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.
- package/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +4 -1
- 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/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 +9 -0
- package/dist/lib/scaffold-render-sweep.js +24 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +119 -0
- package/dist/programmatic.js +3 -0
- 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.js +140 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +7 -2
- package/dist/topology.js +60 -3
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/CLAUDE.md +25 -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/lib/__mocks__/prisma.ts +9 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +81 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
- package/templates/apps/api/src/middleware/auth.ts +206 -46
- package/templates/apps/api/src/routes/leads/index.ts +4 -0
- package/templates/apps/api/src/server.ts +107 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
- package/templates/apps/api/src/services/lead-routing.ts +30 -2
- 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__/billing.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
- 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 +126 -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 +139 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
- 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 +114 -50
- 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 +177 -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/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -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 +4 -1
- 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 +80 -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 +108 -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 +5 -1
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/account-config.ts +188 -52
- 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 +24 -0
- package/templates/apps/web/messages/en.json +156 -35
- package/templates/database/CHANGELOG.md +21 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- 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__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +90 -15
- 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/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
|
@@ -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
|
|
9
|
-
* routes of `HUB_SESSION_ROUTES
|
|
10
|
-
* run (the status `lib/account-config.ts`'s `read()` already
|
|
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
|
-
*
|
|
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 {
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
128
|
-
expect([...HUB_SESSION_ROUTES]).toEqual(
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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([
|
|
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
|
+
});
|