Skip to content

feat(examples): account-scoped custom data on Commerce Extensions - #539

Merged
field123 merged 12 commits into
mainfrom
feat/536-account-scoped-custom-data
Sep 30, 2026
Merged

field123 merged 12 commits into
mainfrom
feat/536-account-scoped-custom-data

Conversation

@field123

@field123 field123 commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

What this adds

A new example, examples/commerce-extensions-saved-list: a signed-in shopper saves products to a list stored as Custom API Entries on Commerce Extensions.

The list is the demo. The subject is authorization, and two platform facts set the shape:

  • An implicit (shopper) token is read-only. It cannot create, update or delete a Custom API Entry, so every write goes through a server-side route and the browser never talks to Elastic Path about the saved list.
  • Custom API Entries have no per-entry ownership. Any token that may list entries can list every shopper's entries. account_id is an ordinary string field; nothing in the platform checks it.

Both were confirmed against a real store while building this — see Verification.

Closes #536.

Where the authorization lives

File What it decides
src/lib/account-session.ts Who the caller is. The Account Management Authentication Token Elastic Path issued at login sits in an httpOnly cookie, and every request asks Elastic Path which account that token belongs to. There is no identity of our own to forge. A token that does not resolve to exactly one account is signed out; an Elastic Path outage is IdentityUnavailableError, not "signed out".
src/lib/saved-list-context.ts Resolves that identity into an account id and a store handle. Routes start here, so none can reach the store without one.
src/lib/saved-list.ts Every read and write. Account id is a required argument; reads are filtered on the API and again in memory; a delete re-reads the entry and compares owners first.

An entry owned by someone else answers the same way one that does not exist does, so the delete route cannot be used to discover which entry ids are real.

Verification against a real store

Re-run end to end on 30 Sep, after rebasing onto main (#637 regenerated @epcc-sdk/commerce-extensions), against the integration store with two freshly created account members on different accounts. Driven in a browser against next build && next start.

The platform facts:

  • An unscoped read of the entries returned all three entries across both accounts (two stamped with A's account id, one with B's). Re-confirmed on 30 Sep.
  • The API accepted a DELETE of another account's entry with 204, no ownership check. This is why the ownership re-read in saved-list.ts is load-bearing. (Original run.)
  • A filter value containing ),ge(...) was parsed and changed the query — which is why account ids are rejected unless they match [A-Za-z0-9_-]. (Original run.)

The full flow, 30 Sep:

Step Result
Signed out: /, /saved-list, GET/POST/DELETE on the API products render with no Save buttons; 307 to login; 401, 401, 401
A saves two products 201, 201
Hosts the browser contacted during the session localhost only
Auth cookies visible to page JavaScript none (httpOnly)
A's list both entries
Forged account token cookie (junk, and an unsigned JWT) 401 on all three verbs; /saved-list 307
B's list {"data": []}
B deletes A's entry by id 404, same body as an id that does not exist
B saves a product 201; B's list holds only that entry
A's list afterwards both of A's entries, none of B's
A deletes B's entry by id 404
A deletes own entry 204
Signed-in shopper, endpoint set to a bare hostname configuration error page naming the variable

Also confirmed live in the original run: provisioning is idempotent, immutable: true on account_id is enforced by the platform ("The field 'account_id' is immutable."), string custom fields are filterable, entry values come back flat, page[offset] paging terminates correctly, and a member of two accounts receives one token per account.

Walkthrough

Captured in a browser against the built app and the integration store, signed in as two account members on different accounts.

Signed out — products render, no Save buttons, no list, no errors.

01-signed-out

Shopper A signs in and the Save buttons appear.

02-a-signed-in

A saves two products. Each click is a POST to this application's own route; the browser never talks to Elastic Path about the list.

03-a-saves-two

A's saved list.

04-a-list

Shopper B signs in and sees nothing. The entries exist, and the store's API would return them to a caller that asked without scoping. From B's session, GET returns an empty list and a DELETE naming A's entry id answers 404.

05-b-empty

A's list is untouched, although B has since saved an entry of its own.

06-a-intact

A removes one of its own entries.

07-a-deletes-own

The configuration error page, reporting a bare-hostname endpoint to a signed-in shopper.
08-configuration-error

Bugs the verification caught

