create-ailk 0.18.0 → 0.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +11 -3
- package/dist/category-profile.d.ts +10 -0
- package/dist/category-profile.js +21 -0
- package/dist/derive-architecture.d.ts +5 -1
- package/dist/derive-architecture.js +8 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/license-key-files.d.ts +6 -3
- package/dist/lib/license-key-files.js +8 -2
- package/dist/lib/scaffold-render-sweep.d.ts +32 -0
- package/dist/lib/scaffold-render-sweep.js +76 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +128 -3
- package/dist/programmatic.js +11 -1
- package/dist/site-roster.js +21 -4
- package/dist/spoke-checkout.d.ts +11 -3
- package/dist/spoke-checkout.js +62 -12
- package/dist/starter-grid.d.ts +1 -1
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +170 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +7 -2
- package/dist/topology.js +88 -3
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +31 -7
- package/templates/apps/api/LICENSE +6 -5
- package/templates/apps/api/NOTICE +2 -0
- package/templates/apps/api/jest.config.cjs +4 -29
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +17 -0
- package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
- package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
- package/templates/apps/api/src/lib/consent-store.ts +335 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +176 -3
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +272 -39
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
- package/templates/apps/api/src/middleware/auth.ts +214 -46
- package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
- package/templates/apps/api/src/routes/checkout/session.ts +3 -3
- package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
- package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
- package/templates/apps/api/src/routes/consent/index.ts +18 -0
- package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
- package/templates/apps/api/src/routes/leads/index.ts +19 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
- package/templates/apps/api/src/server.ts +218 -16
- package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +173 -4
- package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
- package/templates/apps/api/src/services/consent.ts +488 -3
- package/templates/apps/api/src/services/lead-routing.ts +135 -14
- package/templates/apps/api/src/services/person.ts +501 -1
- package/templates/apps/api/src/services/project-listings.ts +7 -2
- package/templates/apps/api/src/services/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/.env.example +10 -0
- package/templates/apps/mcp/CLAUDE.md +9 -4
- package/templates/apps/mcp/LICENSE +6 -5
- package/templates/apps/mcp/NOTICE +2 -0
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
- package/templates/apps/mcp/jest.config.cjs +3 -44
- package/templates/apps/mcp/src/abuse/index.ts +117 -21
- package/templates/apps/mcp/src/boot.ts +5 -23
- package/templates/apps/mcp/src/http-transport.ts +93 -73
- package/templates/apps/mcp/src/index.ts +2 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- package/templates/apps/web/LICENSE +6 -5
- package/templates/apps/web/NOTICE +2 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +302 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +724 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +346 -41
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +232 -29
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +140 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +409 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +65 -380
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +188 -54
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +174 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +30 -4
- package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
- package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
- package/templates/apps/web/app/[locale]/layout.tsx +23 -4
- package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
- package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
- package/templates/apps/web/app/robots.txt/route.ts +10 -4
- package/templates/apps/web/auth.ts +25 -2
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/account-config.ts +188 -52
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
- package/templates/apps/web/lib/hub-session/index.ts +1 -0
- package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
- package/templates/apps/web/lib/hub-session/seal.ts +13 -9
- package/templates/apps/web/lib/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +194 -33
- package/templates/database/CHANGELOG.md +37 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
- package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
- package/templates/database/__tests__/member-area.db.test.ts +15 -3
- package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
- package/templates/database/__tests__/member-contract.db.test.ts +413 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/__tests__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +166 -25
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
- package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
- package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
- package/templates/database/package.json +3 -2
- package/templates/database/seed.ts +34 -2
- package/versions.json +36 -36
- package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
- package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
- package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
- package/templates/apps/api/src/__tests__/server.test.ts +0 -591
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
- package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
- package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
- package/templates/database/__tests__/build.test.ts +0 -131
|
@@ -33,10 +33,19 @@
|
|
|
33
33
|
* (`lib/hub-sign-in-store.ts`). It is admitted only beside the SAME
|
|
34
34
|
* spoke's key (`x-ailk-spoke-key`), only while its grant is unrevoked and
|
|
35
35
|
* unexpired, its client enabled and its person's email verified, and only
|
|
36
|
-
* on `HUB_SESSION_ROUTES` — an exact list of
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* `
|
|
36
|
+
* on `HUB_SESSION_ROUTES` — an exact list of METHOD + registered URL
|
|
37
|
+
* pairs — the credential's own `me` and the member routes, 20 in all
|
|
38
|
+
* (#8220); any other route is 404 (never 403, so the account area's
|
|
39
|
+
* `read()` loads "nothing for this caller"). It acts as its person
|
|
40
|
+
* (`request.userId`) and carries `request.hubSession` — never
|
|
41
|
+
* `request.sessionTenant` — and its request logger names the spoke
|
|
42
|
+
* (`spokeId`). Each person is limited, at each spoke, to 60 requests and
|
|
43
|
+
* 10 writes a minute and 30 writes an hour — a fixed hour window from
|
|
44
|
+
* the bucket's first admitted write (#8220 D2, SEC-8169-4): the bucket is
|
|
45
|
+
* keyed on the VERIFIED spoke and person, never a header, and charged
|
|
46
|
+
* only after every check above passes, so a refused credential or an
|
|
47
|
+
* off-list route never gets one. The write limits refuse only writes,
|
|
48
|
+
* and a request the budget refuses is not counted.
|
|
40
49
|
*
|
|
41
50
|
* Every authenticated request carries `request.actor` — the person, or the
|
|
42
51
|
* agent and the person it acts for — and its request/reply loggers are
|
|
@@ -141,21 +150,47 @@ export type HubSession = {
|
|
|
141
150
|
};
|
|
142
151
|
|
|
143
152
|
/**
|
|
144
|
-
* The only routes a hub-issued credential reaches (#7894 D6): an
|
|
145
|
-
* method + registered URL, never a prefix
|
|
146
|
-
* by a reviewed edit of this list
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
153
|
+
* The only routes a hub-issued credential reaches (#7894 D6, #8220 D1): an
|
|
154
|
+
* EXACT list of method + registered URL, never a prefix and never computed,
|
|
155
|
+
* so a route added later joins only by a reviewed edit of this list
|
|
156
|
+
* (SEC-7894-3). Four groups, in this order: the credential's own `me` and
|
|
157
|
+
* #7877's three member reads; #8169's eight member reads; #8170's six member
|
|
158
|
+
* writes (roadmap §7.2 row order); #8438's member consents read and write.
|
|
159
|
+
* `GET /v1/consent/platform` is not admitted — it is the Admin owner's own
|
|
160
|
+
* consent to Working Theory, on the hub's cookie session. The member profile write IS admitted — it
|
|
161
|
+
* is the person's own account, which roadmap D9 saves through it. Not
|
|
162
|
+
* admitted, deliberately: `GET /v1/waitlist-signups/me`,
|
|
163
|
+
* `GET /v1/flow-checkouts/me`, `GET /v1/subscription/me` and
|
|
164
|
+
* `POST /v1/subscription/portal-session` (they answer for the HUB's site and
|
|
165
|
+
* the person's platform billing, SEC-7894-1, -2), the token routes and the
|
|
166
|
+
* hub's own-account `/v1/user/*` routes, `PATCH /v1/user/profile` among them.
|
|
152
167
|
*/
|
|
153
|
-
export const HUB_SESSION_ROUTES: readonly string[] = [
|
|
168
|
+
export const HUB_SESSION_ROUTES: readonly string[] = Object.freeze([
|
|
154
169
|
"GET /v1/hub-sign-in/me",
|
|
155
170
|
"GET /v1/member/pieces",
|
|
156
171
|
"GET /v1/member/messages",
|
|
157
172
|
"GET /v1/member/messages/:id/file",
|
|
158
|
-
|
|
173
|
+
"GET /v1/member/purchases",
|
|
174
|
+
"GET /v1/member/appointments",
|
|
175
|
+
"GET /v1/member/artifacts",
|
|
176
|
+
"GET /v1/member/artifacts/:pieceId/versions/:version/file",
|
|
177
|
+
"GET /v1/member/meeting-types",
|
|
178
|
+
"GET /v1/member/availability",
|
|
179
|
+
"GET /v1/member/request-form",
|
|
180
|
+
"GET /v1/member/billing",
|
|
181
|
+
"POST /v1/member/artifacts/:pieceId/review",
|
|
182
|
+
"POST /v1/member/bookings",
|
|
183
|
+
"POST /v1/member/requests",
|
|
184
|
+
"POST /v1/member/messages",
|
|
185
|
+
"PATCH /v1/member/profile",
|
|
186
|
+
"GET /v1/member/profile/contact",
|
|
187
|
+
"PATCH /v1/member/profile/contact",
|
|
188
|
+
"POST /v1/member/deletion-request",
|
|
189
|
+
// #8438 D6 — the member's own consents. `GET /v1/consent/platform` is NOT
|
|
190
|
+
// admitted: those are the Admin owner's, on the hub's own cookie session.
|
|
191
|
+
"GET /v1/member/consents",
|
|
192
|
+
"POST /v1/member/consents",
|
|
193
|
+
]);
|
|
159
194
|
|
|
160
195
|
/**
|
|
161
196
|
* Who made a request (#3591 D9). A cookie session and a personal `ApiToken`
|
|
@@ -311,12 +346,13 @@ function bindActor(
|
|
|
311
346
|
reply.log = request.log;
|
|
312
347
|
}
|
|
313
348
|
|
|
314
|
-
// ── The per-
|
|
349
|
+
// ── The per-caller limits (#3591 D4 step 5, SEC-3591-04/-14; #8220 D2) ──────
|
|
315
350
|
//
|
|
316
351
|
// In the handler, not `onRequest`: the shared limiter runs before any
|
|
317
|
-
// identity exists, and a bucket must only ever be keyed on a VERIFIED
|
|
318
|
-
//
|
|
319
|
-
// `routes/subscription/portal.ts`'s per-user budget.
|
|
352
|
+
// identity exists, and a bucket must only ever be keyed on a VERIFIED id, so
|
|
353
|
+
// an invented key or credential never gets one. The shape of
|
|
354
|
+
// `routes/subscription/portal.ts`'s per-user budget. An agent and a person
|
|
355
|
+
// never share a map.
|
|
320
356
|
|
|
321
357
|
const AGENT_WINDOW_MS = 60_000;
|
|
322
358
|
const AGENT_REQUESTS_PER_WINDOW = 60;
|
|
@@ -324,53 +360,159 @@ const AGENT_WRITES_PER_WINDOW = 10;
|
|
|
324
360
|
/** Cap on distinct buckets — the oldest is evicted at capacity (DEF-011's shape). */
|
|
325
361
|
const MAX_AGENT_BUCKETS = 5_000;
|
|
326
362
|
|
|
327
|
-
|
|
328
|
-
const
|
|
363
|
+
const HUB_SESSION_WINDOW_MS = 60_000;
|
|
364
|
+
const HUB_SESSION_REQUESTS_PER_WINDOW = 60;
|
|
365
|
+
const HUB_SESSION_WRITES_PER_WINDOW = 10;
|
|
366
|
+
const HUB_SESSION_WRITE_HOUR_MS = 3_600_000;
|
|
367
|
+
const HUB_SESSION_WRITES_PER_HOUR = 30;
|
|
368
|
+
const MAX_HUB_SESSION_BUCKETS = 5_000;
|
|
369
|
+
|
|
370
|
+
type CallerBucket = {
|
|
371
|
+
windowStartMs: number;
|
|
372
|
+
requests: number;
|
|
373
|
+
writes: number;
|
|
374
|
+
/** The hourly write window — set by the bucket's first admitted write. */
|
|
375
|
+
hourStartMs: number | null;
|
|
376
|
+
hourWrites: number;
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
type CallerLimits = {
|
|
380
|
+
windowMs: number;
|
|
381
|
+
maxBuckets: number;
|
|
382
|
+
requests: number;
|
|
383
|
+
writes: number;
|
|
384
|
+
/** An hourly write ceiling — the hub-credential path's alone. */
|
|
385
|
+
writesPerHour?: { limit: number; windowMs: number };
|
|
386
|
+
/** When true, the write limits refuse only writes; a read meets only `requests`. */
|
|
387
|
+
refuseOnlyWrites: boolean;
|
|
388
|
+
/** When true, a refused request still counts toward every limit. */
|
|
389
|
+
countRefused: boolean;
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const AGENT_LIMITS: CallerLimits = {
|
|
393
|
+
windowMs: AGENT_WINDOW_MS,
|
|
394
|
+
maxBuckets: MAX_AGENT_BUCKETS,
|
|
395
|
+
requests: AGENT_REQUESTS_PER_WINDOW,
|
|
396
|
+
writes: AGENT_WRITES_PER_WINDOW,
|
|
397
|
+
refuseOnlyWrites: false,
|
|
398
|
+
countRefused: true,
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const HUB_SESSION_LIMITS: CallerLimits = {
|
|
402
|
+
windowMs: HUB_SESSION_WINDOW_MS,
|
|
403
|
+
maxBuckets: MAX_HUB_SESSION_BUCKETS,
|
|
404
|
+
requests: HUB_SESSION_REQUESTS_PER_WINDOW,
|
|
405
|
+
writes: HUB_SESSION_WRITES_PER_WINDOW,
|
|
406
|
+
writesPerHour: {
|
|
407
|
+
limit: HUB_SESSION_WRITES_PER_HOUR,
|
|
408
|
+
windowMs: HUB_SESSION_WRITE_HOUR_MS,
|
|
409
|
+
},
|
|
410
|
+
refuseOnlyWrites: true,
|
|
411
|
+
countRefused: false,
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const agentBuckets = new Map<string, CallerBucket>();
|
|
415
|
+
const hubSessionBuckets = new Map<string, CallerBucket>();
|
|
329
416
|
|
|
330
417
|
/** Test-only reset. Not intended for production use. */
|
|
331
418
|
export function __resetAgentRateBuckets(): void {
|
|
332
419
|
agentBuckets.clear();
|
|
333
420
|
}
|
|
334
421
|
|
|
422
|
+
/** Test-only reset. Not intended for production use. */
|
|
423
|
+
export function __resetHubSessionRateBuckets(): void {
|
|
424
|
+
hubSessionBuckets.clear();
|
|
425
|
+
}
|
|
426
|
+
|
|
335
427
|
function isWrite(method: string): boolean {
|
|
336
428
|
const m = method.toUpperCase();
|
|
337
429
|
return m !== "GET" && m !== "HEAD";
|
|
338
430
|
}
|
|
339
431
|
|
|
340
|
-
/**
|
|
341
|
-
function
|
|
342
|
-
|
|
432
|
+
/** A bucket no window still holds — the eviction pass may drop it. */
|
|
433
|
+
function isStale(
|
|
434
|
+
bucket: CallerBucket,
|
|
435
|
+
now: number,
|
|
436
|
+
limits: CallerLimits,
|
|
437
|
+
): boolean {
|
|
438
|
+
if (now - bucket.windowStartMs < limits.windowMs) return false;
|
|
439
|
+
return (
|
|
440
|
+
bucket.hourStartMs === null ||
|
|
441
|
+
limits.writesPerHour === undefined ||
|
|
442
|
+
now - bucket.hourStartMs >= limits.writesPerHour.windowMs
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Charge one request to `key`'s bucket. `undefined` when admitted; the
|
|
448
|
+
* seconds to wait — to the end of the latest window that refused, at least
|
|
449
|
+
* 1 — when over budget.
|
|
450
|
+
*/
|
|
451
|
+
function chargeCallerBucket(
|
|
452
|
+
buckets: Map<string, CallerBucket>,
|
|
453
|
+
key: string,
|
|
343
454
|
method: string,
|
|
344
455
|
now: number,
|
|
456
|
+
limits: CallerLimits,
|
|
345
457
|
): number | undefined {
|
|
346
|
-
let bucket =
|
|
347
|
-
if (!bucket || now - bucket.windowStartMs >=
|
|
348
|
-
if (!
|
|
349
|
-
for (const [
|
|
350
|
-
if (
|
|
458
|
+
let bucket = buckets.get(key);
|
|
459
|
+
if (!bucket || now - bucket.windowStartMs >= limits.windowMs) {
|
|
460
|
+
if (!bucket && buckets.size >= limits.maxBuckets) {
|
|
461
|
+
for (const [k, b] of buckets) {
|
|
462
|
+
if (isStale(b, now, limits)) buckets.delete(k);
|
|
351
463
|
}
|
|
352
|
-
if (
|
|
353
|
-
const oldest =
|
|
354
|
-
if (oldest !== undefined)
|
|
464
|
+
if (buckets.size >= limits.maxBuckets) {
|
|
465
|
+
const oldest = buckets.keys().next().value;
|
|
466
|
+
if (oldest !== undefined) buckets.delete(oldest);
|
|
355
467
|
}
|
|
356
468
|
}
|
|
357
|
-
// Re-insert, so Map order stays oldest-window-first for the eviction
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
469
|
+
// Re-insert, so Map order stays oldest-window-first for the eviction
|
|
470
|
+
// above. A new minute keeps the hour's start and count.
|
|
471
|
+
buckets.delete(key);
|
|
472
|
+
bucket = {
|
|
473
|
+
windowStartMs: now,
|
|
474
|
+
requests: 0,
|
|
475
|
+
writes: 0,
|
|
476
|
+
hourStartMs: bucket?.hourStartMs ?? null,
|
|
477
|
+
hourWrites: bucket?.hourWrites ?? 0,
|
|
478
|
+
};
|
|
479
|
+
buckets.set(key, bucket);
|
|
361
480
|
}
|
|
362
|
-
|
|
363
|
-
if (isWrite(method)) bucket.writes += 1;
|
|
481
|
+
const hour = limits.writesPerHour;
|
|
364
482
|
if (
|
|
365
|
-
|
|
366
|
-
bucket.
|
|
483
|
+
hour !== undefined &&
|
|
484
|
+
bucket.hourStartMs !== null &&
|
|
485
|
+
now - bucket.hourStartMs >= hour.windowMs
|
|
367
486
|
) {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
Math.ceil((bucket.windowStartMs + AGENT_WINDOW_MS - now) / 1_000),
|
|
371
|
-
);
|
|
487
|
+
bucket.hourStartMs = null;
|
|
488
|
+
bucket.hourWrites = 0;
|
|
372
489
|
}
|
|
373
|
-
|
|
490
|
+
|
|
491
|
+
const write = isWrite(method);
|
|
492
|
+
const requests = bucket.requests + 1;
|
|
493
|
+
const writes = bucket.writes + (write ? 1 : 0);
|
|
494
|
+
const hourWrites = bucket.hourWrites + (write ? 1 : 0);
|
|
495
|
+
const minuteEnd = bucket.windowStartMs + limits.windowMs;
|
|
496
|
+
const refusedUntil: number[] = [];
|
|
497
|
+
if (requests > limits.requests) refusedUntil.push(minuteEnd);
|
|
498
|
+
if (write || !limits.refuseOnlyWrites) {
|
|
499
|
+
if (writes > limits.writes) refusedUntil.push(minuteEnd);
|
|
500
|
+
if (write && hour !== undefined && hourWrites > hour.limit) {
|
|
501
|
+
refusedUntil.push((bucket.hourStartMs ?? now) + hour.windowMs);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const refused = refusedUntil.length > 0;
|
|
506
|
+
if (!refused || limits.countRefused) {
|
|
507
|
+
bucket.requests = requests;
|
|
508
|
+
bucket.writes = writes;
|
|
509
|
+
if (write && hour !== undefined) {
|
|
510
|
+
bucket.hourWrites = hourWrites;
|
|
511
|
+
bucket.hourStartMs ??= now;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (!refused) return undefined;
|
|
515
|
+
return Math.max(1, Math.ceil((Math.max(...refusedUntil) - now) / 1_000));
|
|
374
516
|
}
|
|
375
517
|
|
|
376
518
|
/**
|
|
@@ -412,7 +554,13 @@ async function authenticateAgent(
|
|
|
412
554
|
onBehalfOfUserId: personId,
|
|
413
555
|
});
|
|
414
556
|
|
|
415
|
-
const retryAfter =
|
|
557
|
+
const retryAfter = chargeCallerBucket(
|
|
558
|
+
agentBuckets,
|
|
559
|
+
agentId,
|
|
560
|
+
request.method,
|
|
561
|
+
now,
|
|
562
|
+
AGENT_LIMITS,
|
|
563
|
+
);
|
|
416
564
|
if (retryAfter !== undefined) {
|
|
417
565
|
reply.code(429).header("Retry-After", String(retryAfter)).send(RATE_LIMITED);
|
|
418
566
|
return;
|
|
@@ -440,7 +588,9 @@ async function authenticateAgent(
|
|
|
440
588
|
/**
|
|
441
589
|
* The hub-credential path (#7894 D6). The credential is never logged and
|
|
442
590
|
* never echoed: every refusal is a fixed body. (1)-(3) failing is the one 401;
|
|
443
|
-
* (4), the route not being on `HUB_SESSION_ROUTES`, is 404 with no handler run
|
|
591
|
+
* (4), the route not being on `HUB_SESSION_ROUTES`, is 404 with no handler run;
|
|
592
|
+
* (5), the person over budget at this spoke (#8220 D2), is 429 with no
|
|
593
|
+
* handler run.
|
|
444
594
|
*/
|
|
445
595
|
async function authenticateHubSession(
|
|
446
596
|
request: FastifyRequest,
|
|
@@ -490,6 +640,24 @@ async function authenticateHubSession(
|
|
|
490
640
|
workspaceId: spoke.workspaceId as Id<"workspace">,
|
|
491
641
|
};
|
|
492
642
|
bindActor(request, reply, { type: "user", userId: request.userId });
|
|
643
|
+
// #8220 D10 — every line the request logger writes after admission names
|
|
644
|
+
// the spoke that acted: its id, never its key or the credential. (Lines a
|
|
645
|
+
// helper writes through the module-level `logger` carry no request binding.)
|
|
646
|
+
request.log = request.log.child({ spokeId: spoke.id });
|
|
647
|
+
reply.log = request.log;
|
|
648
|
+
|
|
649
|
+
// (5) The verified (spoke, person) is within its budget.
|
|
650
|
+
const retryAfter = chargeCallerBucket(
|
|
651
|
+
hubSessionBuckets,
|
|
652
|
+
`${spoke.id}:${grant.userId}`,
|
|
653
|
+
request.method,
|
|
654
|
+
now,
|
|
655
|
+
HUB_SESSION_LIMITS,
|
|
656
|
+
);
|
|
657
|
+
if (retryAfter !== undefined) {
|
|
658
|
+
reply.code(429).header("Retry-After", String(retryAfter)).send(RATE_LIMITED);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
493
661
|
}
|
|
494
662
|
|
|
495
663
|
export type AuthPreHandlerOptions = {
|
|
@@ -170,6 +170,11 @@ export const FORWARDED_VISITOR_ROUTES: readonly Readonly<{
|
|
|
170
170
|
{ method: "POST", url: "/v1/leads/callback" },
|
|
171
171
|
{ method: "GET", url: "/v1/schedule/availability" },
|
|
172
172
|
{ method: "POST", url: "/v1/schedule/book" },
|
|
173
|
+
// #8438 D5/D9 — the cookie banner's log and the unsubscribe landing: a
|
|
174
|
+
// site's server forwards each visitor's IP, so one spoke's visitors are
|
|
175
|
+
// counted one by one rather than in one bucket keyed on its egress IP.
|
|
176
|
+
{ method: "POST", url: "/v1/consent/cookie" },
|
|
177
|
+
{ method: "POST", url: "/v1/consent/unsubscribe" },
|
|
173
178
|
];
|
|
174
179
|
|
|
175
180
|
const VISITOR_IP_HEADER = "x-ailk-visitor-ip";
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file live-key.test.ts
|
|
3
|
+
*
|
|
4
|
+
* The hub checkout with a live key (#8450 AC 1, AC 2): `POST
|
|
5
|
+
* /v1/checkout/sessions` and the return page's `GET
|
|
6
|
+
* /v1/checkout/sessions/:id`. Unlike `sessions.test.ts` and
|
|
7
|
+
* `session.test.ts`, BOTH guards here are real: `lib/stripe.ts` runs as
|
|
8
|
+
* itself (only the two Stripe calls are mocked, so nothing leaves the
|
|
9
|
+
* process), and `@working-theory/stripe-client` runs as itself with
|
|
10
|
+
* `assertSandboxKey` wrapped in a spy, so the test can see whether a route
|
|
11
|
+
* still calls the sandbox-only guard.
|
|
12
|
+
*
|
|
13
|
+
* The process admits a restricted live key once, in `beforeAll`, exactly as
|
|
14
|
+
* `build()` does at boot. Every live-shaped key is composed at runtime from
|
|
15
|
+
* fragments — never a literal (architecture.yaml's live-key pattern).
|
|
16
|
+
*/
|
|
17
|
+
import type { FastifyInstance } from "fastify";
|
|
18
|
+
|
|
19
|
+
const mockCreateCheckoutSession = jest.fn();
|
|
20
|
+
const mockRetrieveCheckoutSession = jest.fn();
|
|
21
|
+
|
|
22
|
+
jest.mock("@working-theory/stripe-client", () => {
|
|
23
|
+
const actual = jest.requireActual("@working-theory/stripe-client");
|
|
24
|
+
return { ...actual, assertSandboxKey: jest.fn(actual.assertSandboxKey) };
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
28
|
+
jest.mock("../../../lib/stripe.js", () => ({
|
|
29
|
+
...jest.requireActual("../../../lib/stripe.js"),
|
|
30
|
+
createCheckoutSession: (...args: unknown[]) => mockCreateCheckoutSession(...args),
|
|
31
|
+
retrieveCheckoutSession: (...args: unknown[]) => mockRetrieveCheckoutSession(...args),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted; the DB boundary, mirrors sessions.test.ts
|
|
35
|
+
jest.mock("../../../lib/prisma.js", () => ({
|
|
36
|
+
prisma: {
|
|
37
|
+
session: { findUnique: jest.fn() },
|
|
38
|
+
user: { findUnique: jest.fn() },
|
|
39
|
+
apiToken: { findUnique: jest.fn(), update: jest.fn() },
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
import { assertSandboxKey } from "@working-theory/stripe-client";
|
|
44
|
+
|
|
45
|
+
import { config } from "../../../config/index.js";
|
|
46
|
+
import { admitApiStripeKey, stripeKeyMode } from "../../../lib/stripe.js";
|
|
47
|
+
import { __resetBuckets } from "../../../middleware/rate-limit.js";
|
|
48
|
+
import { checkoutRoutes } from "../index";
|
|
49
|
+
|
|
50
|
+
config.FRONTEND_URL = "https://example.com";
|
|
51
|
+
config.TRUSTED_ORIGINS = ["https://example.com"];
|
|
52
|
+
|
|
53
|
+
const TAIL = "FixtureOnlyCheckout";
|
|
54
|
+
const restrictedLiveKey = ["rk", "live", ""].join("_") + TAIL;
|
|
55
|
+
const secretLiveKey = ["sk", "live", ""].join("_") + TAIL;
|
|
56
|
+
const publishableLiveKey = ["pk", "live", ""].join("_") + TAIL;
|
|
57
|
+
|
|
58
|
+
const sandboxSpy = assertSandboxKey as jest.MockedFunction<typeof assertSandboxKey>;
|
|
59
|
+
|
|
60
|
+
const ENV_KEYS = ["STRIPE_SECRET_KEY", "STRIPE_MODE", "VERCEL_ENV", "OSS_API_INTERNAL_SECRET"] as const;
|
|
61
|
+
const savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
|
62
|
+
|
|
63
|
+
const VALID_BODY = {
|
|
64
|
+
productCode: "ailk-pro",
|
|
65
|
+
successUrl: "https://example.com/success",
|
|
66
|
+
cancelUrl: "https://example.com/cancel",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const SESSION_ID = "cs_live_fixture12345";
|
|
70
|
+
|
|
71
|
+
function buildApp(): FastifyInstance {
|
|
72
|
+
const fastify = require("fastify")({ logger: false });
|
|
73
|
+
fastify.register(checkoutRoutes);
|
|
74
|
+
return fastify;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("the hub checkout — an admitted live key (#8450)", () => {
|
|
78
|
+
let app: FastifyInstance;
|
|
79
|
+
|
|
80
|
+
beforeAll(() => {
|
|
81
|
+
delete process.env["OSS_API_INTERNAL_SECRET"];
|
|
82
|
+
process.env["STRIPE_SECRET_KEY"] = restrictedLiveKey;
|
|
83
|
+
process.env["STRIPE_MODE"] = "live";
|
|
84
|
+
process.env["VERCEL_ENV"] = "production";
|
|
85
|
+
expect(admitApiStripeKey()).toBe("live");
|
|
86
|
+
expect(stripeKeyMode()).toBe("live");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterAll(() => {
|
|
90
|
+
for (const k of ENV_KEYS) {
|
|
91
|
+
if (savedEnv[k] === undefined) delete process.env[k];
|
|
92
|
+
else process.env[k] = savedEnv[k];
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
beforeEach(async () => {
|
|
97
|
+
jest.clearAllMocks();
|
|
98
|
+
__resetBuckets();
|
|
99
|
+
process.env["STRIPE_SECRET_KEY"] = restrictedLiveKey;
|
|
100
|
+
mockCreateCheckoutSession.mockResolvedValue({
|
|
101
|
+
sessionId: "cs_live_fixture12345",
|
|
102
|
+
url: "https://checkout.stripe.com/c/pay/fixture",
|
|
103
|
+
});
|
|
104
|
+
mockRetrieveCheckoutSession.mockResolvedValue({
|
|
105
|
+
id: SESSION_ID,
|
|
106
|
+
status: "complete",
|
|
107
|
+
paymentStatus: "paid",
|
|
108
|
+
currency: "usd",
|
|
109
|
+
amountTotal: 4800,
|
|
110
|
+
lineItems: [],
|
|
111
|
+
});
|
|
112
|
+
app = buildApp();
|
|
113
|
+
await app.ready();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
afterEach(async () => {
|
|
117
|
+
await app.close();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("POST /v1/checkout/sessions", () => {
|
|
121
|
+
it("gets past the key check on the restricted live key, without the sandbox-only guard", async () => {
|
|
122
|
+
const res = await app.inject({ method: "POST", url: "/v1/checkout/sessions", payload: VALID_BODY });
|
|
123
|
+
|
|
124
|
+
expect(res.statusCode).toBe(201);
|
|
125
|
+
expect(mockCreateCheckoutSession).toHaveBeenCalledTimes(1);
|
|
126
|
+
expect(sandboxSpy).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it.each([
|
|
130
|
+
["an unrestricted live key", secretLiveKey],
|
|
131
|
+
["a publishable live key", publishableLiveKey],
|
|
132
|
+
])("still refuses %s with 500 live-mode-forbidden and no Stripe call", async (_label, key) => {
|
|
133
|
+
process.env["STRIPE_SECRET_KEY"] = key;
|
|
134
|
+
|
|
135
|
+
const res = await app.inject({ method: "POST", url: "/v1/checkout/sessions", payload: VALID_BODY });
|
|
136
|
+
|
|
137
|
+
expect(res.statusCode).toBe(500);
|
|
138
|
+
expect(res.json()).toEqual({ error: "live-mode-forbidden" });
|
|
139
|
+
expect(res.body).not.toContain(TAIL);
|
|
140
|
+
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("GET /v1/checkout/sessions/:id", () => {
|
|
145
|
+
it("gets past the key check on the restricted live key, without the sandbox-only guard", async () => {
|
|
146
|
+
const res = await app.inject({ method: "GET", url: `/v1/checkout/sessions/${SESSION_ID}` });
|
|
147
|
+
|
|
148
|
+
expect(res.statusCode).toBe(200);
|
|
149
|
+
expect(mockRetrieveCheckoutSession).toHaveBeenCalledTimes(1);
|
|
150
|
+
expect(sandboxSpy).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it.each([
|
|
154
|
+
["an unrestricted live key", secretLiveKey],
|
|
155
|
+
["a publishable live key", publishableLiveKey],
|
|
156
|
+
])("still refuses %s with 500 live-mode-forbidden and no Stripe call", async (_label, key) => {
|
|
157
|
+
process.env["STRIPE_SECRET_KEY"] = key;
|
|
158
|
+
|
|
159
|
+
const res = await app.inject({ method: "GET", url: `/v1/checkout/sessions/${SESSION_ID}` });
|
|
160
|
+
|
|
161
|
+
expect(res.statusCode).toBe(500);
|
|
162
|
+
expect(res.json()).toEqual({ error: "live-mode-forbidden" });
|
|
163
|
+
expect(res.body).not.toContain(TAIL);
|
|
164
|
+
expect(mockRetrieveCheckoutSession).not.toHaveBeenCalled();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* 400 validation-error — `:id` fails the shape check
|
|
28
28
|
* 404 unknown-session — Stripe raised `resource_missing`
|
|
29
29
|
* 500 stripe-not-configured — STRIPE_SECRET_KEY is missing/empty
|
|
30
|
-
* 500 live-mode-forbidden — STRIPE_SECRET_KEY is a live key
|
|
30
|
+
* 500 live-mode-forbidden — STRIPE_SECRET_KEY is a live key the process did not admit
|
|
31
31
|
* 500 config-error — STRIPE_SECRET_KEY invalid configuration
|
|
32
32
|
* 502 checkout-unavailable — Stripe SDK threw an unexpected error
|
|
33
33
|
*/
|
|
@@ -35,7 +35,7 @@ import type { FastifyReply, FastifyRequest } from "fastify";
|
|
|
35
35
|
import { z } from "zod";
|
|
36
36
|
|
|
37
37
|
import {
|
|
38
|
-
|
|
38
|
+
assertAdmittedStripeKey,
|
|
39
39
|
retrieveCheckoutSession,
|
|
40
40
|
StripeConfigError,
|
|
41
41
|
UnknownSessionError,
|
|
@@ -80,7 +80,7 @@ export async function checkoutSessionReadHandler(
|
|
|
80
80
|
return reply.status(500).send({ error: "stripe-not-configured" });
|
|
81
81
|
}
|
|
82
82
|
try {
|
|
83
|
-
|
|
83
|
+
assertAdmittedStripeKey(stripeSecretKey);
|
|
84
84
|
} catch {
|
|
85
85
|
return reply.status(500).send({ error: "live-mode-forbidden" });
|
|
86
86
|
}
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* price opens a subscription session), so this route serves both (#4398).
|
|
8
8
|
*
|
|
9
9
|
* Security invariants:
|
|
10
|
-
* -
|
|
10
|
+
* - Admitted key only (#8450): before any Stripe call, a test-mode key or
|
|
11
|
+
* the restricted live key the process admitted at boot passes
|
|
12
|
+
* (`assertAdmittedStripeKey`); every other live key is refused.
|
|
11
13
|
* - No PII in logs: successUrl/cancelUrl and full session IDs are never logged.
|
|
12
14
|
* Only the first 8 chars of the session ID appear in log lines.
|
|
13
15
|
* - Error responses never echo user input (productCode, URLs).
|
|
@@ -48,7 +50,7 @@
|
|
|
48
50
|
* unauthenticated redirect is off the site origin (#6877)
|
|
49
51
|
* 404 unknown-product-code — no active Stripe price for the given productCode
|
|
50
52
|
* 500 stripe-not-configured — STRIPE_SECRET_KEY is missing/empty (no Stripe call)
|
|
51
|
-
* 500 live-mode-forbidden — STRIPE_SECRET_KEY is a live key
|
|
53
|
+
* 500 live-mode-forbidden — STRIPE_SECRET_KEY is a live key the process did not admit
|
|
52
54
|
* 500 config-error — STRIPE_SECRET_KEY invalid configuration (from the SDK wrapper),
|
|
53
55
|
* or no trusted browser origin is configured when the origin
|
|
54
56
|
* lock applies (#6877, #7395)
|
|
@@ -62,7 +64,7 @@ import { z } from "zod";
|
|
|
62
64
|
import { config } from "../../config/index.js";
|
|
63
65
|
import { siteStamp } from "../../lib/site.js";
|
|
64
66
|
import {
|
|
65
|
-
|
|
67
|
+
assertAdmittedStripeKey,
|
|
66
68
|
createCheckoutSession,
|
|
67
69
|
StripeConfigError,
|
|
68
70
|
UnknownCustomerError,
|
|
@@ -248,7 +250,7 @@ export async function checkoutSessionsHandler(
|
|
|
248
250
|
return reply.status(500).send({ error: "stripe-not-configured" });
|
|
249
251
|
}
|
|
250
252
|
try {
|
|
251
|
-
|
|
253
|
+
assertAdmittedStripeKey(stripeSecretKey);
|
|
252
254
|
} catch {
|
|
253
255
|
return reply.status(500).send({ error: "live-mode-forbidden" });
|
|
254
256
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cookie banner's log (#8438, spec
|
|
3
|
+
* docs/specs/8438-consent-registry-and-ledger-step-spec.md D5).
|
|
4
|
+
*
|
|
5
|
+
* POST /v1/consent/cookie
|
|
6
|
+
* `{ visitorId, choice: "accepted" | "declined", locale }` — one row
|
|
7
|
+
* in the consent ledger against the visitor id, source
|
|
8
|
+
* `cookie_banner`. Answers `201 { recorded: true }`, or
|
|
9
|
+
* `200 { recorded: false }` when the site declares no cookie purpose
|
|
10
|
+
* or the visitor's newest row already records the same choice.
|
|
11
|
+
*
|
|
12
|
+
* The workspace comes from `resolvePublicWorkspace(request)` — the spoke
|
|
13
|
+
* key, else the one-workspace deployment — and NEVER from the body, which is
|
|
14
|
+
* strict and names no site, workspace, person or user. An unresolved
|
|
15
|
+
* workspace is `503 tenant_unresolved`; no database `503
|
|
16
|
+
* consent_store_unavailable`; a failed write the same.
|
|
17
|
+
*
|
|
18
|
+
* Registered at the top level in its own rate-limited scope, outside every
|
|
19
|
+
* module gate: consent logging is part of the never-gated privacy floor.
|
|
20
|
+
* The browser cookie `ailk-consent` stays the operative switch (C3) — this
|
|
21
|
+
* route only records the choice.
|
|
22
|
+
*
|
|
23
|
+
* Logs carry `{ event, outcome }` only: never a visitor id or a statement.
|
|
24
|
+
*
|
|
25
|
+
* MCP parity note: no MCP twin, a recorded exception — it logs a browser's
|
|
26
|
+
* banner choice against a browser-held id, and an agent holds neither
|
|
27
|
+
* (tests/parity/README.md).
|
|
28
|
+
*/
|
|
29
|
+
import { consentCookieBodySchema } from "@working-theory/validation";
|
|
30
|
+
import type { FastifyPluginAsync } from "fastify";
|
|
31
|
+
|
|
32
|
+
import { recordCookieChoice } from "../../services/consent.js";
|
|
33
|
+
import { resolvePublicWorkspace } from "../../services/public-site-resolver.js";
|
|
34
|
+
|
|
35
|
+
const EVENT = "consent-cookie";
|
|
36
|
+
|
|
37
|
+
export const consentCookieRoute: FastifyPluginAsync = async (fastify) => {
|
|
38
|
+
fastify.post("/v1/consent/cookie", async (request, reply) => {
|
|
39
|
+
const body = consentCookieBodySchema.safeParse(request.body);
|
|
40
|
+
if (!body.success) {
|
|
41
|
+
return reply.status(400).send({
|
|
42
|
+
error: "invalid_input",
|
|
43
|
+
message: "The request was not valid.",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if (!process.env["DATABASE_URL"]) {
|
|
47
|
+
return reply.status(503).send({
|
|
48
|
+
error: "consent_store_unavailable",
|
|
49
|
+
message: "Consent cannot be recorded on this site.",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const site = await resolvePublicWorkspace(request);
|
|
54
|
+
if (site.kind !== "resolved") {
|
|
55
|
+
request.log.warn(
|
|
56
|
+
{ event: EVENT, outcome: "tenant_unresolved" },
|
|
57
|
+
"[consent] refused",
|
|
58
|
+
);
|
|
59
|
+
return reply.status(503).send({
|
|
60
|
+
error: "tenant_unresolved",
|
|
61
|
+
message: "This site is not configured to record consent.",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const result = await recordCookieChoice({
|
|
67
|
+
workspaceId: site.workspaceId,
|
|
68
|
+
visitorId: body.data.visitorId,
|
|
69
|
+
choice: body.data.choice,
|
|
70
|
+
locale: body.data.locale,
|
|
71
|
+
});
|
|
72
|
+
request.log.info(
|
|
73
|
+
{ event: EVENT, outcome: result.recorded ? "recorded" : "unchanged" },
|
|
74
|
+
"[consent] cookie choice",
|
|
75
|
+
);
|
|
76
|
+
return reply
|
|
77
|
+
.status(result.recorded ? 201 : 200)
|
|
78
|
+
.send({ recorded: result.recorded });
|
|
79
|
+
} catch {
|
|
80
|
+
request.log.error(
|
|
81
|
+
{ event: EVENT, outcome: "failed" },
|
|
82
|
+
"[consent] cookie choice failed",
|
|
83
|
+
);
|
|
84
|
+
return reply.status(503).send({
|
|
85
|
+
error: "consent_store_unavailable",
|
|
86
|
+
message: "Consent cannot be recorded right now.",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
};
|