create-ailk 0.18.0 → 0.19.0

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.
Files changed (167) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +4 -1
  4. package/dist/category-profile.d.ts +10 -0
  5. package/dist/category-profile.js +21 -0
  6. package/dist/derive-architecture.d.ts +5 -1
  7. package/dist/derive-architecture.js +8 -3
  8. package/dist/lib/license-key-files.d.ts +6 -3
  9. package/dist/lib/license-key-files.js +8 -2
  10. package/dist/lib/scaffold-render-sweep.d.ts +9 -0
  11. package/dist/lib/scaffold-render-sweep.js +24 -3
  12. package/dist/license-set.d.ts +1 -1
  13. package/dist/license-set.js +13 -8
  14. package/dist/module-architecture.js +119 -0
  15. package/dist/programmatic.js +3 -0
  16. package/dist/spoke-checkout.d.ts +11 -3
  17. package/dist/spoke-checkout.js +62 -12
  18. package/dist/starter-grid.d.ts +1 -1
  19. package/dist/surfaces.js +140 -1
  20. package/dist/template-manifest.d.ts +4 -3
  21. package/dist/topology.d.ts +7 -2
  22. package/dist/topology.js +60 -3
  23. package/package.json +5 -4
  24. package/templates/.env.example +3 -3
  25. package/templates/.github/workflows/ci.yml +5 -0
  26. package/templates/apps/api/CLAUDE.md +25 -7
  27. package/templates/apps/api/LICENSE +6 -5
  28. package/templates/apps/api/NOTICE +2 -0
  29. package/templates/apps/api/jest.config.cjs +4 -29
  30. package/templates/apps/api/package.json +1 -0
  31. package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
  32. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  33. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  34. package/templates/apps/api/src/lib/stripe.ts +81 -0
  35. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
  36. package/templates/apps/api/src/middleware/auth.ts +206 -46
  37. package/templates/apps/api/src/routes/leads/index.ts +4 -0
  38. package/templates/apps/api/src/server.ts +107 -0
  39. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
  40. package/templates/apps/api/src/services/lead-routing.ts +30 -2
  41. package/templates/apps/api/vercel.json +2 -2
  42. package/templates/apps/mcp/.env.example +10 -0
  43. package/templates/apps/mcp/CLAUDE.md +9 -4
  44. package/templates/apps/mcp/LICENSE +6 -5
  45. package/templates/apps/mcp/NOTICE +2 -0
  46. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  47. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  48. package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
  49. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  50. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  51. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  52. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  53. package/templates/apps/mcp/jest.config.cjs +3 -44
  54. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  55. package/templates/apps/mcp/src/boot.ts +5 -23
  56. package/templates/apps/mcp/src/http-transport.ts +93 -73
  57. package/templates/apps/mcp/src/index.ts +2 -0
  58. package/templates/apps/mcp/src/request-context.ts +52 -0
  59. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  60. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  61. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  62. package/templates/apps/web/LICENSE +6 -5
  63. package/templates/apps/web/NOTICE +2 -0
  64. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  65. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
  67. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
  68. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  69. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
  70. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  72. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  74. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  75. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
  77. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  78. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  79. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
  80. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
  81. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  82. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  83. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  84. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  85. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  86. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  87. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  88. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  89. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
  90. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  91. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  92. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  93. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  94. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  95. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  96. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  97. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
  98. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  99. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  100. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  101. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  102. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
  103. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  106. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  107. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  108. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  111. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  114. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  115. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  116. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  117. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  118. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  119. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  120. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  121. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  122. package/templates/apps/web/auth.ts +5 -1
  123. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  124. package/templates/apps/web/lib/account-config.ts +188 -52
  125. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  126. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  127. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  128. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  129. package/templates/apps/web/lib/topology.generated.ts +24 -0
  130. package/templates/apps/web/messages/en.json +156 -35
  131. package/templates/database/CHANGELOG.md +21 -0
  132. package/templates/database/LICENSE +6 -5
  133. package/templates/database/NOTICE +2 -0
  134. package/templates/database/README.md +1 -1
  135. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  136. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  137. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  138. package/templates/database/__tests__/seed.test.ts +54 -2
  139. package/templates/database/inbox/schema.prisma +90 -15
  140. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  141. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  142. package/templates/database/package.json +3 -2
  143. package/templates/database/seed.ts +34 -2
  144. package/versions.json +36 -36
  145. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  146. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  147. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  148. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  149. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  150. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  151. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  152. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  153. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  154. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  155. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  156. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  157. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  158. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  159. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  160. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  161. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  162. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  163. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  164. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  165. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  166. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  167. 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 four reads; any other route
37
- * is 404 (never 403, so the account area's `read()` loads "nothing for
38
- * this caller"). It acts as its person (`request.userId`) and carries
39
- * `request.hubSession` — never `request.sessionTenant`.
36
+ * on `HUB_SESSION_ROUTES` — an exact list of METHOD + registered URL
37
+ * pairs — the credential's own `me` and the member routes, 18 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,39 @@ export type HubSession = {
141
150
  };