A bare-hostname endpoint 500'd every page. Most .env.local files in this repository store NEXT_PUBLIC_EPCC_ENDPOINT_URL as a bare hostname. Every request built from one throws URL is malformed, and the first throw is in middleware. unusableEnvRequirements now reports variables that are set but cannot be used, and middleware checks before the token call.

The configuration error page crashed for a signed-in shopper. The root layout resolves the shopper for the header, and let IdentityUnavailableError escape. With Elastic Path unreachable (or the endpoint unusable), every page crashed, including the page that exists to explain the problem. The header now renders without a session in that case.

The regenerated SDK returns failures instead of throwing them. Since #637, @epcc-sdk/commerce-extensions returns a failed request, network failures included, as { error }. A failed Custom API lookup therefore read as an unprovisioned store ("run the provisioning script"), and a failed entry read as not found. Both now throw: a failed lookup answers 503, and a failed entry read answers 500 rather than a false 404. pnpm provision had the same flaw and now stops on a failed lookup instead of trying to create again.

Sign-in redirected to any URL. The login action redirected to whatever returnUrl the query string carried, so /login?returnUrl=https://evil.example sent a shopper off site straight after a real sign-in. It now falls back to /saved-list for anything that is not a path on this origin. The first version of that fix was itself bypassable: the URL parser resolves dot segments, so /.//evil.example came back as //evil.example, which Next's client follows off site. An independent adversarial review found it; the guard now also rejects any result starting with // or /\. Covered by unit tests, each failing against the earlier guard; not re-driven in the browser. The implicit-token cookie is now also secure in production, as the account token cookie already was.

Store setup

pnpm provision creates the Custom API and its two fields, taking admin credentials from the shell. Running it twice is safe. The README has a ## Store Setup Requirements section; a missing requirement sends the reader to the configuration error page naming what is missing, rather than rendering an empty panel — the spirit of #527, for this example only.

Honest limits, stated in the README

  • The storefront's key is not scoped to these entries. Writing a Custom API Entry needs a client_credentials key, and Elastic Path store keys are not scoped per endpoint, so the key the storefront holds can do more than the saved list needs. What is guaranteed is narrower: the secret never reaches the browser, and the admin credentials used to create the structure are never in a file the application loads. In production you would put the writes behind a service you control.
  • Guest shoppers are out of scope, per the issue.
  • No account switching. A member of two accounts gets one token per account at sign-in; the example takes the first.
  • The example uses the settings endpoint rather than /v2/extensions/{slug} purely because that is the pair the SDK generates a typed request body and filter query for. Both endpoints take the same bearer and the same records; switching changes nothing about the authorization.

Tests

62 tests. pnpm test, pnpm type:check and pnpm build all clean.

  • src/app/api/saved-list/routes.test.ts — the acceptance criteria through the real route handlers, including the cross-account delete.
  • src/lib/saved-list.test.ts — the ownership rules, including the case where the API ignores the filter and returns everything.
  • src/lib/account-session.test.ts — the cookie-to-identity seam: a token that does not resolve is signed out, an outage is not, and the identity bearer is not overwritten by the shared client.
  • src/lib/commerce-extensions-store.test.ts and src/lib/saved-list-context.test.ts — an SDK error response is a failure, not "missing"; each fails against the pre-fix code.
  • src/lib/return-url.test.ts — after sign-in, only a path on this site is followed; absolute, protocol-relative and backslash URLs fall back.
  • src/lib/store-requirements.test.ts — missing and unusable configuration is named, not swallowed.

@vercel

vercel Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

6 Skipped Deployments
Project Deployment Actions Updated
commerce-essentials Ignored Ignored Preview Sep 30, 2026 1:34pm UTC
composable-frontend-algolia Ignored Ignored Sep 30, 2026 1:34pm UTC
composable-frontend-core Ignored Ignored Preview Sep 30, 2026 1:34pm UTC
composable-frontend-docs Ignored Ignored Preview Sep 30, 2026 1:34pm UTC
composable-frontend-simple Ignored Ignored Preview Sep 30, 2026 1:34pm UTC
composable-frontend-subscriptions Ignored Ignored Preview Sep 30, 2026 1:34pm UTC

Request Review

@changeset-bot

