create-ailk 0.14.0 → 0.16.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/component-catalog.json +3 -3
- package/dist/add-module.d.ts +7 -0
- package/dist/add-module.js +80 -3
- package/dist/add-route.js +26 -2
- package/dist/adopt.d.ts +74 -0
- package/dist/adopt.js +314 -0
- package/dist/apply-design.d.ts +12 -1
- package/dist/apply-design.js +36 -4
- package/dist/cli.js +103 -3
- package/dist/derive-architecture.d.ts +37 -2
- package/dist/derive-architecture.js +92 -7
- package/dist/design-package.js +3 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/chrome-readers.d.ts +21 -0
- package/dist/lib/chrome-readers.js +100 -0
- 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 +20 -0
- package/dist/lib/extract-module-bundle.js +53 -6
- package/dist/lib/license-key-files.d.ts +6 -1
- package/dist/lib/license-key-files.js +9 -4
- package/dist/lib/module-tier.d.ts +2 -0
- package/dist/lib/module-tier.js +4 -0
- package/dist/module-architecture.d.ts +9 -0
- package/dist/module-architecture.js +82 -0
- package/dist/parse-args.d.ts +27 -0
- package/dist/parse-args.js +89 -0
- package/dist/programmatic.d.ts +28 -0
- package/dist/programmatic.js +181 -11
- package/dist/seed-flows.d.ts +65 -0
- package/dist/seed-flows.js +350 -0
- package/dist/site-chrome.d.ts +4 -0
- package/dist/site-chrome.js +34 -3
- package/dist/site-manifest.d.ts +55 -5
- package/dist/site-manifest.js +121 -12
- package/dist/site-roster.d.ts +13 -0
- package/dist/site-roster.js +22 -8
- package/dist/spoke-checkout.d.ts +42 -0
- package/dist/spoke-checkout.js +359 -0
- package/dist/surfaces.js +7 -0
- package/dist/sync-routes.js +29 -5
- package/dist/topology.d.ts +246 -0
- package/dist/topology.js +942 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +3 -3
- package/templates/.env.example +10 -0
- package/templates/apps/api/CLAUDE.md +21 -4
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
- 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 +99 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
- 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__/site.test.ts +54 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
- package/templates/apps/api/src/lib/agent-store.ts +95 -3
- 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/site.ts +37 -0
- package/templates/apps/api/src/lib/spoke-store.ts +41 -3
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
- package/templates/apps/api/src/middleware/auth.ts +129 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
- package/templates/apps/api/src/openapi/spec.ts +38 -2
- 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/checkout/__tests__/sessions.test.ts +50 -2
- package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
- package/templates/apps/api/src/routes/leads/index.ts +69 -6
- package/templates/apps/api/src/routes/webhooks/README.md +39 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
- 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 +492 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
- package/templates/apps/api/src/services/default-tenant.ts +41 -7
- package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
- package/templates/apps/api/src/services/lead-routing.ts +278 -30
- package/templates/apps/api/src/services/person.ts +541 -0
- package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
- package/templates/apps/mcp/__tests__/index.test.ts +5 -5
- package/templates/apps/mcp/src/index.ts +6 -4
- package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
- package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
- package/templates/apps/web/.env.example +58 -5
- package/templates/apps/web/CLAUDE.md +1 -0
- 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]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
- package/templates/apps/web/app/[locale]/layout.tsx +83 -18
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
- 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 +104 -1
- package/templates/apps/web/app/openapi.json/route.ts +72 -7
- 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/instrumentation.ts +7 -4
- package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
- package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
- 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 +317 -0
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
- 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/closing-footer-path.ts +50 -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 +209 -3
- package/templates/apps/web/lib/server-api.ts +11 -1
- package/templates/apps/web/lib/site-brand.tsx +22 -1
- package/templates/apps/web/lib/site-chrome.ts +3 -0
- package/templates/apps/web/lib/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +32 -3
- package/templates/apps/web/package.json +1 -1
- package/templates/database/CHANGELOG.md +51 -0
- package/templates/database/__tests__/agents.db.test.ts +85 -0
- package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -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 +24 -0
- package/templates/database/config/schema.prisma +13 -0
- package/templates/database/inbox/schema.prisma +111 -0
- package/templates/database/jest.config.cjs +7 -11
- package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
- package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
- package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -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 -34
|
@@ -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: {
|
|
@@ -70,12 +76,30 @@ export const prisma = {
|
|
|
70
76
|
// #3591 — `lib/agent-store.ts`'s list names each agent's creator by id.
|
|
71
77
|
findMany: jest.fn(),
|
|
72
78
|
update: jest.fn(),
|
|
79
|
+
// #7803 — `activateWorkspace` records the remembered workspace on the
|
|
80
|
+
// caller's own row (tenant-context.ts, spec D3).
|
|
81
|
+
updateMany: jest.fn(),
|
|
82
|
+
create: jest.fn(),
|
|
83
|
+
},
|
|
84
|
+
// #7803 — `activateWorkspace` writes the session's active-workspace pointer
|
|
85
|
+
// in one `$transaction` with the `user.updateMany` above.
|
|
86
|
+
session: {
|
|
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`).
|
|
73
91
|
create: jest.fn(),
|
|
92
|
+
findUnique: jest.fn(),
|
|
74
93
|
},
|
|
75
94
|
apiToken: {
|
|
76
95
|
create: jest.fn(),
|
|
77
96
|
findMany: jest.fn(),
|
|
78
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(),
|
|
79
103
|
},
|
|
80
104
|
userData: {
|
|
81
105
|
findUnique: jest.fn(),
|
|
@@ -210,6 +234,8 @@ export const prisma = {
|
|
|
210
234
|
findFirst: jest.fn(),
|
|
211
235
|
findMany: jest.fn(),
|
|
212
236
|
upsert: jest.fn(),
|
|
237
|
+
// #7844 D7 — `copyFlowDefinition`'s one create-only write.
|
|
238
|
+
create: jest.fn(),
|
|
213
239
|
},
|
|
214
240
|
// `deleteMany` — used by the FlowSession retention/cleanup sweep
|
|
215
241
|
// (flow-engine.ts's `cleanupAbandonedSessions`, #3974 AC3).
|
|
@@ -253,6 +279,58 @@ export const prisma = {
|
|
|
253
279
|
updateMany: jest.fn(),
|
|
254
280
|
findUnique: jest.fn(),
|
|
255
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),
|
|
256
334
|
},
|
|
257
335
|
// ── Config namespace (SiteListingConfig — #5033) ─────────────────────────
|
|
258
336
|
// `findUnique` — the ONLY read path (`listingConfigFor`), keyed on siteId.
|
|
@@ -389,6 +467,27 @@ export const prisma = {
|
|
|
389
467
|
findUnique: jest.fn(),
|
|
390
468
|
createMany: jest.fn(),
|
|
391
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
|
+
},
|
|
392
491
|
// ── #3591 — agents (auth) ────────────────────────────────────────────────
|
|
393
492
|
// Every call lives in `lib/agent-store.ts`: `findUnique` by credential
|
|
394
493
|
// digest (the auth preHandler's one lookup) or by id; `update` — the
|
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
* transaction, and refuses (not found) a workspace of another
|
|
14
14
|
* organization or a person with no membership there (SEC-3591-15);
|
|
15
15
|
* - re-keying and switching are scoped by organization: another
|
|
16
|
-
* organization's agent is "not found", and nothing is written
|
|
16
|
+
* organization's agent is "not found", and nothing is written;
|
|
17
|
+
* - setting grants (#7864 D4, AC-4) collapses duplicates into D1 order,
|
|
18
|
+
* refuses a grant its person's role does not hold, and is scoped by
|
|
19
|
+
* organization like the other writes.
|
|
17
20
|
*
|
|
18
21
|
* Test fixtures never hold a key-shaped literal: every `ailk_agt_` value is
|
|
19
22
|
* built at run time by `mintAgentKey()`, or the repo-wide secret scan flags it.
|
|
@@ -36,6 +39,7 @@ import {
|
|
|
36
39
|
replaceServedAgentKey,
|
|
37
40
|
setAgentEnabled,
|
|
38
41
|
setServedAgentEnabled,
|
|
42
|
+
setServedAgentGrants,
|
|
39
43
|
touchAgentLastActive,
|
|
40
44
|
} from "../agent-store.js";
|
|
41
45
|
import { prisma } from "../prisma.js";
|
|
@@ -71,6 +75,7 @@ const AGENT_ROW = {
|
|
|
71
75
|
workspaceId: WS_A1,
|
|
72
76
|
responsibleMembershipId: PERSON_MEMBERSHIP,
|
|
73
77
|
permissionLevel: "member",
|
|
78
|
+
grants: [] as string[],
|
|
74
79
|
enabled: true,
|
|
75
80
|
lastActiveAt: null,
|
|
76
81
|
createdAt: new Date("2026-10-04T00:00:00Z"),
|
|
@@ -129,6 +134,7 @@ describe("findAgentByKeyHash", () => {
|
|
|
129
134
|
workspaceId: WS_A1,
|
|
130
135
|
organizationId: ORG_A,
|
|
131
136
|
permissionLevel: "member",
|
|
137
|
+
grants: ["content-edit"],
|
|
132
138
|
enabled: true,
|
|
133
139
|
keyIssuedToMembershipId: OWNER_MEMBERSHIP,
|
|
134
140
|
lastActiveAt: null,
|
|
@@ -146,6 +152,7 @@ describe("findAgentByKeyHash", () => {
|
|
|
146
152
|
workspaceId: true,
|
|
147
153
|
organizationId: true,
|
|
148
154
|
permissionLevel: true,
|
|
155
|
+
grants: true,
|
|
149
156
|
enabled: true,
|
|
150
157
|
keyIssuedToMembershipId: true,
|
|
151
158
|
lastActiveAt: true,
|
|
@@ -157,6 +164,7 @@ describe("findAgentByKeyHash", () => {
|
|
|
157
164
|
workspaceId: WS_A1,
|
|
158
165
|
organizationId: ORG_A,
|
|
159
166
|
permissionLevel: "member",
|
|
167
|
+
grants: ["content-edit"],
|
|
160
168
|
enabled: true,
|
|
161
169
|
keyIssuedToMembershipId: OWNER_MEMBERSHIP,
|
|
162
170
|
lastActiveAt: null,
|
|
@@ -399,6 +407,110 @@ describe("the served writes (route-facing)", () => {
|
|
|
399
407
|
});
|
|
400
408
|
});
|
|
401
409
|
|
|
410
|
+
describe("setServedAgentGrants (#7864 D4, AC-4)", () => {
|
|
411
|
+
/** The row the transaction reads: the agent with its person's role. */
|
|
412
|
+
function agentWithRole(role: string, grants: string[] = []) {
|
|
413
|
+
return { organizationId: ORG_A, grants, responsible: { role } };
|
|
414
|
+
}
|
|
415
|
+
let listGrants: string[];
|
|
416
|
+
|
|
417
|
+
beforeEach(() => {
|
|
418
|
+
listGrants = [];
|
|
419
|
+
(prisma.agent.updateMany as jest.Mock).mockImplementation(
|
|
420
|
+
({ data }: { data: { grants: string[] } }) => {
|
|
421
|
+
listGrants = data.grants;
|
|
422
|
+
return Promise.resolve({ count: 1 });
|
|
423
|
+
},
|
|
424
|
+
);
|
|
425
|
+
(prisma.agent.findMany as jest.Mock).mockImplementation(() =>
|
|
426
|
+
Promise.resolve([
|
|
427
|
+
{
|
|
428
|
+
...AGENT_ROW,
|
|
429
|
+
grants: listGrants,
|
|
430
|
+
responsible: { userId: PERSON, user: { name: "Pat", email: "pat@example.test" } },
|
|
431
|
+
keyIssuedTo: { userId: OWNER, user: { name: "Olive" } },
|
|
432
|
+
},
|
|
433
|
+
]),
|
|
434
|
+
);
|
|
435
|
+
(prisma.user.findMany as jest.Mock).mockResolvedValue([{ id: OWNER, name: "Olive" }]);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("writes the set in D1 order with duplicates collapsed, in one transaction, and serves the list row", async () => {
|
|
439
|
+
(prisma.agent.findUnique as jest.Mock).mockResolvedValue(agentWithRole("admin", ["assets-write"]));
|
|
440
|
+
|
|
441
|
+
const result = await setServedAgentGrants({
|
|
442
|
+
organizationId: ORG_A,
|
|
443
|
+
agentId: AGENT,
|
|
444
|
+
grants: ["magnets-manage", "content-edit", "magnets-manage"],
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
|
448
|
+
expect(prisma.agent.findUnique).toHaveBeenCalledWith({
|
|
449
|
+
where: { id: AGENT, organizationId: ORG_A },
|
|
450
|
+
select: { organizationId: true, grants: true, responsible: { select: { role: true } } },
|
|
451
|
+
});
|
|
452
|
+
expect(prisma.agent.updateMany).toHaveBeenCalledWith({
|
|
453
|
+
where: { id: AGENT, organizationId: ORG_A },
|
|
454
|
+
data: { grants: ["content-edit", "magnets-manage"] },
|
|
455
|
+
});
|
|
456
|
+
expect(result).toMatchObject({
|
|
457
|
+
ok: true,
|
|
458
|
+
agent: { id: AGENT, grants: ["content-edit", "magnets-manage"] },
|
|
459
|
+
previous: ["assets-write"],
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("an empty set removes every grant", async () => {
|
|
464
|
+
(prisma.agent.findUnique as jest.Mock).mockResolvedValue(
|
|
465
|
+
agentWithRole("owner", ["content-edit", "content-publish"]),
|
|
466
|
+
);
|
|
467
|
+
const result = await setServedAgentGrants({ organizationId: ORG_A, agentId: AGENT, grants: [] });
|
|
468
|
+
expect(prisma.agent.updateMany).toHaveBeenCalledWith({
|
|
469
|
+
where: { id: AGENT, organizationId: ORG_A },
|
|
470
|
+
data: { grants: [] },
|
|
471
|
+
});
|
|
472
|
+
expect(result).toMatchObject({ ok: true, agent: { grants: [] }, previous: ["content-edit", "content-publish"] });
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("refuses a grant its person's role does not hold, and writes nothing", async () => {
|
|
476
|
+
(prisma.agent.findUnique as jest.Mock).mockResolvedValue(agentWithRole("member"));
|
|
477
|
+
await expect(
|
|
478
|
+
setServedAgentGrants({ organizationId: ORG_A, agentId: AGENT, grants: ["content-edit"] }),
|
|
479
|
+
).resolves.toEqual({ ok: false, reason: "exceeds-person" });
|
|
480
|
+
expect(prisma.agent.updateMany).not.toHaveBeenCalled();
|
|
481
|
+
expect(prisma.agent.findMany).not.toHaveBeenCalled();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("is not-found for an unknown agent or one of another organization, and writes nothing", async () => {
|
|
485
|
+
(prisma.agent.findUnique as jest.Mock).mockResolvedValueOnce(null);
|
|
486
|
+
await expect(
|
|
487
|
+
setServedAgentGrants({ organizationId: ORG_A, agentId: AGENT, grants: ["content-edit"] }),
|
|
488
|
+
).resolves.toEqual({ ok: false, reason: "not-found" });
|
|
489
|
+
|
|
490
|
+
(prisma.agent.findUnique as jest.Mock).mockResolvedValueOnce({
|
|
491
|
+
...agentWithRole("owner"),
|
|
492
|
+
organizationId: ORG_B,
|
|
493
|
+
});
|
|
494
|
+
await expect(
|
|
495
|
+
setServedAgentGrants({ organizationId: ORG_A, agentId: AGENT, grants: ["content-edit"] }),
|
|
496
|
+
).resolves.toEqual({ ok: false, reason: "not-found" });
|
|
497
|
+
expect(prisma.agent.updateMany).not.toHaveBeenCalled();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("the list row serves only the five, in D1 order, whatever the row holds", async () => {
|
|
501
|
+
(prisma.agent.findMany as jest.Mock).mockResolvedValue([
|
|
502
|
+
{
|
|
503
|
+
...AGENT_ROW,
|
|
504
|
+
grants: ["meeting-types-manage", "content-edit", "agents-manage"],
|
|
505
|
+
responsible: { userId: PERSON, user: { name: "Pat", email: "pat@example.test" } },
|
|
506
|
+
keyIssuedTo: null,
|
|
507
|
+
},
|
|
508
|
+
]);
|
|
509
|
+
const [row] = await listAgentsForOrganization(ORG_A);
|
|
510
|
+
expect(row?.grants).toEqual(["content-edit", "meeting-types-manage"]);
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
402
514
|
describe("listAgentsForOrganization", () => {
|
|
403
515
|
beforeEach(() => {
|
|
404
516
|
(prisma.agent.findMany as jest.Mock).mockResolvedValue([
|
|
@@ -423,6 +535,7 @@ describe("listAgentsForOrganization", () => {
|
|
|
423
535
|
workspaceId: WS_A1,
|
|
424
536
|
responsible: { userId: PERSON, name: "Pat", email: "pat@example.test" },
|
|
425
537
|
permissionLevel: "member",
|
|
538
|
+
grants: [],
|
|
426
539
|
enabled: true,
|
|
427
540
|
lastActiveAt: null,
|
|
428
541
|
createdAt: AGENT_ROW.createdAt,
|
|
@@ -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
|
+
});
|