142
151
 
143
152
  /**
144
- * The only routes a hub-issued credential reaches (#7894 D6): an EXACT list of
145
- * method + registered URL, never a prefix, so a route added later joins only
146
- * by a reviewed edit of this list (SEC-7894-3). The first is the credential's
147
- * own `me`; the other three are #7877's member reads. Not admitted,
148
- * deliberately: `GET /v1/waitlist-signups/me`, `GET /v1/flow-checkouts/me`,
149
- * `GET /v1/subscription/me` and `POST /v1/subscription/portal-session` (they
150
- * answer for the HUB's site and the person's platform billing, SEC-7894-1,
151
- * -2), and the token and profile routes.
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). Three 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). The member profile write IS admitted — it
159
+ * is the person's own account, which roadmap D9 saves through it. Not
160
+ * admitted, deliberately: `GET /v1/waitlist-signups/me`,
161
+ * `GET /v1/flow-checkouts/me`, `GET /v1/subscription/me` and
162
+ * `POST /v1/subscription/portal-session` (they answer for the HUB's site and
163
+ * the person's platform billing, SEC-7894-1, -2), the token routes and the
164
+ * hub's own-account `/v1/user/*` routes, `PATCH /v1/user/profile` among them.
152
165
  */
153
- export const HUB_SESSION_ROUTES: readonly string[] = [
166
+ export const HUB_SESSION_ROUTES: readonly string[] = Object.freeze([
154
167
  "GET /v1/hub-sign-in/me",
155
168
  "GET /v1/member/pieces",
156
169
  "GET /v1/member/messages",
157
170
  "GET /v1/member/messages/:id/file",
158
- ];
171
+ "GET /v1/member/purchases",
172
+ "GET /v1/member/appointments",
173
+ "GET /v1/member/artifacts",
174
+ "GET /v1/member/artifacts/:pieceId/versions/:version/file",
175
+ "GET /v1/member/meeting-types",
176
+ "GET /v1/member/availability",
177
+ "GET /v1/member/request-form",
178
+ "GET /v1/member/billing",
179
+ "POST /v1/member/artifacts/:pieceId/review",
180
+ "POST /v1/member/bookings",
181
+ "POST /v1/member/requests",
182
+ "POST /v1/member/messages",
183
+ "PATCH /v1/member/profile",
184
+ "POST /v1/member/deletion-request",
185
+ ]);
159
186
 
160
187
  /**
161
188
  * Who made a request (#3591 D9). A cookie session and a personal `ApiToken`
@@ -311,12 +338,13 @@ function bindActor(
311
338
  reply.log = request.log;
312
339
  }
313
340
 
314
- // ── The per-agent limit (#3591 D4 step 5, SEC-3591-04/-14) ─────────────────
341
+ // ── The per-caller limits (#3591 D4 step 5, SEC-3591-04/-14; #8220 D2) ──────
315
342
  //
316
343
  // 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 agent
318
- // id, so an invented key never gets one. The shape of
319
- // `routes/subscription/portal.ts`'s per-user budget.
344
+ // identity exists, and a bucket must only ever be keyed on a VERIFIED id, so
345
+ // an invented key or credential never gets one. The shape of
346
+ // `routes/subscription/portal.ts`'s per-user budget. An agent and a person
347
+ // never share a map.
320
348
 
321
349
  const AGENT_WINDOW_MS = 60_000;
322
350
  const AGENT_REQUESTS_PER_WINDOW = 60;
@@ -324,53 +352,159 @@ const AGENT_WRITES_PER_WINDOW = 10;
324
352
  /** Cap on distinct buckets — the oldest is evicted at capacity (DEF-011's shape). */
325
353
  const MAX_AGENT_BUCKETS = 5_000;
326
354
 