changeset-bot Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: f0e40bb

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel
vercel Bot deployed to Preview – commerce-essentials September 16, 2026 12:15 Active
@vercel
vercel Bot had a problem deploying to Preview – commerce-essentials September 16, 2026 12:17 Failure
@field123
field123 force-pushed the feat/536-account-scoped-custom-data branch from adc3689 to a482498 Compare September 16, 2026 12:40
@field123
field123 force-pushed the feat/536-account-scoped-custom-data branch from a482498 to e4e4cf1 Compare September 16, 2026 13:00
@field123

Copy link
Copy Markdown
Collaborator Author

Heads-up: #637 (merged) regenerated @epcc-sdk/commerce-extensions from the published spec, so this example will stop compiling after a rebase onto main. Changes it needs:

  • getAllCustomApis → listCustomApis
  • getAllCustomFields → listCustomFields
  • getAllCustomEntries → listCustomApiEntries
  • createACustomEntry → createACustomApiEntry
  • Path keys are hyphenated: custom_api_id → "custom-api-id", custom_api_entry_id → "custom-api-entry-id"

The create bodies in scripts/provision.ts already send description, which is now required. The full list of changes is in the package's changeset in #637.

field123 and others added 10 commits September 30, 2026 12:50
…tensions

A signed-in shopper saves products to a list stored as Custom API Entries.
The subject is authorization, not the list: an implicit token cannot write a
Custom API Entry, and entries have no per-entry ownership, so every read and
write goes through a server-side route that scopes on the account id from a
signed, httpOnly session cookie.

- src/lib/saved-list.ts holds every ownership rule, over a narrow port, so the
  cross-account read and delete attempts are unit tested without a store
- src/lib/session.ts signs the session cookie so the account id in it cannot
  be edited by the browser
- scripts/provision.ts creates the Custom API and its fields from admin
  credentials in the shell; the storefront never holds them
- the README states what the store must contain, and a missing requirement
  reports on the configuration error page rather than rendering empty

Closes #536
Standards review:
- provisioning script and the running app now read one declaration of the
  Custom API slug and api_type instead of two
- routes share one guard reply via contextErrorResponse
- nowInSeconds no longer named as though it returned an offset
- the Custom API id cache states its lifetime

Spec review:
- add route-level tests: the acceptance criteria are now proven through the
  real handlers, including one shopper's DELETE of another's entry id
- the configuration error page names a missing Custom API instead of showing
  a generic checklist; missingCustomApiRequirement is wired up
- /login and the login action check requirements first, so a missing
  SESSION_SECRET is no longer reported as a wrong password
- route handlers no longer answer a store failure with a bare 500
- list pages through all entries rather than stopping at 100
- README: drop the unsupported claim that the storefront key can be scoped
  lower than the admin key, state plainly what is and is not guaranteed, and
  say the settings endpoint is an SDK typing choice, not a security one
Found by running the example against a real store. Most .env.local files in
this repository store NEXT_PUBLIC_EPCC_ENDPOINT_URL as a bare hostname. Every
request built from one throws "URL is malformed", and the first throw is in
middleware, so every page answered 500 with nothing to tell the reader why.

unusableEnvRequirements reports a variable that is set but cannot be used, and
middleware checks it before it reaches the token call. A bare hostname now
redirects to the configuration error page naming the variable and the absolute
URL to use. envRequirementProblems is what pages call: absent and unusable
together.
The README carries the explanation; the code does not need to repeat it.
Removes 80 comments across the example, 281 deletions against 1 insertion,
with no behaviour change: 39 tests, typecheck and build unchanged.
Explains what the example demonstrates before it explains how to run it: what
a Custom API Entry is, that the platform stores no owner for one, and that an
implicit token cannot write.

Answers the question the old README left open. Custom API Role Policies are
the one native control, and they work per role and per API, not per entry, so
the application must scope entries itself.

Removes the bloat: no bold, no em-dashes, no semicolons, no contractions, no
sentence over 25 words. 184 lines down to 124.
The first review covered only the opening commit. A review of the whole branch
found a bug in the fix that the third commit introduced, plus damage left by
the comment removal.

Confirmed by running the app, not by reading:
- An absent NEXT_PUBLIC_EPCC_ENDPOINT_URL returned 500 on every page. The
  middleware guard only caught an endpoint that was present but malformed.
  endpointProblem now reports absent and malformed, and middleware calls it.

Also fixed:
- Removing the comments left an empty catch in api-client.ts and a
  configuration-error fallback that read as a bug. Both are now expressed in
  code: parseCredentials returns null, and customApiIsKnownMissing says in its
  name why a failed lookup is not a missing Custom API.
