create-ailk 0.15.0 → 0.17.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/README.md +124 -0
- package/dist/add-module.js +52 -7
- package/dist/add-route.js +8 -0
- package/dist/adopt.d.ts +74 -0
- package/dist/adopt.js +314 -0
- package/dist/cli.js +82 -2
- package/dist/derive-architecture.d.ts +22 -0
- package/dist/derive-architecture.js +58 -1
- package/dist/lib/declared-env.d.ts +142 -0
- package/dist/lib/declared-env.js +985 -0
- package/dist/lib/env-catalog.d.ts +17 -0
- package/dist/lib/env-catalog.js +234 -0
- package/dist/lib/extract-module-bundle.d.ts +9 -0
- package/dist/lib/extract-module-bundle.js +18 -1
- package/dist/lib/module-tier.d.ts +2 -0
- package/dist/lib/module-tier.js +4 -0
- package/dist/module-architecture.js +76 -0
- package/dist/parse-args.d.ts +5 -0
- package/dist/parse-args.js +17 -1
- package/dist/programmatic.d.ts +8 -0
- package/dist/programmatic.js +78 -9
- package/dist/seed-flows.d.ts +65 -0
- package/dist/seed-flows.js +350 -0
- package/dist/site-manifest.d.ts +43 -0
- package/dist/site-manifest.js +98 -3
- package/dist/site-roster.d.ts +13 -0
- package/dist/site-roster.js +22 -8
- package/dist/spoke-checkout.d.ts +67 -0
- package/dist/spoke-checkout.js +562 -0
- package/dist/surfaces.js +7 -0
- package/dist/sync-routes.js +23 -8
- package/dist/topology.d.ts +124 -9
- package/dist/topology.js +451 -40
- package/package.json +3 -3
- package/templates/.env.example +10 -0
- package/templates/apps/api/CLAUDE.md +19 -4
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
- package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
- package/templates/apps/api/src/bin/spokes.ts +62 -1
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
- package/templates/apps/api/src/config/index.ts +35 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +89 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
- package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
- package/templates/apps/api/src/lib/spoke-store.ts +25 -3
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
- package/templates/apps/api/src/middleware/auth.ts +126 -0
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +7 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +72 -1
- package/templates/apps/api/src/server.ts +105 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
- package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +293 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
- package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
- package/templates/apps/api/src/services/lead-routing.ts +131 -2
- package/templates/apps/api/src/services/person.ts +541 -0
- package/templates/apps/web/.env.example +58 -5
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
- package/templates/apps/web/app/[locale]/layout.tsx +28 -15
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
- package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
- package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +57 -24
- package/templates/apps/web/app/openapi.json/route.ts +3 -6
- package/templates/apps/web/auth.ts +29 -2
- package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
- package/templates/apps/web/components/signed-in-nav.tsx +17 -3
- package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
- package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
- package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
- package/templates/apps/web/lib/__tests__/relay.test.ts +33 -0
- package/templates/apps/web/lib/__tests__/server-api.test.ts +37 -28
- package/templates/apps/web/lib/account-config.ts +130 -1
- package/templates/apps/web/lib/api-origin.generated.ts +11 -0
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
- package/templates/apps/web/lib/hub-session/config.ts +104 -0
- package/templates/apps/web/lib/hub-session/index.ts +23 -0
- package/templates/apps/web/lib/hub-session/seal.ts +82 -0
- package/templates/apps/web/lib/hub-session/session.ts +180 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
- package/templates/apps/web/lib/listings-config.ts +31 -0
- package/templates/apps/web/lib/listings.tsx +7 -3
- package/templates/apps/web/lib/meeting-slugs.ts +11 -1
- package/templates/apps/web/lib/next-path.ts +35 -0
- package/templates/apps/web/lib/relay.ts +3 -1
- package/templates/apps/web/lib/server-api.ts +11 -1
- package/templates/apps/web/lib/topology.generated.ts +15 -5
- package/templates/apps/web/messages/en.json +32 -3
- package/templates/database/CHANGELOG.md +26 -0
- package/templates/database/__tests__/agents.db.test.ts +25 -5
- package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
- package/templates/database/__tests__/member-area.db.test.ts +245 -0
- package/templates/database/__tests__/person.db.test.ts +584 -0
- package/templates/database/auth/schema.prisma +3 -2
- package/templates/database/config/schema.prisma +13 -0
- package/templates/database/inbox/schema.prisma +98 -0
- package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
- package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
- package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
- package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
- package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/person-backfill.sql +132 -0
- package/templates/database/tenancy.ts +53 -0
- package/templates/tools/check-declared-env.ts +37 -0
- package/templates/tools/declared-env.ts +1163 -0
- package/versions.json +35 -35
|
@@ -60,8 +60,14 @@
|
|
|
60
60
|
* userData.create/updateMany (auth) + siteSetting.findUnique (config) — the
|
|
61
61
|
* dunning record's compare-and-swap write and the per-tenant grace-policy
|
|
62
62
|
* read (#6034), used by dunning-policy.test.ts and stripe-dunning.test.ts.
|
|
63
|
+
*
|
|
64
|
+
* person (inbox) + flowCheckout.findFirst — the Person step every lead
|
|
65
|
+
* submit, appointment create and hub checkout now runs (#852 D13).
|
|
63
66
|
*/
|
|
64
67
|
|
|
68
|
+
/** The Person every lead written through this mock is stamped with (#852 D13). */
|
|
69
|
+
export const MOCK_PERSON_ID = "person_00000000-0000-7000-8000-000000000852";
|
|
70
|
+
|
|
65
71
|
export const prisma = {
|
|
66
72
|
// ── Auth namespace ──────────────────────────────────────────────────────────
|
|
67
73
|
user: {
|
|
@@ -79,11 +85,21 @@ export const prisma = {
|
|
|
79
85
|
// in one `$transaction` with the `user.updateMany` above.
|
|
80
86
|
session: {
|
|
81
87
|
update: jest.fn(),
|
|
88
|
+
// #7894 — the hub-sign-in suites assert the token route NEVER mints a
|
|
89
|
+
// Better Auth session (`create`), and that the preHandler's cookie path
|
|
90
|
+
// sees none for a hub credential (`findUnique`).
|
|
91
|
+
create: jest.fn(),
|
|
92
|
+
findUnique: jest.fn(),
|
|
82
93
|
},
|
|
83
94
|
apiToken: {
|
|
84
95
|
create: jest.fn(),
|
|
85
96
|
findMany: jest.fn(),
|
|
86
97
|
updateMany: jest.fn(),
|
|
98
|
+
// #7894 — the auth preHandler's bearer lookup and its best-effort
|
|
99
|
+
// `lastUsedAt` bump; the hub-sign-in route suite sends a personal token to
|
|
100
|
+
// the codes route to prove it mints nothing.
|
|
101
|
+
findUnique: jest.fn(),
|
|
102
|
+
update: jest.fn(),
|
|
87
103
|
},
|
|
88
104
|
userData: {
|
|
89
105
|
findUnique: jest.fn(),
|
|
@@ -263,6 +279,58 @@ export const prisma = {
|
|
|
263
279
|
updateMany: jest.fn(),
|
|
264
280
|
findUnique: jest.fn(),
|
|
265
281
|
findMany: jest.fn(),
|
|
282
|
+
// #852 D6 — `recordCheckoutPerson`'s read of the row this session completed.
|
|
283
|
+
findFirst: jest.fn(),
|
|
284
|
+
},
|
|
285
|
+
// ── Inbox namespace (Person — #852 D13) ──────────────────────────────────
|
|
286
|
+
// Every call lives in services/person.ts. `findFirst`'s DEFAULT resolves
|
|
287
|
+
// one fixed `person_` row, so every suite that reaches `persistToDb`
|
|
288
|
+
// through this mock stamps `MOCK_PERSON_ID` on its lead without arranging
|
|
289
|
+
// anything. A suite that wants another answer overrides it per test.
|
|
290
|
+
person: {
|
|
291
|
+
findFirst: jest.fn(async () => ({
|
|
292
|
+
id: MOCK_PERSON_ID,
|
|
293
|
+
email: "mock@example.com",
|
|
294
|
+
name: null,
|
|
295
|
+
userId: null,
|
|
296
|
+
billingCustomerId: null,
|
|
297
|
+
})),
|
|
298
|
+
findMany: jest.fn(),
|
|
299
|
+
createMany: jest.fn(async () => ({ count: 0 })),
|
|
300
|
+
updateMany: jest.fn(async () => ({ count: 0 })),
|
|
301
|
+
count: jest.fn(),
|
|
302
|
+
},
|
|
303
|
+
// #7877 D17 — the member area's pieces and outbound messages. Calls live in
|
|
304
|
+
// lib/tenant-db.ts (`memberArea`), lib/member-store.ts and, for
|
|
305
|
+
// `interaction.count`, lib/asset-store.ts. Each defaults to an empty result.
|
|
306
|
+
piece: {
|
|
307
|
+
findMany: jest.fn(async () => []),
|
|
308
|
+
findFirst: jest.fn(async () => null),
|
|
309
|
+
create: jest.fn(),
|
|
310
|
+
update: jest.fn(),
|
|
311
|
+
updateMany: jest.fn(async () => ({ count: 0 })),
|
|
312
|
+
upsert: jest.fn(),
|
|
313
|
+
count: jest.fn(async () => 0),
|
|
314
|
+
},
|
|
315
|
+
interaction: {
|
|
316
|
+
findMany: jest.fn(async () => []),
|
|
317
|
+
findFirst: jest.fn(async () => null),
|
|
318
|
+
create: jest.fn(),
|
|
319
|
+
update: jest.fn(),
|
|
320
|
+
updateMany: jest.fn(async () => ({ count: 0 })),
|
|
321
|
+
upsert: jest.fn(),
|
|
322
|
+
count: jest.fn(async () => 0),
|
|
323
|
+
},
|
|
324
|
+
// ── Config namespace (WorkspacePieceStages — #7877 D17) ──────────────────
|
|
325
|
+
workspacePieceStages: {
|
|
326
|
+
findMany: jest.fn(async () => []),
|
|
327
|
+
findFirst: jest.fn(async () => null),
|
|
328
|
+
findUnique: jest.fn(async () => null),
|
|
329
|
+
create: jest.fn(),
|
|
330
|
+
update: jest.fn(),
|
|
331
|
+
updateMany: jest.fn(async () => ({ count: 0 })),
|
|
332
|
+
upsert: jest.fn(),
|
|
333
|
+
count: jest.fn(async () => 0),
|
|
266
334
|
},
|
|
267
335
|
// ── Config namespace (SiteListingConfig — #5033) ─────────────────────────
|
|
268
336
|
// `findUnique` — the ONLY read path (`listingConfigFor`), keyed on siteId.
|
|
@@ -399,6 +467,27 @@ export const prisma = {
|
|
|
399
467
|
findUnique: jest.fn(),
|
|
400
468
|
createMany: jest.fn(),
|
|
401
469
|
},
|
|
470
|
+
// ── #7894 — the hub signs in its spokes (auth) ─────────────────────────────
|
|
471
|
+
// Every call lives in `lib/hub-sign-in-store.ts`: `oAuthClient` — a spoke's
|
|
472
|
+
// sign-in client (read by the codes/token/revoke service; upsert/updateMany
|
|
473
|
+
// from the operator CLI); `oAuthAuthorization` — the credential grant
|
|
474
|
+
// (upsert on exchange, updateMany on revoke, findUnique in the preHandler);
|
|
475
|
+
// `verification` — the short-lived authorization-code rows.
|
|
476
|
+
oAuthClient: {
|
|
477
|
+
findUnique: jest.fn(),
|
|
478
|
+
upsert: jest.fn(),
|
|
479
|
+
updateMany: jest.fn(),
|
|
480
|
+
},
|
|
481
|
+
oAuthAuthorization: {
|
|
482
|
+
findUnique: jest.fn(),
|
|
483
|
+
upsert: jest.fn(),
|
|
484
|
+
updateMany: jest.fn(),
|
|
485
|
+
},
|
|
486
|
+
verification: {
|
|
487
|
+
create: jest.fn(),
|
|
488
|
+
findFirst: jest.fn(),
|
|
489
|
+
deleteMany: jest.fn(),
|
|
490
|
+
},
|
|
402
491
|
// ── #3591 — agents (auth) ────────────────────────────────────────────────
|
|
403
492
|
// Every call lives in `lib/agent-store.ts`: `findUnique` by credential
|
|
404
493
|
// digest (the auth preHandler's one lookup) or by id; `update` — the
|
|
@@ -21,20 +21,46 @@ const ASSET_ID = "asset_00000000-0000-7000-8000-0000000000a1";
|
|
|
21
21
|
describe("findDeliverableAssetIdByKey", () => {
|
|
22
22
|
beforeEach(() => {
|
|
23
23
|
jest.clearAllMocks();
|
|
24
|
+
(prisma.interaction.count as jest.Mock).mockResolvedValue(0);
|
|
24
25
|
});
|
|
25
26
|
|
|
26
27
|
it("queries with exactly { workspaceId, key }", async () => {
|
|
27
|
-
(prisma.asset.findFirst as jest.Mock).mockResolvedValue({ id: ASSET_ID });
|
|
28
|
+
(prisma.asset.findFirst as jest.Mock).mockResolvedValue({ id: ASSET_ID, tags: [] });
|
|
28
29
|
|
|
29
30
|
const result = await findDeliverableAssetIdByKey(WORKSPACE_ID, "q3-report.pdf");
|
|
30
31
|
|
|
31
32
|
expect(prisma.asset.findFirst).toHaveBeenCalledWith({
|
|
32
33
|
where: { workspaceId: WORKSPACE_ID, key: "q3-report.pdf" },
|
|
33
|
-
select: { id: true },
|
|
34
|
+
select: { id: true, tags: true },
|
|
34
35
|
});
|
|
35
36
|
expect(result).toBe(ASSET_ID);
|
|
36
37
|
});
|
|
37
38
|
|
|
39
|
+
// SEC-7877-A: a client file is never a magnet deliverable — a `private` key
|
|
40
|
+
// resolves to nothing, the same as a miss (#7877 security review SR-7877-1).
|
|
41
|
+
it("returns null for a private-tagged asset", async () => {
|
|
42
|
+
(prisma.asset.findFirst as jest.Mock).mockResolvedValue({
|
|
43
|
+
id: ASSET_ID,
|
|
44
|
+
tags: ["private"],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const result = await findDeliverableAssetIdByKey(WORKSPACE_ID, "member/scan.pdf");
|
|
48
|
+
|
|
49
|
+
expect(result).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns null for an asset a message attaches", async () => {
|
|
53
|
+
(prisma.asset.findFirst as jest.Mock).mockResolvedValue({ id: ASSET_ID, tags: [] });
|
|
54
|
+
(prisma.interaction.count as jest.Mock).mockResolvedValue(1);
|
|
55
|
+
|
|
56
|
+
const result = await findDeliverableAssetIdByKey(WORKSPACE_ID, "member/scan.pdf");
|
|
57
|
+
|
|
58
|
+
expect(prisma.interaction.count).toHaveBeenCalledWith({
|
|
59
|
+
where: { assetId: ASSET_ID, workspaceId: WORKSPACE_ID },
|
|
60
|
+
});
|
|
61
|
+
expect(result).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
38
64
|
it("does not return a same-key row scoped to a different workspace", async () => {
|
|
39
65
|
// The mock proves the CALL is scoped correctly; a real DB enforces the
|
|
40
66
|
// rest. This asserts the where-clause never widens to a foreign
|
|
@@ -48,7 +74,7 @@ describe("findDeliverableAssetIdByKey", () => {
|
|
|
48
74
|
|
|
49
75
|
expect(prisma.asset.findFirst).toHaveBeenCalledWith({
|
|
50
76
|
where: { workspaceId: OTHER_WORKSPACE_ID, key: "q3-report.pdf" },
|
|
51
|
-
select: { id: true },
|
|
77
|
+
select: { id: true, tags: true },
|
|
52
78
|
});
|
|
53
79
|
expect(result).toBeNull();
|
|
54
80
|
});
|
|
@@ -65,6 +91,7 @@ describe("findDeliverableAssetIdByKey", () => {
|
|
|
65
91
|
describe("readDeliverableAsset", () => {
|
|
66
92
|
beforeEach(() => {
|
|
67
93
|
jest.clearAllMocks();
|
|
94
|
+
(prisma.interaction.count as jest.Mock).mockResolvedValue(0);
|
|
68
95
|
});
|
|
69
96
|
|
|
70
97
|
it("queries with exactly { id, workspaceId } and selects filename/contentType/bytes", async () => {
|
|
@@ -72,13 +99,14 @@ describe("readDeliverableAsset", () => {
|
|
|
72
99
|
filename: "q3-report.pdf",
|
|
73
100
|
contentType: "application/pdf",
|
|
74
101
|
bytes: Buffer.from("PDF-BYTES"),
|
|
102
|
+
tags: [],
|
|
75
103
|
});
|
|
76
104
|
|
|
77
105
|
const result = await readDeliverableAsset(ASSET_ID, WORKSPACE_ID);
|
|
78
106
|
|
|
79
107
|
expect(prisma.asset.findFirst).toHaveBeenCalledWith({
|
|
80
108
|
where: { id: ASSET_ID, workspaceId: WORKSPACE_ID },
|
|
81
|
-
select: { filename: true, contentType: true, bytes: true },
|
|
109
|
+
select: { filename: true, contentType: true, bytes: true, tags: true },
|
|
82
110
|
});
|
|
83
111
|
expect(result).toEqual({
|
|
84
112
|
filename: "q3-report.pdf",
|
|
@@ -95,12 +123,45 @@ describe("readDeliverableAsset", () => {
|
|
|
95
123
|
expect(result).toBeNull();
|
|
96
124
|
});
|
|
97
125
|
|
|
126
|
+
// SEC-7877-A: a grant minted before the asset became a client file still
|
|
127
|
+
// never serves it (#7877 security review SR-7877-1).
|
|
128
|
+
it("returns null for a private-tagged asset", async () => {
|
|
129
|
+
(prisma.asset.findFirst as jest.Mock).mockResolvedValue({
|
|
130
|
+
filename: "scan.pdf",
|
|
131
|
+
contentType: "application/pdf",
|
|
132
|
+
bytes: Buffer.from("PDF-BYTES"),
|
|
133
|
+
tags: ["private"],
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const result = await readDeliverableAsset(ASSET_ID, WORKSPACE_ID);
|
|
137
|
+
|
|
138
|
+
expect(result).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("returns null for an asset a message attaches", async () => {
|
|
142
|
+
(prisma.asset.findFirst as jest.Mock).mockResolvedValue({
|
|
143
|
+
filename: "scan.pdf",
|
|
144
|
+
contentType: "application/pdf",
|
|
145
|
+
bytes: Buffer.from("PDF-BYTES"),
|
|
146
|
+
tags: [],
|
|
147
|
+
});
|
|
148
|
+
(prisma.interaction.count as jest.Mock).mockResolvedValue(1);
|
|
149
|
+
|
|
150
|
+
const result = await readDeliverableAsset(ASSET_ID, WORKSPACE_ID);
|
|
151
|
+
|
|
152
|
+
expect(prisma.interaction.count).toHaveBeenCalledWith({
|
|
153
|
+
where: { assetId: ASSET_ID, workspaceId: WORKSPACE_ID },
|
|
154
|
+
});
|
|
155
|
+
expect(result).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
98
158
|
it("wraps the returned bytes as a Buffer view, not a copy", async () => {
|
|
99
159
|
const raw = new Uint8Array([1, 2, 3, 4]);
|
|
100
160
|
(prisma.asset.findFirst as jest.Mock).mockResolvedValue({
|
|
101
161
|
filename: "f.pdf",
|
|
102
162
|
contentType: "application/pdf",
|
|
103
163
|
bytes: raw,
|
|
164
|
+
tags: [],
|
|
104
165
|
});
|
|
105
166
|
|
|
106
167
|
const result = await readDeliverableAsset(ASSET_ID, WORKSPACE_ID);
|
|
@@ -116,7 +177,7 @@ describe("module boundary contract (§1.14 / D16)", () => {
|
|
|
116
177
|
// The boundary's entire contract is "two named functions, nothing else
|
|
117
178
|
// reads prisma.asset" — asserted at the module surface so a future edit
|
|
118
179
|
// adding a third export or a raw prisma re-export fails this test.
|
|
119
|
-
|
|
180
|
+
|
|
120
181
|
const mod = require("../deliverable-asset-store");
|
|
121
182
|
expect(Object.keys(mod).sort()).toEqual(
|
|
122
183
|
["findDeliverableAssetIdByKey", "readDeliverableAsset"].sort(),
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file hub-sign-in-store.test.ts
|
|
3
|
+
*
|
|
4
|
+
* The sign-in-client store's operator half (#7894, spec
|
|
5
|
+
* docs/specs/7894-the-hub-signs-in-its-spec.md D3; AC-5): the callback-URL
|
|
6
|
+
* validator and `registerSignInClient`, which the `spokes sign-in-client` CLI
|
|
7
|
+
* calls. The code, grant and lookup functions are exercised through the
|
|
8
|
+
* service and preHandler suites, which run them end to end.
|
|
9
|
+
*
|
|
10
|
+
* External dependency boundary mocked:
|
|
11
|
+
* - @working-theory/database (via the prisma singleton; __mocks__/prisma.ts)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
|
|
15
|
+
jest.mock("../prisma.js");
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
installHubSignInFakeDb,
|
|
19
|
+
seedSpoke,
|
|
20
|
+
type FakeHubDb,
|
|
21
|
+
} from "../../__tests__/support/hub-sign-in-fake-db.js";
|
|
22
|
+
import {
|
|
23
|
+
canonicalRedirectUri,
|
|
24
|
+
disableSignInClient,
|
|
25
|
+
HUB_CLIENT_SECRET_PREFIX,
|
|
26
|
+
mintClientSecret,
|
|
27
|
+
mintCode,
|
|
28
|
+
mintCredential,
|
|
29
|
+
registerSignInClient,
|
|
30
|
+
sha256Hex,
|
|
31
|
+
} from "../hub-sign-in-store.js";
|
|
32
|
+
import { prisma } from "../prisma.js";
|
|
33
|
+
|
|
34
|
+
const db = jest.mocked(prisma);
|
|
35
|
+
|
|
36
|
+
let state: FakeHubDb;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
jest.resetAllMocks();
|
|
40
|
+
state = installHubSignInFakeDb(db);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("minting", () => {
|
|
44
|
+
it("mints the three secrets as 32 random bytes in base64url, each with its own prefix", () => {
|
|
45
|
+
expect(mintCode()).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
46
|
+
expect(mintCredential()).toMatch(/^ailk_hst_[A-Za-z0-9_-]{43}$/);
|
|
47
|
+
expect(mintClientSecret()).toMatch(/^ailk_hcs_[A-Za-z0-9_-]{43}$/);
|
|
48
|
+
expect(new Set(Array.from({ length: 50 }, mintCode)).size).toBe(50);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("canonicalRedirectUri (D3)", () => {
|
|
53
|
+
it.each([
|
|
54
|
+
[
|
|
55
|
+
"https://site.example.com/api/hub-session/callback",
|
|
56
|
+
"https://site.example.com/api/hub-session/callback",
|
|
57
|
+
],
|
|
58
|
+
[
|
|
59
|
+
"https://SITE.example.com:443/api/hub-session/callback",
|
|
60
|
+
"https://site.example.com/api/hub-session/callback",
|
|
61
|
+
],
|
|
62
|
+
[
|
|
63
|
+
"http://localhost:3000/api/hub-session/callback",
|
|
64
|
+
"http://localhost:3000/api/hub-session/callback",
|
|
65
|
+
],
|
|
66
|
+
[
|
|
67
|
+
"http://127.0.0.1:3000/api/hub-session/callback",
|
|
68
|
+
"http://127.0.0.1:3000/api/hub-session/callback",
|
|
69
|
+
],
|
|
70
|
+
])("accepts %s", (raw, canonical) => {
|
|
71
|
+
expect(canonicalRedirectUri(raw)).toBe(canonical);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it.each([
|
|
75
|
+
[
|
|
76
|
+
"http: on a non-loopback host",
|
|
77
|
+
"http://site.example.com/api/hub-session/callback",
|
|
78
|
+
],
|
|
79
|
+
["another path", "https://site.example.com/api/hub-session/other"],
|
|
80
|
+
["a trailing slash", "https://site.example.com/api/hub-session/callback/"],
|
|
81
|
+
["a query", "https://site.example.com/api/hub-session/callback?x=1"],
|
|
82
|
+
["an empty query", "https://site.example.com/api/hub-session/callback?"],
|
|
83
|
+
["a fragment", "https://site.example.com/api/hub-session/callback#frag"],
|
|
84
|
+
["an empty fragment", "https://site.example.com/api/hub-session/callback#"],
|
|
85
|
+
["userinfo", "https://user:pass@site.example.com/api/hub-session/callback"],
|
|
86
|
+
[
|
|
87
|
+
"a username alone",
|
|
88
|
+
"https://user@site.example.com/api/hub-session/callback",
|
|
89
|
+
],
|
|
90
|
+
["a non-http scheme", "javascript:alert(1)"],
|
|
91
|
+
["a relative URL", "/api/hub-session/callback"],
|
|
92
|
+
["no URL at all", ""],
|
|
93
|
+
])("refuses %s", (_name, raw) => {
|
|
94
|
+
expect(canonicalRedirectUri(raw)).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("registerSignInClient (the `spokes sign-in-client` CLI)", () => {
|
|
99
|
+
const goodUri = "https://new.example.com/api/hub-session/callback";
|
|
100
|
+
|
|
101
|
+
it("creates the client for the spoke, binds it by id, and returns the secret once", async () => {
|
|
102
|
+
const { spoke } = seedSpoke(state);
|
|
103
|
+
state.clients.clear();
|
|
104
|
+
|
|
105
|
+
const result = await registerSignInClient({
|
|
106
|
+
spokeId: spoke.id,
|
|
107
|
+
redirectUri: goodUri,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(result.ok).toBe(true);
|
|
111
|
+
if (!result.ok) return;
|
|
112
|
+
expect(result.secret.startsWith(HUB_CLIENT_SECRET_PREFIX)).toBe(true);
|
|
113
|
+
|
|
114
|
+
const client = state.clients.get(spoke.id);
|
|
115
|
+
// The client's public id IS the spoke's id (D3), and the secret is a digest.
|
|
116
|
+
expect(client?.clientId).toBe(spoke.id);
|
|
117
|
+
expect(client?.clientSecret).toBe(sha256Hex(result.secret));
|
|
118
|
+
expect(JSON.stringify(client)).not.toContain(result.secret);
|
|
119
|
+
expect(client?.redirectUris).toEqual([goodUri]);
|
|
120
|
+
expect(client?.enabled).toBe(true);
|
|
121
|
+
expect(db.oAuthClient.upsert).toHaveBeenCalledWith(
|
|
122
|
+
expect.objectContaining({
|
|
123
|
+
where: { clientId: spoke.id },
|
|
124
|
+
create: expect.objectContaining({
|
|
125
|
+
name: spoke.name,
|
|
126
|
+
scopes: ["account"],
|
|
127
|
+
trusted: true,
|
|
128
|
+
enabled: true,
|
|
129
|
+
}),
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("re-running rotates the secret and replaces the callback", async () => {
|
|
135
|
+
const { spoke } = seedSpoke(state);
|
|
136
|
+
const first = await registerSignInClient({
|
|
137
|
+
spokeId: spoke.id,
|
|
138
|
+
redirectUri: goodUri,
|
|
139
|
+
});
|
|
140
|
+
const second = await registerSignInClient({
|
|
141
|
+
spokeId: spoke.id,
|
|
142
|
+
redirectUri: "https://other.example.com/api/hub-session/callback",
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(first.ok && second.ok).toBe(true);
|
|
146
|
+
if (!first.ok || !second.ok) return;
|
|
147
|
+
expect(second.secret).not.toBe(first.secret);
|
|
148
|
+
const client = state.clients.get(spoke.id);
|
|
149
|
+
expect(client?.clientSecret).toBe(sha256Hex(second.secret));
|
|
150
|
+
expect(client?.redirectUris).toEqual([
|
|
151
|
+
"https://other.example.com/api/hub-session/callback",
|
|
152
|
+
]);
|
|
153
|
+
expect(state.clients.size).toBe(1);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("re-enables a client that was disabled", async () => {
|
|
157
|
+
const { spoke, client } = seedSpoke(state);
|
|
158
|
+
client.enabled = false;
|
|
159
|
+
const result = await registerSignInClient({
|
|
160
|
+
spokeId: spoke.id,
|
|
161
|
+
redirectUri: goodUri,
|
|
162
|
+
});
|
|
163
|
+
expect(result.ok).toBe(true);
|
|
164
|
+
expect(state.clients.get(spoke.id)?.enabled).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("refuses an invalid redirect URI before reading the spoke or writing anything", async () => {
|
|
168
|
+
const { spoke } = seedSpoke(state);
|
|
169
|
+
const before = JSON.stringify([...state.clients.values()]);
|
|
170
|
+
const result = await registerSignInClient({
|
|
171
|
+
spokeId: spoke.id,
|
|
172
|
+
redirectUri: "http://site.example.com/api/hub-session/callback",
|
|
173
|
+
});
|
|
174
|
+
expect(result).toEqual({ ok: false, reason: "invalid-redirect-uri" });
|
|
175
|
+
expect(db.oAuthClient.upsert).not.toHaveBeenCalled();
|
|
176
|
+
expect(JSON.stringify([...state.clients.values()])).toBe(before);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("refuses an unknown spoke and a revoked one", async () => {
|
|
180
|
+
expect(
|
|
181
|
+
await registerSignInClient({
|
|
182
|
+
spokeId: "spoke_nope",
|
|
183
|
+
redirectUri: goodUri,
|
|
184
|
+
}),
|
|
185
|
+
).toEqual({
|
|
186
|
+
ok: false,
|
|
187
|
+
reason: "spoke-not-found",
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const { spoke } = seedSpoke(state);
|
|
191
|
+
spoke.revokedAt = new Date();
|
|
192
|
+
expect(
|
|
193
|
+
await registerSignInClient({ spokeId: spoke.id, redirectUri: goodUri }),
|
|
194
|
+
).toEqual({
|
|
195
|
+
ok: false,
|
|
196
|
+
reason: "spoke-revoked",
|
|
197
|
+
});
|
|
198
|
+
expect(db.oAuthClient.upsert).not.toHaveBeenCalled();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("disableSignInClient", () => {
|
|
203
|
+
it("switches the client off and reports whether it had one", async () => {
|
|
204
|
+
const { spoke, client } = seedSpoke(state);
|
|
205
|
+
expect(await disableSignInClient(spoke.id)).toBe(true);
|
|
206
|
+
expect(client.enabled).toBe(false);
|
|
207
|
+
expect(await disableSignInClient("spoke_nope")).toBe(false);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -19,6 +19,7 @@ import { createHash } from "node:crypto";
|
|
|
19
19
|
import { prisma } from "../prisma.js";
|
|
20
20
|
import {
|
|
21
21
|
SPOKE_KEY_PREFIX,
|
|
22
|
+
findSpokeById,
|
|
22
23
|
findSpokeByKeyHash,
|
|
23
24
|
hasRegisteredSpoke,
|
|
24
25
|
hashSpokeKey,
|
|
@@ -79,6 +80,25 @@ describe("findSpokeByKeyHash (D3 step 5)", () => {
|
|
|
79
80
|
});
|
|
80
81
|
});
|
|
81
82
|
|
|
83
|
+
describe("findSpokeById (#7894 D3, D5)", () => {
|
|
84
|
+
it("is one findUnique by id, selecting the row the sign-in client needs", async () => {
|
|
85
|
+
const row = { id: S1, workspaceId: W1, revokedAt: null, name: "Site" };
|
|
86
|
+
db.spoke.findUnique.mockResolvedValue(row as never);
|
|
87
|
+
|
|
88
|
+
await expect(findSpokeById(S1)).resolves.toEqual(row);
|
|
89
|
+
expect(db.spoke.findUnique).toHaveBeenCalledTimes(1);
|
|
90
|
+
expect(db.spoke.findUnique).toHaveBeenCalledWith({
|
|
91
|
+
where: { id: S1 },
|
|
92
|
+
select: { id: true, workspaceId: true, revokedAt: true, name: true },
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("answers null for an unknown id", async () => {
|
|
97
|
+
db.spoke.findUnique.mockResolvedValue(null);
|
|
98
|
+
await expect(findSpokeById(S1)).resolves.toBeNull();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
82
102
|
describe("hasRegisteredSpoke (#7633 D1, AC-5)", () => {
|
|
83
103
|
it("is true when the registry holds a row, revoked or not — one findFirst with no where", async () => {
|
|
84
104
|
db.spoke.findFirst.mockResolvedValue({ id: S1 } as never);
|
|
@@ -23,12 +23,29 @@
|
|
|
23
23
|
* (D15), so a rewritten or mis-set grant can never serve another
|
|
24
24
|
* workspace's bytes.
|
|
25
25
|
*
|
|
26
|
+
* Neither ever resolves a client file (SEC-7877-A, #7877): an asset tagged
|
|
27
|
+
* `private`, or one a member-area message attaches, is the same `null` a
|
|
28
|
+
* miss is — so a magnet pointed at a client file's key mints nothing and a
|
|
29
|
+
* grant minted before the file became one serves nothing.
|
|
30
|
+
*
|
|
26
31
|
* No write, no `findMany`, no list, no unscoped read — both functions take
|
|
27
32
|
* a `workspaceId` the caller resolved server-side, never a client-supplied
|
|
28
33
|
* value, so neither can enumerate or cross a tenant boundary (NFR3).
|
|
29
34
|
*/
|
|
35
|
+
import { PRIVATE_ASSET_TAG } from "@working-theory/validation";
|
|
36
|
+
|
|
30
37
|
import { prisma } from "./prisma.js";
|
|
31
38
|
|
|
39
|
+
/** A client file (SEC-7877-A) — `private`-tagged, or attached to a message. */
|
|
40
|
+
async function isClientFile(
|
|
41
|
+
assetId: string,
|
|
42
|
+
workspaceId: string,
|
|
43
|
+
tags: string[],
|
|
44
|
+
): Promise<boolean> {
|
|
45
|
+
if (tags.includes(PRIVATE_ASSET_TAG)) return true;
|
|
46
|
+
return (await prisma.interaction.count({ where: { assetId, workspaceId } })) > 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
32
49
|
/** What the mint-time resolution needs, and nothing more. */
|
|
33
50
|
export async function findDeliverableAssetIdByKey(
|
|
34
51
|
workspaceId: string,
|
|
@@ -36,9 +53,10 @@ export async function findDeliverableAssetIdByKey(
|
|
|
36
53
|
): Promise<string | null> {
|
|
37
54
|
const row = await prisma.asset.findFirst({
|
|
38
55
|
where: { workspaceId, key },
|
|
39
|
-
select: { id: true },
|
|
56
|
+
select: { id: true, tags: true },
|
|
40
57
|
});
|
|
41
|
-
|
|
58
|
+
if (!row || (await isClientFile(row.id, workspaceId, row.tags))) return null;
|
|
59
|
+
return row.id;
|
|
42
60
|
}
|
|
43
61
|
|
|
44
62
|
/** What the serve path needs, and deliberately nothing more (no key, no workspace). */
|
|
@@ -61,12 +79,13 @@ export async function readDeliverableAsset(
|
|
|
61
79
|
|
|
62
80
|
const row = await prisma.asset.findFirst({
|
|
63
81
|
where: { id: assetId, workspaceId },
|
|
64
|
-
select: { filename: true, contentType: true, bytes: true },
|
|
82
|
+
select: { filename: true, contentType: true, bytes: true, tags: true },
|
|
65
83
|
});
|
|
66
|
-
if (!row) return null;
|
|
84
|
+
if (!row || (await isClientFile(assetId, workspaceId, row.tags))) return null;
|
|
67
85
|
|
|
68
86
|
return {
|
|
69
|
-
|
|
87
|
+
filename: row.filename,
|
|
88
|
+
contentType: row.contentType,
|
|
70
89
|
// Prisma maps a `Bytes` column to `Uint8Array`, and Fastify treats only
|
|
71
90
|
// a `Buffer` as a raw payload — this wraps rather than copies
|
|
72
91
|
// (`Buffer.from(buffer, byteOffset, length)` is a view), mirroring
|