327
- type AgentBucket = { windowStartMs: number; requests: number; writes: number };
328
- const agentBuckets = new Map<string, AgentBucket>();
355
+ const HUB_SESSION_WINDOW_MS = 60_000;
356
+ const HUB_SESSION_REQUESTS_PER_WINDOW = 60;
357
+ const HUB_SESSION_WRITES_PER_WINDOW = 10;
358
+ const HUB_SESSION_WRITE_HOUR_MS = 3_600_000;
359
+ const HUB_SESSION_WRITES_PER_HOUR = 30;
360
+ const MAX_HUB_SESSION_BUCKETS = 5_000;
361
+
362
+ type CallerBucket = {
363
+ windowStartMs: number;
364
+ requests: number;
365
+ writes: number;
366
+ /** The hourly write window — set by the bucket's first admitted write. */
367
+ hourStartMs: number | null;
368
+ hourWrites: number;
369
+ };
370
+
371
+ type CallerLimits = {
372
+ windowMs: number;
373
+ maxBuckets: number;
374
+ requests: number;
375
+ writes: number;
376
+ /** An hourly write ceiling — the hub-credential path's alone. */
377
+ writesPerHour?: { limit: number; windowMs: number };
378
+ /** When true, the write limits refuse only writes; a read meets only `requests`. */
379
+ refuseOnlyWrites: boolean;
380
+ /** When true, a refused request still counts toward every limit. */
381
+ countRefused: boolean;
382
+ };
383
+
384
+ const AGENT_LIMITS: CallerLimits = {
385
+ windowMs: AGENT_WINDOW_MS,
386
+ maxBuckets: MAX_AGENT_BUCKETS,
387
+ requests: AGENT_REQUESTS_PER_WINDOW,
388
+ writes: AGENT_WRITES_PER_WINDOW,
389
+ refuseOnlyWrites: false,
390
+ countRefused: true,
391
+ };
392
+
393
+ const HUB_SESSION_LIMITS: CallerLimits = {
394
+ windowMs: HUB_SESSION_WINDOW_MS,
395
+ maxBuckets: MAX_HUB_SESSION_BUCKETS,
396
+ requests: HUB_SESSION_REQUESTS_PER_WINDOW,
397
+ writes: HUB_SESSION_WRITES_PER_WINDOW,
398
+ writesPerHour: {
399
+ limit: HUB_SESSION_WRITES_PER_HOUR,
400
+ windowMs: HUB_SESSION_WRITE_HOUR_MS,
401
+ },
402
+ refuseOnlyWrites: true,
403
+ countRefused: false,
404
+ };
405
+
406
+ const agentBuckets = new Map<string, CallerBucket>();
407
+ const hubSessionBuckets = new Map<string, CallerBucket>();
329
408
 
330
409
  /** Test-only reset. Not intended for production use. */
331
410
  export function __resetAgentRateBuckets(): void {
332
411
  agentBuckets.clear();
333
412
  }
334
413
 
414
+ /** Test-only reset. Not intended for production use. */
415
+ export function __resetHubSessionRateBuckets(): void {
416
+ hubSessionBuckets.clear();
417
+ }
418
+
335
419
  function isWrite(method: string): boolean {
336
420
  const m = method.toUpperCase();
337
421
  return m !== "GET" && m !== "HEAD";
338
422
  }
339
423
 