- The entry pagination loop had no bound. If the API ignored page[offset] it
  would never exit. It now stops after 50 pages and reports why.
- The delete route duplicated the 500 branch and answered 500 where the other
  route answered 400. Both now share failed().
- The implicit token cookie now sets httpOnly.
- The README claimed the configuration error page names any missing store
  requirement. It names the environment variables, the endpoint URL and the
  Custom API. It now says which rows it cannot check.

42 tests, up from 39.
The example invented a third auth pattern: an HMAC-signed cookie holding an
account id, plus a SESSION_SECRET to sign it. That was unnecessary.

Elastic Path already issues an account management authentication token at
sign-in, and scopes GET /v2/accounts to the account that token belongs to.
Verified against a real store with two members on two accounts:

- member A's token returns exactly one account, A's
- member B's token returns exactly one account, B's
- a token with one character changed is rejected
- garbage and an empty header are rejected

So the example now stores that token in an httpOnly cookie and asks Elastic
Path who is asking. Deletes src/lib/session.ts, its tests, and SESSION_SECRET.
The cookie concepts are now the same two the sibling examples use, with
httpOnly added.

The lookup uses an implicit token rather than the key with a secret, and that
choice is deliberate. An implicit token alone returns 401, so a request that
lost the account token fails closed. The key with a secret alone returns every
account in the store, which fails open. Both were measured.

Verified end to end over HTTP with no middleware cookie present: signed out
401, A saves 201, B's list empty, B deleting A's entry 404, forged token 401,
A deleting its own entry 204.
…ty bearer

A third review found that the identity call used the shared shopper client.
src/lib/api-client.ts installs an interceptor on that singleton which sets the
Authorization header from the _store_ep_credentials cookie, so it overwrote the
server-minted implicit token the call passed in.

Reproduced: a page render with a valid account token and a deliberately broken
credentials cookie showed "Sign in". The bearer that identifies the shopper was
therefore chosen by the browser, and the fail-closed reasoning in the previous
commit described a path the code did not take.

The identity call now uses its own client, which carries no interceptor. Same
request now renders the saved list.

Also from that review:
- An outage no longer reads as signed out. resolveAccount throws
  IdentityUnavailableError, and the routes answer 503 rather than 401.
- An expired account token sends the shopper to sign in again, instead of
  leaving the save button reporting "That did not work" forever.
- resolveAccount takes its dependencies as arguments, so the cookie to identity
  seam has unit tests again. Seven of them, including one asserting the bearer
  survives, which is the defect above.

Measured, not assumed: a member of two accounts receives one token per account,
and each token scopes the account lookup to its own account. The exactly-one
check is therefore safe. That limitation is now in the README.

41 tests.
#637 renamed the list and create functions, hyphenated the path keys and
made page parameters plain numbers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The regenerated commerce-extensions client returns failed requests,
network failures included, as { error } rather than throwing. A failed
Custom API lookup therefore read as an unprovisioned store, and a failed
entry read as not found. Both now throw, and the saved list context
answers 503 rather than telling the shopper to run provisioning.

The root layout also let IdentityUnavailableError escape, so a signed-in
shopper got a crash on every page when Elastic Path was unreachable,
including the configuration error page that exists to explain it. The
header now renders without a session in that case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@field123
field123 force-pushed the feat/536-account-scoped-custom-data branch from 828e975 to ce12bcc Compare September 30, 2026 13:14
field123 and others added 2 commits September 30, 2026 14:18
The login action redirected to whatever returnUrl the query string
carried, so /login?returnUrl=https://evil.example sent a shopper off site
straight after a real sign-in. It now falls back to /saved-list for
anything that is not a path on this origin.

Also marks the implicit credentials cookie secure in production, as the
account token cookie already was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The URL parser resolves dot segments before the origin check, so
/.//evil.example came back as //evil.example: a protocol-relative URL
that Next's client navigates off site. Reject any result that starts
with // or /\.

The provisioning script now treats a failed lookup as a failure rather
than as "not provisioned", as the storefront already does, and both
look the Custom API up by matching slug instead of taking the first
result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@field123
field123 merged commit 9a6bdc6 into main Sep 30, 2026
7 checks passed
@field123
field123 deleted the feat/536-account-scoped-custom-data branch September 30, 2026 13:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

New example: account-scoped custom data on Commerce Extensions

1 participant