RepositoryContext already wired to the right schema.
Four ways to authenticate
| Credential | Carries | Resolves to | Notes |
|---|---|---|---|
| Session | httpOnly cookie | a Member (tenant + role) | Dashboard only. 10-minute idle timeout, sliding. |
| Secret key | Authorization: Bearer sk_… | a tenant + mode + full scope | Server-to-server. Prefix picks the mode. Hashed at rest, lastUsedAt written fire-and-forget. |
| OAuth token | Authorization: Bearer … (JWT) | a tenant + mode + scope | client_credentials grant from a cid_/csec_ pair, 60-minute expiry. |
| Identity token | Authorization: Bearer … (JWT) | a CustomerIdentity | Customer-facing. Issued after WhatsApp OTP. Marked keyId: identity so it can never pass tenant auth. |
The middleware chain on /v1
The public merchant API is the most interesting chain because it does the most work before the controller sees anything:
By the time SubscriptionController.create runs, it calls this.repos(req).subscriptions.create(...) and that repository is already bound to (tenantId, mode). The controller never sees a raw database client, never sees the other tenants’ data, and never constructs a query that could.
Tenant scoping is the load-bearing wall
RepositoryContext is the object that makes cross-tenant leakage structurally hard:
Each repository is constructed with the tenantId baked in and every query it issues includes tenantId in the where clause. There is no repository method that omits it. A new resource added to the system gets a repository, gets added to RepositoryContext, and inherits the scoping for free.
Idempotency, scoped to the tenant
Mutating endpoints accept anIdempotency-Key. The record is keyed (tenantId, key) — a compound unique. This matters: a global key namespace would let one merchant’s key collide with another’s, and an attacker could probe for replays. Per-tenant scoping closes that.
A real bug this design caught early: the idempotency table originally had a global-unique
key. That’s a cross-tenant replay vector — fixed to @@unique([tenantId, key]). The security model page documents this and the other findings from the audit pass.Mode is never optional, never a default
Four-fifths of the bugs in multi-mode billing systems come frommode defaulting silently. Duro never defaults it:
- Keys carry it in the prefix (
sk_test_vssk_live_). - Request-scoped code reads it from
req.auth.modeand passes it todata(mode). - Public token lookups derive it from the token itself — a checkout token is
cs_<mode>_…, so the mode is parsed from the prefix, not guessed by scanning both schemas. - The customer portal takes it explicitly — the dashboard’s mode switcher sends the current mode; the portal never silently lists both.
- Background scanners loop both — and only background scanners, because a cron has no “current mode.”