340
- /** `undefined` when admitted; the seconds to wait when the agent is over budget. */
341
- function chargeAgentRateLimit(
342
- agentId: string,
424
+ /** A bucket no window still holds — the eviction pass may drop it. */
425
+ function isStale(
426
+ bucket: CallerBucket,
427
+ now: number,
428
+ limits: CallerLimits,
429
+ ): boolean {
430
+ if (now - bucket.windowStartMs < limits.windowMs) return false;
431
+ return (
432
+ bucket.hourStartMs === null ||
433
+ limits.writesPerHour === undefined ||
434
+ now - bucket.hourStartMs >= limits.writesPerHour.windowMs
435
+ );
436
+ }
437
+
438
+ /**
439
+ * Charge one request to `key`'s bucket. `undefined` when admitted; the
440
+ * seconds to wait — to the end of the latest window that refused, at least
441
+ * 1 — when over budget.
442
+ */
443
+ function chargeCallerBucket(
444
+ buckets: Map<string, CallerBucket>,
445
+ key: string,
343
446
  method: string,
344
447
  now: number,
448
+ limits: CallerLimits,
345
449
  ): number | undefined {
346
- let bucket = agentBuckets.get(agentId);
347
- if (!bucket || now - bucket.windowStartMs >= AGENT_WINDOW_MS) {
348
- if (!agentBuckets.has(agentId) && agentBuckets.size >= MAX_AGENT_BUCKETS) {
349
- for (const [id, b] of agentBuckets) {
350
- if (now - b.windowStartMs >= AGENT_WINDOW_MS) agentBuckets.delete(id);
450
+ let bucket = buckets.get(key);
451
+ if (!bucket || now - bucket.windowStartMs >= limits.windowMs) {
452
+ if (!bucket && buckets.size >= limits.maxBuckets) {
453
+ for (const [k, b] of buckets) {
454
+ if (isStale(b, now, limits)) buckets.delete(k);
351
455
  }
352
- if (agentBuckets.size >= MAX_AGENT_BUCKETS) {
353
- const oldest = agentBuckets.keys().next().value;
354
- if (oldest !== undefined) agentBuckets.delete(oldest);
456
+ if (buckets.size >= limits.maxBuckets) {
457
+ const oldest = buckets.keys().next().value;
458
+ if (oldest !== undefined) buckets.delete(oldest);
355
459
  }
356
460
  }
357
- // Re-insert, so Map order stays oldest-window-first for the eviction above.
358
- agentBuckets.delete(agentId);
359
- bucket = { windowStartMs: now, requests: 0, writes: 0 };
360
- agentBuckets.set(agentId, bucket);
461
+ // Re-insert, so Map order stays oldest-window-first for the eviction
462
+ // above. A new minute keeps the hour's start and count.
463
+ buckets.delete(key);
464
+ bucket = {
465
+ windowStartMs: now,
466
+ requests: 0,
467
+ writes: 0,
468
+ hourStartMs: bucket?.hourStartMs ?? null,
469
+ hourWrites: bucket?.hourWrites ?? 0,
470
+ };
471
+ buckets.set(key, bucket);
361
472
  }
362
- bucket.requests += 1;
363
- if (isWrite(method)) bucket.writes += 1;
473
+ const hour = limits.writesPerHour;
364
474
  if (
365
- bucket.requests > AGENT_REQUESTS_PER_WINDOW ||
366
- bucket.writes > AGENT_WRITES_PER_WINDOW
475
+ hour !== undefined &&
476
+ bucket.hourStartMs !== null &&
477
+ now - bucket.hourStartMs >= hour.windowMs
367
478
  ) {
368
- return Math.max(
369
- 1,
370
- Math.ceil((bucket.windowStartMs + AGENT_WINDOW_MS - now) / 1_000),
371
- );
479
+ bucket.hourStartMs = null;
480
+ bucket.hourWrites = 0;
372
481
  }
373
- return undefined;
482
+
483
+ const write = isWrite(method);
484
+ const requests = bucket.requests + 1;
485
+ const writes = bucket.writes + (write ? 1 : 0);
486
+ const hourWrites = bucket.hourWrites + (write ? 1 : 0);
487
+ const minuteEnd = bucket.windowStartMs + limits.windowMs;
488
+ const refusedUntil: number[] = [];
489
+ if (requests > limits.requests) refusedUntil.push(minuteEnd);
490
+ if (write || !limits.refuseOnlyWrites) {
491
+ if (writes > limits.writes) refusedUntil.push(minuteEnd);
492
+ if (write && hour !== undefined && hourWrites > hour.limit) {
493
+ refusedUntil.push((bucket.hourStartMs ?? now) + hour.windowMs);
494
+ }
495
+ }
496
+
497
+ const refused = refusedUntil.length > 0;
498
+ if (!refused || limits.countRefused) {
499
+ bucket.requests = requests;
500
+ bucket.writes = writes;
501
+ if (write && hour !== undefined) {
502
+ bucket.hourWrites = hourWrites;
503
+ bucket.hourStartMs ??= now;
504
+ }
505
+ }
506
+ if (!refused) return undefined;
507
+ return Math.max(1, Math.ceil((Math.max(...refusedUntil) - now) / 1_000));
374
508
  }
375
509
 
376
510
  /**
@@ -412,7 +546,13 @@ async function authenticateAgent(
412
546
  onBehalfOfUserId: personId,
413
547
  });
414
548
 
415
- const retryAfter = chargeAgentRateLimit(agentId, request.method, now);
549
+ const retryAfter = chargeCallerBucket(
550
+ agentBuckets,
551
+ agentId,
552
+ request.method,
553
+ now,
554
+ AGENT_LIMITS,
555
+ );
416
556
  if (retryAfter !== undefined) {
417
557
  reply.code(429).header("Retry-After", String(retryAfter)).send(RATE_LIMITED);
418
558
  return;
@@ -440,7 +580,9 @@ async function authenticateAgent(
440
580
  /**
441
581
  * The hub-credential path (#7894 D6). The credential is never logged and
442
582
  * 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.
583
+ * (4), the route not being on `HUB_SESSION_ROUTES`, is 404 with no handler run;
584
+ * (5), the person over budget at this spoke (#8220 D2), is 429 with no
585
+ * handler run.
444
586
  */
445
587
  async function authenticateHubSession(
446
588
  request: FastifyRequest,
@@ -490,6 +632,24 @@ async function authenticateHubSession(
490
632
  workspaceId: spoke.workspaceId as Id<"workspace">,
491
633
  };
492
634
  bindActor(request, reply, { type: "user", userId: request.userId });
635
+ // #8220 D10 — every line the request logger writes after admission names
636
+ // the spoke that acted: its id, never its key or the credential. (Lines a
637
+ // helper writes through the module-level `logger` carry no request binding.)
638
+ request.log = request.log.child({ spokeId: spoke.id });
639
+ reply.log = request.log;
640
+
641
+ // (5) The verified (spoke, person) is within its budget.
642
+ const retryAfter = chargeCallerBucket(
643
+ hubSessionBuckets,
644
+ `${spoke.id}:${grant.userId}`,
645
+ request.method,
646
+ now,
647
+ HUB_SESSION_LIMITS,
648
+ );
649
+ if (retryAfter !== undefined) {
650
+ reply.code(429).header("Retry-After", String(retryAfter)).send(RATE_LIMITED);
651
+ return;
652
+ }
493
653
  }
494
654
 
495
655
  export type AuthPreHandlerOptions = {
@@ -245,6 +245,10 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
245
245
  }
246
246
  }
247
247
 
248
+ // #8185 D3 — a spoke's same-origin relay marks every forwarded call.
249
+ // The mark can only remove the spoke-checkout marker, never grant it.
250
+ sourceMeta.relayed = request.headers["x-ailk-relayed"] !== undefined;
251
+
248
252
  const result = await routeLead(
249
253
  buildInput(kind, body, workspaceId),
250
254
  sourceMeta,
@@ -228,9 +228,30 @@ const WORKSPACES_PEOPLE = "./routes/workspaces/people.js";
228
228
  const WORKSPACES_PIECES = "./routes/workspaces/pieces.js";
229
229
  const WORKSPACES_PLAN = "./routes/workspaces/plan.js";
230
230
  const WORKSPACES_MESSAGES_OUT = "./routes/workspaces/messages-out.js";
231
+ // #8171 D8 — the workspace's Requests read (the inbound member rows), the
232
+ // SAME scope.
233
+ const WORKSPACES_REQUESTS = "./routes/workspaces/requests.js";
231
234
  // #7877 D11 — the member reads: auth only, under the SAME module predicate.
232
235
  const MEMBER_PIECES = "./routes/member/pieces.js";
233
236
  const MEMBER_MESSAGES = "./routes/member/messages.js";
237
+ // #8169 D9 — the Phase 1 member reads. Five under the SAME predicate; the two
238
+ // that reach services/scheduling/ under scheduling && (workspace ||
239
+ // multi-tenant), because only the scheduling module delivers that directory.
240
+ const MEMBER_PURCHASES = "./routes/member/purchases.js";
241
+ const MEMBER_APPOINTMENTS = "./routes/member/appointments.js";
242
+ const MEMBER_ARTIFACTS = "./routes/member/artifacts.js";
243
+ const MEMBER_REQUEST_FORM = "./routes/member/request-form.js";
244
+ const MEMBER_BILLING = "./routes/member/billing.js";
245
+ const MEMBER_MEETING_TYPES = "./routes/member/meeting-types.js";
246
+ const MEMBER_AVAILABILITY = "./routes/member/availability.js";
247
+ // #8170 D11 — the member writes. Four under the tenancy predicate (the
248
+ // message POST rides MEMBER_MESSAGES); the booking, which reaches
249
+ // services/scheduling/, under scheduling && (workspace || multi-tenant).
250
+ const MEMBER_REQUESTS = "./routes/member/requests.js";
251
+ const MEMBER_ARTIFACT_REVIEW = "./routes/member/artifact-review.js";
252
+ const MEMBER_PROFILE = "./routes/member/profile.js";
253
+ const MEMBER_DELETION_REQUEST = "./routes/member/deletion-request.js";
254
+ const MEMBER_BOOKINGS = "./routes/member/bookings.js";
234
255
  // #3591 PR-B — the agent seat's management routes and an agent's own
235
256
  // context, siblings of WORKSPACES_MEMBERS in the same workspace/multi-tenant
236
257
  // scope.
@@ -316,6 +337,11 @@ const SLICE_PLUGINS = {
316
337
  specifier: WORKSPACES_PIECES,
317
338
  load: () => import(WORKSPACES_PIECES),
318
339
  },
340
+ // #8171 D8 — the Requests read, the SAME scope.
341
+ workspaceRequestRoutes: {
342
+ specifier: WORKSPACES_REQUESTS,
343
+ load: () => import(WORKSPACES_REQUESTS),
344
+ },
319
345
  workspacePlanRoutes: {
320
346
  specifier: WORKSPACES_PLAN,
321
347
  load: () => import(WORKSPACES_PLAN),
@@ -333,6 +359,58 @@ const SLICE_PLUGINS = {
333
359
  specifier: MEMBER_MESSAGES,
334
360
  load: () => import(MEMBER_MESSAGES),
335
361
  },
362
+ // #8169 D9 — the Phase 1 member reads, in authScope beside the two above.
363
+ memberPurchaseRoutes: {
364
+ specifier: MEMBER_PURCHASES,
365
+ load: () => import(MEMBER_PURCHASES),
366
+ },
367
+ memberAppointmentRoutes: {
368
+ specifier: MEMBER_APPOINTMENTS,
369
+ load: () => import(MEMBER_APPOINTMENTS),
370
+ },
371
+ memberArtifactRoutes: {
372
+ specifier: MEMBER_ARTIFACTS,
373
+ load: () => import(MEMBER_ARTIFACTS),
374
+ },
375
+ memberRequestFormRoutes: {
376
+ specifier: MEMBER_REQUEST_FORM,
377
+ load: () => import(MEMBER_REQUEST_FORM),
378
+ },
379
+ memberBillingRoutes: {
380
+ specifier: MEMBER_BILLING,
381
+ load: () => import(MEMBER_BILLING),
382
+ },
383
+ // The two that reach services/scheduling/ (scheduling-owned, D9).
384
+ memberMeetingTypeRoutes: {
385
+ specifier: MEMBER_MEETING_TYPES,
386
+ load: () => import(MEMBER_MEETING_TYPES),
387
+ },
388
+ memberAvailabilityRoutes: {
389
+ specifier: MEMBER_AVAILABILITY,
390
+ load: () => import(MEMBER_AVAILABILITY),
391
+ },
392
+ // #8170 D11 — the member writes, in authScope beside the reads.
393
+ memberRequestRoutes: {
394
+ specifier: MEMBER_REQUESTS,
395
+ load: () => import(MEMBER_REQUESTS),
396
+ },
397
+ memberArtifactReviewRoutes: {
398
+ specifier: MEMBER_ARTIFACT_REVIEW,
399
+ load: () => import(MEMBER_ARTIFACT_REVIEW),
400
+ },
401
+ memberProfileRoutes: {
402
+ specifier: MEMBER_PROFILE,
403
+ load: () => import(MEMBER_PROFILE),
404
+ },
405
+ memberDeletionRequestRoutes: {
406
+ specifier: MEMBER_DELETION_REQUEST,
407
+ load: () => import(MEMBER_DELETION_REQUEST),
408
+ },
409
+ // The one that reaches services/scheduling/ (scheduling-owned, D11).
410
+ memberBookingRoutes: {
411
+ specifier: MEMBER_BOOKINGS,
412
+ load: () => import(MEMBER_BOOKINGS),
413
+ },
336
414
  // #3591 PR-B — the agent seat, the SAME scope.
337
415
  workspaceAgentRoutes: {
338
416
  specifier: WORKSPACES_AGENTS,
@@ -927,6 +1005,30 @@ export async function build() {
927
1005
  if (isModuleEnabled("workspace") || isModuleEnabled("multi-tenant")) {
928
1006
  await authScope.register(await sliceRoutePlugin("memberPieceRoutes"));
929
1007
  await authScope.register(await sliceRoutePlugin("memberMessageRoutes"));
1008
+ // #8169 D9 — the Phase 1 member reads, under the same predicate.
1009
+ await authScope.register(await sliceRoutePlugin("memberPurchaseRoutes"));
1010
+ await authScope.register(await sliceRoutePlugin("memberAppointmentRoutes"));
1011
+ await authScope.register(await sliceRoutePlugin("memberArtifactRoutes"));
1012
+ await authScope.register(await sliceRoutePlugin("memberRequestFormRoutes"));
1013
+ await authScope.register(await sliceRoutePlugin("memberBillingRoutes"));
1014
+ // #8170 D11 — the member writes, under the same predicate.
1015
+ await authScope.register(await sliceRoutePlugin("memberRequestRoutes"));
1016
+ await authScope.register(await sliceRoutePlugin("memberArtifactReviewRoutes"));
1017
+ await authScope.register(await sliceRoutePlugin("memberProfileRoutes"));
1018
+ await authScope.register(await sliceRoutePlugin("memberDeletionRequestRoutes"));
1019
+ }
1020
+ // #8169 D9 — the two member reads that reach services/scheduling/, which
1021
+ // only the scheduling module delivers: a scaffold with a tenancy module
1022
+ // and no scheduling carries neither file, and one with scheduling and no
1023
+ // tenancy module registers neither.
1024
+ if (
1025
+ isModuleEnabled("scheduling") &&
1026
+ (isModuleEnabled("workspace") || isModuleEnabled("multi-tenant"))
1027
+ ) {
1028
+ await authScope.register(await sliceRoutePlugin("memberMeetingTypeRoutes"));
1029
+ await authScope.register(await sliceRoutePlugin("memberAvailabilityRoutes"));
1030
+ // #8170 D11 — the member booking, under the same predicate.
1031
+ await authScope.register(await sliceRoutePlugin("memberBookingRoutes"));
930
1032
  }
931
1033
 
932
1034
  // Tenant-guarded domain routes (multi-tenant module) — the tenant
@@ -1104,6 +1206,11 @@ export async function build() {
1104
1206
  await tenantScope.register(
1105
1207
  await sliceRoutePlugin("workspacePieceRoutes"),
1106
1208
  );
1209
+ // #8171 D8 — the Requests read: the inbound member rows, behind
1210
+ // `inbox-read`; the content only to a role holding `people-read`.
1211
+ await tenantScope.register(
1212
+ await sliceRoutePlugin("workspaceRequestRoutes"),
1213
+ );
1107
1214
  await tenantScope.register(
1108
1215
  await sliceRoutePlugin("workspacePlanRoutes"),
1109
1216
  );
@@ -1198,6 +1198,64 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1198
1198
  expect(planLines()).toEqual([]);
1199
1199
  });
1200
1200
 
1201
+ // #8185 — the hub stores its conclusion that a lead is a spoke's live paid
1202
+ // checkout (`Lead.spokeCheckout`): the plan rule's own predicate, plus "not
1203
+ // relayed". An unmarked create names no `spokeCheckout` key at all.
1204
+ const createData = () =>
1205
+ (prisma.lead.create as jest.Mock).mock.calls[0][0].data as Record<string, unknown>;
1206
+
1207
+ it("a live spoke checkout lead is created with spokeCheckout true and still sets the plan once", async () => {
1208
+ await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
1209
+ expect(createData()).toMatchObject({ spokeCheckout: true });
1210
+ expect(setPlan).toHaveBeenCalledTimes(1);
1211
+ });
1212
+
1213
+ it("the same lead relayed for a visitor is created with no spokeCheckout key and still sets the plan", async () => {
1214
+ await routeLead(checkoutLead, { ...spokeMeta, relayed: true }, { idempotencyKey: KEY });
1215
+ expect(createData()).not.toHaveProperty("spokeCheckout");
1216
+ expect(setPlan).toHaveBeenCalledTimes(1);
1217
+ });
1218
+
1219
+ it.each([
1220
+ ["an anonymous lead (viaSpoke false)", checkoutLead, sourceMeta, KEY],
1221
+ ["a test-mode session key", checkoutLead, spokeMeta, "cs_test_a1B2c3"],
1222
+ [
1223
+ "amountTotal 0",
1224
+ { ...checkoutLead, payload: { ...checkoutLead.payload, amountTotal: 0 } },
1225
+ spokeMeta,
1226
+ KEY,
1227
+ ],
1228
+ [
1229
+ "a negative amountTotal",
1230
+ { ...checkoutLead, payload: { ...checkoutLead.payload, amountTotal: -5 } },
1231
+ spokeMeta,
1232
+ KEY,
1233
+ ],
1234
+ ["a key that is not a session id", checkoutLead, spokeMeta, "abcdefghijklmnop"],
1235
+ ["no key", checkoutLead, spokeMeta, undefined],
1236
+ ["source contact", { ...checkoutLead, source: "contact" }, spokeMeta, KEY],
1237
+ [
1238
+ "page contact",
1239
+ { ...checkoutLead, payload: { ...checkoutLead.payload, page: "contact" } },
1240
+ spokeMeta,
1241
+ KEY,
1242
+ ],
1243
+ [
1244
+ "a numeric offering",
1245
+ { ...checkoutLead, payload: { ...checkoutLead.payload, offering: 42 } },
1246
+ spokeMeta,
1247
+ KEY,
1248
+ ],
1249
+ ] as const)("no spokeCheckout key in the create data for %s", async (_label, input, meta, key) => {
1250
+ await routeLead(
1251
+ input as typeof checkoutLead,
1252
+ meta,
1253
+ key === undefined ? {} : { idempotencyKey: key },
1254
+ );
1255
+ expect(prisma.lead.create).toHaveBeenCalledTimes(1);
1256
+ expect(createData()).not.toHaveProperty("spokeCheckout");
1257
+ });
1258
+
1201
1259
  it("an offering that is not a key after the strip logs skipped and makes no plan call", async () => {
1202
1260
  await routeLead(
1203
1261
  { ...checkoutLead, payload: { ...checkoutLead.payload, offering: "Wendi_Retainer!" } },
@@ -63,6 +63,15 @@
63
63
  * `routeLead`'s result, and logs one bare `checkout-plan` line — never the
64
64
  * offering, the email or the key. A lead that does not qualify makes no
65
65
  * Person call at all.
66
+ *
67
+ * SPOKE CHECKOUT MARKER (#8185 D2). The same predicate, plus "not relayed
68
+ * for a visitor" (`SourceMeta.relayed`), is evaluated once BEFORE the lead's
69
+ * create and stored as `Lead.spokeCheckout` — the hub's conclusion at write
70
+ * time, which `GET /v1/member/purchases` serves as a purchase
71
+ * (`lib/member-store.ts`). Written only in the submit create below, never by
72
+ * an update; an unmarked create carries no `spokeCheckout` key at all and
73
+ * relies on the column default. The plan rule above is unchanged: it still
74
+ * runs after the write, from the same candidate.
66
75
  */
67
76
 
68
77
  import { randomUUID } from "node:crypto";
@@ -117,6 +126,13 @@ export type SourceMeta = {
117
126
  * `false`. Never persisted — `captureRecordFor` copies fields by name.
118
127
  */
119
128
  viaSpoke?: boolean;
129
+ /**
130
+ * The request carried `x-ailk-relayed`, i.e. it was forwarded for a
131
+ * visitor by a spoke's same-origin relay (#8185 D3). Absent reads as
132
+ * `false`. Never persisted. It can only REMOVE the spoke-checkout marker,
133
+ * never grant it.
134
+ */
135
+ relayed?: boolean;
120
136
  };
121
137
 
122
138
  /** Where the lead landed: a database row, the local file fallback, or the owner's inbox by email (#6730). */
@@ -340,9 +356,15 @@ async function persistToDb(
340
356
  input: RouteLeadInput,
341
357
  idempotencyKey?: string,
342
358
  viaSpoke = false,
359
+ relayed = false,
343
360
  ): Promise<PersistResult> {
344
361
  if (input.kind === "submit") {
345
362
  const personId = await resolveLeadPersonId(input);
363
+ // #8185 D2 — the plan rule's own predicate (pure: input, key, flag),
364
+ // evaluated before the create so its result can be stored. A relayed
365
+ // request is never a spoke's server call (D3).
366
+ const candidate = checkoutPlanCandidate(input, idempotencyKey, viaSpoke);
367
+ const spokeCheckout = candidate !== null && relayed !== true;
346
368
  let row: DbRow;
347
369
  try {
348
370
  row = (await prisma.lead.create({
@@ -371,6 +393,8 @@ async function persistToDb(
371
393
  // #852 D6 — the Person `resolveLeadPersonId` found or created.
372
394
  personId,
373
395
  ...(idempotencyKey !== undefined && { idempotencyKey }),
396
+ // #8185 D4 — only a marked create names the column.
397
+ ...(spokeCheckout && { spokeCheckout: true }),
374
398
  },
375
399
  select: { id: true },
376
400
  })) as DbRow;
@@ -386,7 +410,6 @@ async function persistToDb(
386
410
  return { ok: "keyed", outcome: classifyKeyedRow(winner, input) };
387
411
  }
388
412
  // #7877 D10 — only after THIS request's own write, so a replay sets nothing.
389
- const candidate = checkoutPlanCandidate(input, idempotencyKey, viaSpoke);
390
413
  if (candidate !== null && personId !== null) {
391
414
  await applyCheckoutPlan(input, personId, candidate);
392
415
  }
@@ -729,7 +752,12 @@ export async function routeLead(
729
752
  let skipReason: string | undefined;
730
753
 
731
754
  try {
732
- const dbResult = await persistToDb(input, idempotencyKey, sourceMeta.viaSpoke === true);
755
+ const dbResult = await persistToDb(
756
+ input,
757
+ idempotencyKey,
758
+ sourceMeta.viaSpoke === true,
759
+ sourceMeta.relayed === true,
760
+ );
733
761
  // #7869 D5 — a lost race answers like the lookup above: no capture
734
762
  // record, no notification.
735
763
  if (dbResult.ok === "keyed") return keyedResult(dbResult.outcome);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 2,
3
- "ignoreCommand": "git diff --quiet HEAD^ HEAD -- apps/api packages database",
4
- "buildCommand": "cd ../.. && pnpm run --filter @ai-launch-kit/backend build:vercel",
3
+ "ignoreCommand": "[ -n \"$VERCEL_GIT_PREVIOUS_SHA\" ] && cd ../.. && git diff --quiet \"$VERCEL_GIT_PREVIOUS_SHA\" HEAD -- apps/api packages database pnpm-lock.yaml || exit 1",
4
+ "buildCommand": "cd ../.. && pnpm run --filter @working-theory/api build:vercel",
5
5
  "installCommand": "cd ../.. && pnpm install --frozen-lockfile",
6
6
  "outputDirectory": "public",
7
7
  "framework": null,
@@ -58,6 +58,16 @@ MCP_ABUSE_CAPTURE_LEAD_DAILY_CAP=50
58
58
  # hosted Checkout Session, so the default is deliberately lower.
59
59
  MCP_ABUSE_START_PURCHASE_DAILY_CAP=20
60
60
 
61
+ # Maximum number of create_booking calls per IP per day (#8071). Its own
62
+ # budget — each call creates a real calendar event and emails the attendee an
63
+ # invite, so the default matches start_purchase's.
64
+ MCP_ABUSE_CREATE_BOOKING_DAILY_CAP=20
65
+
66
+ # Maximum number of submit_flow_step calls per IP per day (#8071). Its own
67
+ # budget, counted per step, not per flow — equal to capture_lead's, because a
68
+ # one-step flow creates a Lead in one call.
69
+ MCP_ABUSE_SUBMIT_FLOW_STEP_DAILY_CAP=50
70
+
61
71
  # Comma-separated list of User-Agent substrings to block (case-insensitive).
62
72
  # Built-in list already blocks curl, wget, python-requests, scrapy, etc.
63
73
  # Add extra patterns here as needed.