Code-read on 2026-09-10 against f303cc8 (local) / b23b7bc (origin/main); the finding is the same on both. No requests were sent to the deployed worker. Full note: dot-do/apis docs/research/payments-do-auth-check.md.
Finding
Every REST route in src/index.ts except POST /webhooks calls getStripe().<resource>.<op>() with no check of who is calling. fetch (src/index.ts:623-652) goes matchRoute → handler → Stripe; there is no middleware, header inspection, or allowlist in between. The only 401 the worker can emit is Stripe rejecting the platform key (:116-124). src/env.d.ts:16-34 declares no caller-side secret or JWKS, so there is no auth mechanism even in configuration. The worker is routed publicly at payments.do/* (wrangler.jsonc:12-17), and the header comment's intent ("REST API for service binding consumers", :4) is not enforced.
getConnectOptions (:144-158) forwards Stripe-Account from the request header, or stripeAccount from the body, verbatim — so an unauthenticated caller can act as the platform on any connected account. The StripeDO rate limiter and method whitelist in src/stripe.ts are not wired (no durable_objects binding, never imported).
Routes
| route |
auth |
Stripe op |
money-moving? |
reads customer data? |
public? |
POST /customers (:251) |
none |
customers.create |
no |
writes PII |
yes |
GET /customers/:id (:258) |
none |
customers.retrieve |
no |
yes |
yes |
PATCH /customers/:id (:264) |
none |
customers.update (body pass-through) |
indirectly |
writes PII |
yes |
POST /subscriptions (:273) |
none |
subscriptions.create |
yes |
— |
yes |
GET /subscriptions/:id (:280) |
none |
subscriptions.retrieve |
no |
yes |
yes |
DELETE /subscriptions/:id (:286) |
none |
subscriptions.cancel |
yes |
— |
yes |
PATCH /subscriptions/:id (:292) |
none |
subscriptions.update (body pass-through) |
yes |
— |
yes |
POST /subscriptions/:id/pause / resume (:300, :313) |
none |
subscriptions.update |
yes |
— |
yes |
POST /charges (:327) |
none |
charges.create |
yes |
— |
yes |
GET /charges/:id (:341) |
none |
charges.retrieve |
no |
yes (card last4, billing details) |
yes |
POST /invoices (:349) |
none |
invoices.create |
yes |
— |
yes |
GET /invoices/:id (:356) |
none |
invoices.retrieve |
no |
yes |
yes |
POST /invoices/:id/finalize / void (:362, :368) |
none |
finalizeInvoice / voidInvoice |
yes |
— |
yes |
POST /products, PATCH /products/:id (:376, :389) |
none |
products.create/update |
catalog write |
— |
yes |
POST /prices (:398) |
none |
prices.create |
catalog write |
— |
yes |
GET /products/:id, GET /prices/:id (:383, :405) |
none |
retrieve |
no |
no |
yes |
POST /refunds (:413) |
none |
refunds.create |
yes (outbound) |
— |
yes |
POST /import (:422-469) |
none |
lists ALL customers, subscriptions, products, prices, invoices on the platform account; emits to EVENTS under a caller-chosen ns (:424) |
no |
yes, in bulk |
yes |
GET /checkout (:477) |
none (public buyer front by design; amount never from the query string) |
checkout.sessions.create |
session only |
no |
yes, intended |
POST /webhooks (:520) |
Stripe-Signature via constructEvent (:521-538) |
event processing |
no |
no |
yes, intended |
unmatched paths (:641-650) |
none |
intended capnweb RPC over the whole Stripe SDK; dead under rpc.do@0.2.4 (RPC() is a client factory, .fetch → transport.call on the Stripe object → TypeError → 500) |
— |
— |
returns 500 |
Recommended fix
Preferred (ax ADR 0023 §2): require a short-lived id.org.ai-issued JWT with aud=payments.do (RFC 8707) on every route except /, /checkout and /webhooks, verified against id.org.ai's JWKS pinned in deploy config. Derive tenant and the permitted Stripe-Account from the token's claims, never from the request header or body.
Smallest safe interim change: a PUBLIC_PATHS = ['/', '/checkout', '/webhooks'] allowlist at src/index.ts:629 (before matchRoute) that answers 404 for every other path unless the request arrived over a service binding; delete POST /import and the RPC fallback (:81-89, :641-650); drop the Stripe-Account/stripeAccount pivot from unauthenticated input. Service-binding-only exposure of the pass-through routes matches the stated intent (CLAUDE.md:21-26); /checkout and /webhooks must stay on the public hostname, so the route itself cannot simply be removed.
Code-read on 2026-09-10 against
f303cc8(local) /b23b7bc(origin/main); the finding is the same on both. No requests were sent to the deployed worker. Full note:dot-do/apisdocs/research/payments-do-auth-check.md.Finding
Every REST route in
src/index.tsexceptPOST /webhookscallsgetStripe().<resource>.<op>()with no check of who is calling.fetch(src/index.ts:623-652) goesmatchRoute→ handler → Stripe; there is no middleware, header inspection, or allowlist in between. The only 401 the worker can emit is Stripe rejecting the platform key (:116-124).src/env.d.ts:16-34declares no caller-side secret or JWKS, so there is no auth mechanism even in configuration. The worker is routed publicly atpayments.do/*(wrangler.jsonc:12-17), and the header comment's intent ("REST API for service binding consumers",:4) is not enforced.getConnectOptions(:144-158) forwardsStripe-Accountfrom the request header, orstripeAccountfrom the body, verbatim — so an unauthenticated caller can act as the platform on any connected account. TheStripeDOrate limiter and method whitelist insrc/stripe.tsare not wired (nodurable_objectsbinding, never imported).Routes
POST /customers(:251)customers.createGET /customers/:id(:258)customers.retrievePATCH /customers/:id(:264)customers.update(body pass-through)POST /subscriptions(:273)subscriptions.createGET /subscriptions/:id(:280)subscriptions.retrieveDELETE /subscriptions/:id(:286)subscriptions.cancelPATCH /subscriptions/:id(:292)subscriptions.update(body pass-through)POST /subscriptions/:id/pause/resume(:300,:313)subscriptions.updatePOST /charges(:327)charges.createGET /charges/:id(:341)charges.retrievePOST /invoices(:349)invoices.createGET /invoices/:id(:356)invoices.retrievePOST /invoices/:id/finalize/void(:362,:368)finalizeInvoice/voidInvoicePOST /products,PATCH /products/:id(:376,:389)products.create/updatePOST /prices(:398)prices.createGET /products/:id,GET /prices/:id(:383,:405)POST /refunds(:413)refunds.createPOST /import(:422-469)EVENTSunder a caller-chosenns(:424)GET /checkout(:477)checkout.sessions.createPOST /webhooks(:520)constructEvent(:521-538):641-650)rpc.do@0.2.4(RPC()is a client factory,.fetch→transport.callon the Stripe object → TypeError → 500)Recommended fix
Preferred (ax ADR 0023 §2): require a short-lived id.org.ai-issued JWT with
aud=payments.do(RFC 8707) on every route except/,/checkoutand/webhooks, verified against id.org.ai's JWKS pinned in deploy config. Derive tenant and the permittedStripe-Accountfrom the token's claims, never from the request header or body.Smallest safe interim change: a
PUBLIC_PATHS = ['/', '/checkout', '/webhooks']allowlist atsrc/index.ts:629(beforematchRoute) that answers 404 for every other path unless the request arrived over a service binding; deletePOST /importand the RPC fallback (:81-89,:641-650); drop theStripe-Account/stripeAccountpivot from unauthenticated input. Service-binding-only exposure of the pass-through routes matches the stated intent (CLAUDE.md:21-26);/checkoutand/webhooksmust stay on the public hostname, so the route itself cannot simply be removed.