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
@@ -5,12 +5,14 @@
5
5
  * docs/specs/7894-the-hub-signs-in-its-spec.md D6; AC-4, AC-5, AC-7).
6
6
  *
7
7
  * A bearer starting `ailk_hst_` is admitted only beside the SAME spoke's key,
8
- * only while its grant is live and its person verified, and only on the four
9
- * routes of `HUB_SESSION_ROUTES`. Any other route answers 404 with no handler
10
- * run (the status `lib/account-config.ts`'s `read()` already loads as "nothing
11
- * for this caller"), never 403.
8
+ * only while its grant is live and its person verified, and only on the 18
9
+ * routes of `HUB_SESSION_ROUTES` (#8220 D1, AC-1). Any other route answers 404
10
+ * with no handler run (the status `lib/account-config.ts`'s `read()` already
11
+ * loads as "nothing for this caller"), never 403. Each (person, spoke) is
12
+ * limited to 60 requests and 10 writes a minute and 30 writes an hour,
13
+ * charged only after admission (#8220 D2, AC-7).
12
14
  *
13
- * Stub routes stand in for #7877's three member reads, which are unmerged; each
15
+ * Recording stubs stand in for every admitted and every off-list route; each
14
16
  * one records that its handler ran, so "404 with no handler run" is asserted,
15
17
  * not assumed.
16
18
  *
@@ -34,7 +36,11 @@ import {
34
36
  } from "../../__tests__/support/hub-sign-in-fake-db.js";
35
37
  import { mintCredential, sha256Hex } from "../../lib/hub-sign-in-store.js";
36
38
  import { prisma } from "../../lib/prisma.js";
37
- import { authPreHandlerPlugin, HUB_SESSION_ROUTES } from "../auth.js";
39
+ import {
40
+ __resetHubSessionRateBuckets,
41
+ authPreHandlerPlugin,
42
+ HUB_SESSION_ROUTES,
43
+ } from "../auth.js";
38
44
 
39
45
  const db = jest.mocked(prisma);
40
46
 
@@ -47,6 +53,55 @@ let spokeA: SeededSpoke;
47
53
  let spokeB: SeededSpoke;
48
54
  let handlerRuns: string[];
49
55
 
56
+ /** D1's 18 strings, in the list's order. */
57
+ const ADMITTED = [
58
+ "GET /v1/hub-sign-in/me",
59
+ "GET /v1/member/pieces",
60
+ "GET /v1/member/messages",
61
+ "GET /v1/member/messages/:id/file",
62
+ "GET /v1/member/purchases",
63
+ "GET /v1/member/appointments",
64
+ "GET /v1/member/artifacts",
65
+ "GET /v1/member/artifacts/:pieceId/versions/:version/file",
66
+ "GET /v1/member/meeting-types",
67
+ "GET /v1/member/availability",
68
+ "GET /v1/member/request-form",
69
+ "GET /v1/member/billing",
70
+ "POST /v1/member/artifacts/:pieceId/review",
71
+ "POST /v1/member/bookings",
72
+ "POST /v1/member/requests",
73
+ "POST /v1/member/messages",
74
+ "PATCH /v1/member/profile",
75
+ "POST /v1/member/deletion-request",
76
+ ];
77
+
78
+ /** Every route the credential must NOT reach, each a recording stub. */
79
+ const OFF_LIST = [
80
+ "GET /v1/member/other",
81
+ "GET /v1/member/messages/:id",
82
+ "GET /v1/waitlist-signups/me",
83
+ "GET /v1/flow-checkouts/me",
84
+ "GET /v1/subscription/me",
85
+ "POST /v1/subscription/portal-session",
86
+ "GET /v1/auth/tokens",
87
+ "PATCH /v1/user/profile",
88
+ "POST /v1/hub-sign-in/codes",
89
+ // D7's near misses — one method or one segment from an admitted entry.
90
+ "GET /v1/member/requests",
91
+ "GET /v1/member/profile",
92
+ "POST /v1/member/purchases",
93
+ "DELETE /v1/member/profile",
94
+ "GET /v1/member/artifacts/:pieceId",
95
+ "PUT /v1/member/messages",
96
+ ];
97
+
98
+ /** A registered URL with each `:param` filled in. */
99
+ const concrete = (url: string) =>
100
+ url
101
+ .replace(":pieceId", "piece_1")
102
+ .replace(":version", "1")
103
+ .replace(":id", "lead_1");
104
+
50
105
  /** A live credential for (user, spoke), stored the way the token route stores it. */
51
106
  function grant(
52
107
  s: SeededSpoke,
@@ -89,22 +144,10 @@ function buildApp(): FastifyInstance {
89
144
  };
90
145
  };
91
146
 
92
- // The four admitted routes (the member reads are stubs).
93
- server.get("/v1/hub-sign-in/me", echo("me"));
94
- server.get("/v1/member/pieces", echo("pieces"));
95
- server.get("/v1/member/messages", echo("messages"));
96
- server.get("/v1/member/messages/:id/file", echo("file"));
97
-
98
- // Everything else the credential must NOT reach.
99
- server.get("/v1/member/other", echo("other"));
100
- server.get("/v1/member/messages/:id", echo("message-item"));
101
- server.get("/v1/waitlist-signups/me", echo("waitlist-signups"));
102
- server.get("/v1/flow-checkouts/me", echo("flow-checkouts"));
103
- server.get("/v1/subscription/me", echo("subscription"));
104
- server.post("/v1/subscription/portal-session", echo("portal"));
105
- server.get("/v1/auth/tokens", echo("tokens"));
106
- server.patch("/v1/user/profile", echo("profile"));
107
- server.post("/v1/hub-sign-in/codes", echo("codes"));
147
+ for (const route of [...ADMITTED, ...OFF_LIST]) {
148
+ const [method, url] = route.split(" ") as [string, string];
149
+ server.route({ method: method as "GET", url, handler: echo(route) });
150
+ }
108
151
  return server;
109
152
  }
110
153
 
@@ -112,6 +155,7 @@ beforeEach(async () => {
112
155
  jest.resetAllMocks();
113
156
  state = installHubSignInFakeDb(db);
114
157
  handlerRuns = [];
158
+ __resetHubSessionRateBuckets();
115
159
  user = seedUser(state);
116
160
  spokeA = seedSpoke(state, "a.example.com");
117
161
  spokeB = seedSpoke(state, "b.example.com");
@@ -124,35 +168,36 @@ afterEach(async () => {
124
168
  });
125
169
 
126
170
  describe("HUB_SESSION_ROUTES", () => {
127
- it("is exactly the four reads, by method and registered URL — never a prefix", () => {
128
- expect([...HUB_SESSION_ROUTES]).toEqual([
129
- "GET /v1/hub-sign-in/me",
130
- "GET /v1/member/pieces",
131
- "GET /v1/member/messages",
132
- "GET /v1/member/messages/:id/file",
133
- ]);
171
+ it("is exactly the 18 entries, by method and registered URL — never a prefix (AC-1)", () => {
172
+ expect([...HUB_SESSION_ROUTES]).toEqual(ADMITTED);
173
+ expect(HUB_SESSION_ROUTES.length).toBe(18);
174
+ for (const route of HUB_SESSION_ROUTES) {
175
+ expect({
176
+ route,
177
+ open: route.endsWith("/") || route.includes("*"),
178
+ }).toEqual({
179
+ route,
180
+ open: false,
181
+ });
182
+ }
134
183
  });
135
184
  });
136
185
 
137
186
  describe("hub credential — admitted routes", () => {
138
- it.each([
139
- ["GET", "/v1/hub-sign-in/me", "me"],
140
- ["GET", "/v1/member/pieces", "pieces"],
141
- ["GET", "/v1/member/messages", "messages"],
142
- ["GET", "/v1/member/messages/lead_1/file", "file"],
143
- ])(
144
- "%s %s is admitted as the credential's person, bound to the spoke",
145
- async (method, url, name) => {
187
+ it.each(ADMITTED)(
188
+ "%s is admitted as the credential's person, bound to the spoke",
189
+ async (route) => {
190
+ const [method, url] = route.split(" ") as [string, string];
146
191
  const credential = grant(spokeA);
147
192
 
148
193
  const res = await app.inject({
149
194
  method: method as "GET",
150
- url,
195
+ url: concrete(url),
151
196
  headers: headers(credential),
152
197
  });
153
198
 
154
199
  expect(res.statusCode).toBe(200);
155
- expect(handlerRuns).toEqual([name]);
200
+ expect(handlerRuns).toEqual([route]);
156
201
  const body = res.json();
157
202
  expect(body.userId).toBe(user.id);
158
203
  expect(body.hubSession).toEqual({
@@ -194,6 +239,28 @@ describe("hub credential — every other route is 404 with no handler run (AC-7)
194
239
  expect(handlerRuns).toEqual([]);
195
240
  });
196
241
 
242
+ it.each(
243
+ OFF_LIST.slice(-6).map((route) => route.split(" ") as [string, string]),
244
+ )(
245
+ "the registered near miss %s %s is still the preHandler's 404 (#8220 D7)",
246
+ async (method, url) => {
247
+ const credential = grant(spokeA);
248
+
249
+ const res = await app.inject({
250
+ method: method as "GET",
251
+ url: concrete(url),
252
+ headers: headers(credential),
253
+ });
254
+
255
+ expect(res.statusCode).toBe(404);
256
+ expect(res.json()).toEqual({
257
+ ok: false,
258
+ error: { code: "not_found", message: "Not found" },
259
+ });
260
+ expect(handlerRuns).toEqual([]);
261
+ },
262
+ );
263
+
197
264
  it("the list is method-exact: HEAD on an admitted URL is not admitted", async () => {
198
265
  const credential = grant(spokeA);
199
266
  const res = await app.inject({
@@ -308,3 +375,163 @@ describe("hub credential — it is a bearer and nothing else", () => {
308
375
  expect(res.body).not.toContain(credential);
309
376
  });
310
377
  });
378
+
379
+ // ─── #8220 D2 — the per-(person, spoke) budget (AC-7) ───────────────────────
380
+
381
+ describe("hub credential — the per-person budget at each spoke (#8220 D2, AC-7)", () => {
382
+ const T0 = Date.UTC(2026, 9, 11, 12, 0, 0);
383
+ let now: number;
384
+ let clock: jest.SpyInstance<number, []>;
385
+
386
+ beforeEach(() => {
387
+ now = T0;
388
+ clock = jest.spyOn(Date, "now").mockImplementation(() => now);
389
+ });
390
+
391
+ afterEach(() => {
392
+ clock.mockRestore();
393
+ });
394
+
395
+ const read = (credential: string, s: SeededSpoke = spokeA) =>
396
+ app.inject({
397
+ method: "GET",
398
+ url: "/v1/member/pieces",
399
+ headers: headers(credential, s),
400
+ });
401
+ const write = (credential: string, s: SeededSpoke = spokeA) =>
402
+ app.inject({
403
+ method: "POST",
404
+ url: "/v1/member/messages",
405
+ headers: headers(credential, s),
406
+ });
407
+
408
+ async function times(
409
+ n: number,
410
+ send: () => ReturnType<typeof read>,
411
+ ): Promise<number[]> {
412
+ const statuses: number[] = [];
413
+ for (let i = 0; i < n; i += 1) statuses.push((await send()).statusCode);
414
+ return statuses;
415
+ }
416
+
417
+ it("a person's 61st admitted request at one spoke in one minute is 429 with Retry-After and the rate_limited body, no handler run", async () => {
418
+ const credential = grant(spokeA);
419
+ expect(new Set(await times(60, () => read(credential)))).toEqual(
420
+ new Set([200]),
421
+ );
422
+ handlerRuns = [];
423
+ now += 15_000;
424
+
425
+ const res = await read(credential);
426
+
427
+ expect(res.statusCode).toBe(429);
428
+ expect(res.headers["retry-after"]).toBe("45");
429
+ expect(res.json()).toEqual({
430
+ ok: false,
431
+ error: { code: "rate_limited", message: "Too many requests" },
432
+ });
433
+ expect(handlerRuns).toEqual([]);
434
+ });
435
+
436
+ it("another person at the same spoke, and the same person at another spoke, are admitted in that minute (SEC-8220-4)", async () => {
437
+ const credential = grant(spokeA);
438
+ await times(60, () => read(credential));
439
+ expect((await read(credential)).statusCode).toBe(429);
440
+
441
+ const other = seedUser(state);
442
+ expect((await read(grant(spokeA, { userId: other.id }))).statusCode).toBe(
443
+ 200,
444
+ );
445
+ expect((await read(grant(spokeB), spokeB)).statusCode).toBe(200);
446
+ // The spent bucket opens again when its minute ends.
447
+ now += 60_000;
448
+ expect((await read(credential)).statusCode).toBe(200);
449
+ });
450
+
451
+ it("the 11th write in a minute is 429; after it a read is admitted and a 12th write is 429", async () => {
452
+ const credential = grant(spokeA);
453
+ expect(new Set(await times(10, () => write(credential)))).toEqual(
454
+ new Set([200]),
455
+ );
456
+
457
+ const eleventh = await write(credential);
458
+ expect(eleventh.statusCode).toBe(429);
459
+ expect(Number(eleventh.headers["retry-after"])).toBeGreaterThanOrEqual(1);
460
+ expect((await read(credential)).statusCode).toBe(200);
461
+ expect((await write(credential)).statusCode).toBe(429);
462
+ });
463
+
464
+ it("a refused request is not counted: after 10 admitted and 50 refused writes, a read is admitted", async () => {
465
+ const credential = grant(spokeA);
466
+ await times(10, () => write(credential));
467
+ expect(new Set(await times(50, () => write(credential)))).toEqual(
468
+ new Set([429]),
469
+ );
470
+
471
+ // A counted refusal would make this the 61st request of the minute.
472
+ expect((await read(credential)).statusCode).toBe(200);
473
+ });
474
+
475
+ it("writes refused by the minute ceiling do not advance the hourly count; the 31st write in an hour is 429 and a read after it is admitted", async () => {
476
+ const credential = grant(spokeA, {
477
+ expiresAt: new Date(T0 + 24 * 3_600_000),
478
+ });
479
+ // Minute 0: 10 admitted, 20 refused by the minute ceiling.
480
+ expect(await times(30, () => write(credential))).toEqual([
481
+ ...Array<number>(10).fill(200),
482
+ ...Array<number>(20).fill(429),
483
+ ]);
484
+ // Minutes 1 and 2: 10 admitted each — 30 admitted in the hour.
485
+ now += 60_000;
486
+ expect(new Set(await times(10, () => write(credential)))).toEqual(
487
+ new Set([200]),
488
+ );
489
+ now += 60_000;
490
+ expect(new Set(await times(10, () => write(credential)))).toEqual(
491
+ new Set([200]),
492
+ );
493
+
494
+ // Minute 3: the 31st admitted write would pass the minute's ceiling, but
495
+ // not the hour's — Retry-After is the hour's end, 57 minutes away.
496
+ now += 60_000;
497
+ const thirtyFirst = await write(credential);
498
+ expect(thirtyFirst.statusCode).toBe(429);
499
+ expect(thirtyFirst.headers["retry-after"]).toBe(String(57 * 60));
500
+ expect((await read(credential)).statusCode).toBe(200);
501
+
502
+ // The hour ends: writes are admitted again.
503
+ now = T0 + 60 * 60_000;
504
+ expect((await write(credential)).statusCode).toBe(200);
505
+ });
506
+
507
+ it("100 off-list or refused-credential requests leave the next admitted request admitted — no bucket before admission", async () => {
508
+ const credential = grant(spokeA);
509
+ // 70 off-list requests with the person's own live credential: charged
510
+ // before the route check, they alone would spend the minute's 60.
511
+ for (let i = 0; i < 70; i += 1) {
512
+ const res = await app.inject({
513
+ method: "GET",
514
+ url: "/v1/subscription/me",
515
+ headers: headers(credential),
516
+ });
517
+ expect(res.statusCode).toBe(404);
518
+ }
519
+ for (let i = 0; i < 30; i += 1) {
520
+ expect((await read(mintCredential())).statusCode).toBe(401);
521
+ }
522
+ expect(handlerRuns).toEqual([]);
523
+
524
+ expect((await read(credential)).statusCode).toBe(200);
525
+ });
526
+
527
+ it("GET /v1/hub-sign-in/me is an admitted route and is charged like the rest", async () => {
528
+ const credential = grant(spokeA);
529
+ await times(60, () => read(credential));
530
+ const res = await app.inject({
531
+ method: "GET",
532
+ url: "/v1/hub-sign-in/me",
533
+ headers: headers(credential),
534
+ });
535
+ expect(res.statusCode).toBe(429);
536
+ });
537
+ });