feat(examples): account-scoped custom data on Commerce Extensions - #539
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. 6 Skipped Deployments
|
|
adc3689 to
a482498
Compare
a482498 to
e4e4cf1
Compare
|
Heads-up: #637 (merged) regenerated
The create bodies in |
…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>
828e975 to
ce12bcc
Compare
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>
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:
account_idis 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
src/lib/account-session.tshttpOnlycookie, 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 isIdentityUnavailableError, not "signed out".src/lib/saved-list-context.tssrc/lib/saved-list.tsAn 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 againstnext build && next start.The platform facts:
DELETEof another account's entry with 204, no ownership check. This is why the ownership re-read insaved-list.tsis load-bearing. (Original run.)),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:
/,/saved-list,GET/POST/DELETEon the API307to login;401,401,401201,201localhostonlyhttpOnly)401on all three verbs;/saved-list307{"data": []}404, same body as an id that does not exist201; B's list holds only that entry404204Also confirmed live in the original run: provisioning is idempotent,
immutable: trueonaccount_idis 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.
Shopper A signs in and the Save buttons appear.
A saves two products. Each click is a
POSTto this application's own route; the browser never talks to Elastic Path about the list.A's saved 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,
GETreturns an empty list and aDELETEnaming A's entry id answers404.A's list is untouched, although B has since saved an entry of its own.
A removes one of its own entries.
The configuration error page, reporting a bare-hostname endpoint to a signed-in shopper.

Bugs the verification caught
A bare-hostname endpoint 500'd every page. Most
.env.localfiles in this repository storeNEXT_PUBLIC_EPCC_ENDPOINT_URLas a bare hostname. Every request built from one throwsURL is malformed, and the first throw is in middleware.unusableEnvRequirementsnow 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
IdentityUnavailableErrorescape. 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-extensionsreturns 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 answers503, and a failed entry read answers500rather than a false404.pnpm provisionhad 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
returnUrlthe query string carried, so/login?returnUrl=https://evil.examplesent a shopper off site straight after a real sign-in. It now falls back to/saved-listfor 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.examplecame 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 alsosecurein production, as the account token cookie already was.Store setup
pnpm provisioncreates the Custom API and its two fields, taking admin credentials from the shell. Running it twice is safe. The README has a## Store Setup Requirementssection; 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
client_credentialskey, 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./v2/extensions/{slug}purely because that is the pair the SDK generates a typed request body andfilterquery for. Both endpoints take the same bearer and the same records; switching changes nothing about the authorization.Tests
62 tests.
pnpm test,pnpm type:checkandpnpm buildall 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.tsandsrc/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.