create-ailk 0.15.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.
Files changed (127) hide show
  1. package/README.md +124 -0
  2. package/dist/add-module.js +52 -7
  3. package/dist/add-route.js +8 -0
  4. package/dist/adopt.d.ts +74 -0
  5. package/dist/adopt.js +314 -0
  6. package/dist/cli.js +82 -2
  7. package/dist/derive-architecture.d.ts +22 -0
  8. package/dist/derive-architecture.js +58 -1
  9. package/dist/lib/declared-env.d.ts +142 -0
  10. package/dist/lib/declared-env.js +985 -0
  11. package/dist/lib/env-catalog.d.ts +17 -0
  12. package/dist/lib/env-catalog.js +234 -0
  13. package/dist/lib/extract-module-bundle.d.ts +9 -0
  14. package/dist/lib/extract-module-bundle.js +18 -1
  15. package/dist/lib/module-tier.d.ts +2 -0
  16. package/dist/lib/module-tier.js +4 -0
  17. package/dist/module-architecture.js +76 -0
  18. package/dist/parse-args.d.ts +5 -0
  19. package/dist/parse-args.js +17 -1
  20. package/dist/programmatic.d.ts +8 -0
  21. package/dist/programmatic.js +78 -9
  22. package/dist/seed-flows.d.ts +65 -0
  23. package/dist/seed-flows.js +350 -0
  24. package/dist/site-manifest.d.ts +43 -0
  25. package/dist/site-manifest.js +98 -3
  26. package/dist/site-roster.d.ts +13 -0
  27. package/dist/site-roster.js +22 -8
  28. package/dist/spoke-checkout.d.ts +42 -0
  29. package/dist/spoke-checkout.js +359 -0
  30. package/dist/surfaces.js +7 -0
  31. package/dist/sync-routes.js +23 -8
  32. package/dist/topology.d.ts +124 -9
  33. package/dist/topology.js +449 -40
  34. package/package.json +3 -3
  35. package/templates/.env.example +10 -0
  36. package/templates/apps/api/CLAUDE.md +19 -4
  37. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
  38. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
  39. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
  40. package/templates/apps/api/src/bin/spokes.ts +62 -1
  41. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
  42. package/templates/apps/api/src/config/index.ts +35 -0
  43. package/templates/apps/api/src/lib/__mocks__/prisma.ts +89 -0
  44. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
  45. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
  46. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
  47. package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
  48. package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
  49. package/templates/apps/api/src/lib/spoke-store.ts +25 -3
  50. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
  51. package/templates/apps/api/src/middleware/auth.ts +126 -0
  52. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
  53. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
  54. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +7 -0
  55. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
  56. package/templates/apps/api/src/routes/webhooks/stripe.ts +72 -1
  57. package/templates/apps/api/src/server.ts +105 -0
  58. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
  59. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
  60. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +293 -0
  61. package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
  62. package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
  63. package/templates/apps/api/src/services/lead-routing.ts +131 -2
  64. package/templates/apps/api/src/services/person.ts +541 -0
  65. package/templates/apps/web/.env.example +58 -5
  66. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
  67. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
  68. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
  69. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
  70. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
  72. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
  73. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
  74. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
  75. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
  76. package/templates/apps/web/app/[locale]/layout.tsx +28 -15
  77. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
  78. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
  79. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
  80. package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
  81. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
  82. package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
  83. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +15 -0
  84. package/templates/apps/web/app/openapi.json/route.ts +3 -6
  85. package/templates/apps/web/auth.ts +29 -2
  86. package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
  87. package/templates/apps/web/components/signed-in-nav.tsx +17 -3
  88. package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
  89. package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
  90. package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
  91. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
  92. package/templates/apps/web/lib/__tests__/relay.test.ts +27 -0
  93. package/templates/apps/web/lib/account-config.ts +130 -1
  94. package/templates/apps/web/lib/api-origin.generated.ts +11 -0
  95. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
  96. package/templates/apps/web/lib/hub-session/config.ts +104 -0
  97. package/templates/apps/web/lib/hub-session/index.ts +23 -0
  98. package/templates/apps/web/lib/hub-session/seal.ts +82 -0
  99. package/templates/apps/web/lib/hub-session/session.ts +180 -0
  100. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
  101. package/templates/apps/web/lib/listings-config.ts +31 -0
  102. package/templates/apps/web/lib/listings.tsx +7 -3
  103. package/templates/apps/web/lib/meeting-slugs.ts +11 -1
  104. package/templates/apps/web/lib/next-path.ts +35 -0
  105. package/templates/apps/web/lib/relay.ts +3 -1
  106. package/templates/apps/web/lib/server-api.ts +11 -1
  107. package/templates/apps/web/lib/topology.generated.ts +15 -5
  108. package/templates/apps/web/messages/en.json +32 -3
  109. package/templates/database/CHANGELOG.md +26 -0
  110. package/templates/database/__tests__/agents.db.test.ts +25 -5
  111. package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
  112. package/templates/database/__tests__/member-area.db.test.ts +245 -0
  113. package/templates/database/__tests__/person.db.test.ts +584 -0
  114. package/templates/database/auth/schema.prisma +3 -2
  115. package/templates/database/config/schema.prisma +13 -0
  116. package/templates/database/inbox/schema.prisma +98 -0
  117. package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
  118. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
  119. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
  120. package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
  121. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
  122. package/templates/database/package.json +1 -1
  123. package/templates/database/scripts/person-backfill.sql +132 -0
  124. package/templates/database/tenancy.ts +53 -0
  125. package/templates/tools/check-declared-env.ts +37 -0
  126. package/templates/tools/declared-env.ts +1163 -0
  127. package/versions.json +35 -35
@@ -65,6 +65,7 @@ import {
65
65
  leadCallbackRoute,
66
66
  } from "../index";
67
67
  import {
68
+ MOCK_PERSON_ID,
68
69
  mockNoWorkspace,
69
70
  mockSentinelWorkspaceExists,
70
71
  } from "../../../lib/__mocks__/prisma.js";
@@ -308,6 +309,8 @@ describe("POST /api/leads — id isolation", () => {
308
309
  source: CONTACT_FORM_LEAD_BODY.source,
309
310
  email: CONTACT_FORM_LEAD_BODY.email,
310
311
  payload: CONTACT_FORM_LEAD_BODY.payload,
312
+ // #852 D6 — the Person the shared mock's `person.findFirst` resolves.
313
+ personId: MOCK_PERSON_ID,
311
314
  },
312
315
  select: { id: true },
313
316
  });
@@ -627,6 +630,8 @@ describe("POST /api/leads — id isolation", () => {
627
630
  source: "step-flow",
628
631
  email: "envelope@example.com",
629
632
  payload: envelopePayload,
633
+ // #852 D6 — the Person the shared mock's `person.findFirst` resolves.
634
+ personId: MOCK_PERSON_ID,
630
635
  },
631
636
  select: { id: true },
632
637
  });
@@ -731,6 +736,8 @@ describe("POST /api/leads — id isolation", () => {
731
736
  source: "step-flow",
732
737
  email: "top-level@example.com",
733
738
  payload: { email: "shadowed-payload@example.com", message: "hi" },
739
+ // #852 D6 — the Person the shared mock's `person.findFirst` resolves.
740
+ personId: MOCK_PERSON_ID,
734
741
  },
735
742
  select: { id: true },
736
743
  });
@@ -328,3 +328,108 @@ describe("AC-5 — PII boundary", () => {
328
328
  }
329
329
  });
330
330
  });
331
+
332
+ // ── #852 D6 — the hub checkout's Person (AC-3) ──────────────────────────────
333
+ //
334
+ // After the stamp, `recordCheckoutPerson` reads the checkout row THIS session
335
+ // completed, then its lead, then finds or creates the Person — with
336
+ // `session.customer` as the customer id only on a live-mode event. Observed
337
+ // through the shared mock's delegates: the `person.findFirst` keyed on a
338
+ // customer id is the customer-id step, so its absence means no id was passed.
339
+ describe("the hub checkout's Person (#852 D6, AC-3)", () => {
340
+ const WORKSPACE = "wksp_00000000-0000-7000-8000-000000000852";
341
+ const LEAD = "lead_00000000-0000-7000-8000-000000000852";
342
+ const personFindFirst = () => jest.mocked(prisma.person.findFirst);
343
+ const personCalls = () =>
344
+ [prisma.person.findFirst, prisma.person.createMany, prisma.person.updateMany].reduce(
345
+ (n, fn) => n + jest.mocked(fn).mock.calls.length,
346
+ 0,
347
+ );
348
+ const personLines = () =>
349
+ logCalls.filter(
350
+ (c) => typeof c[0] === "object" && c[0] !== null && String((c[0] as { outcome?: string }).outcome).startsWith("person_"),
351
+ );
352
+ const liveEvent = (livemode: boolean, object: Record<string, unknown> = {}) => ({
353
+ ...sessionEvent("checkout.session.completed", { customer: "cus_9", ...object }),
354
+ livemode,
355
+ });
356
+
357
+ beforeEach(() => {
358
+ (prisma.flowCheckout.findFirst as jest.Mock).mockResolvedValue({ leadId: LEAD, workspaceId: WORKSPACE });
359
+ (prisma.lead.findFirst as jest.Mock).mockResolvedValue({ email: EMAIL });
360
+ });
361
+
362
+ it("a paid live-mode hub session finds the Person of its checkout's lead with session.customer", async () => {
363
+ personFindFirst().mockResolvedValueOnce(null); // no Person carries cus_9 yet
364
+ mockVerifyWebhookSignature.mockReturnValue(liveEvent(true));
365
+ expect((await post()).statusCode).toBe(200);
366
+
367
+ expect(prisma.flowCheckout.findFirst).toHaveBeenCalledWith({
368
+ where: { id: FLOW_CHECKOUT_ID, stripeSessionId: SESSION_ID, status: "completed" },
369
+ select: { leadId: true, workspaceId: true },
370
+ });
371
+ expect(prisma.lead.findFirst).toHaveBeenCalledWith({
372
+ where: { id: LEAD, workspaceId: WORKSPACE },
373
+ select: { email: true },
374
+ });
375
+ expect(personFindFirst()).toHaveBeenCalledWith(
376
+ expect.objectContaining({ where: { workspaceId: WORKSPACE, billingCustomerId: "cus_9" } }),
377
+ );
378
+ expect(personFindFirst()).toHaveBeenCalledWith(
379
+ expect.objectContaining({ where: { workspaceId: WORKSPACE, email: EMAIL } }),
380
+ );
381
+ // The stamp settles before the Person step reads the row it completed.
382
+ expect((prisma.flowCheckout.updateMany as jest.Mock).mock.invocationCallOrder[0]!).toBeLessThan(
383
+ (prisma.flowCheckout.findFirst as jest.Mock).mock.invocationCallOrder[0]!,
384
+ );
385
+ });
386
+
387
+ it("the same session in test mode passes no customer id", async () => {
388
+ mockVerifyWebhookSignature.mockReturnValue(liveEvent(false));
389
+ expect((await post()).statusCode).toBe(200);
390
+
391
+ expect(personFindFirst()).toHaveBeenCalledWith(
392
+ expect.objectContaining({ where: { workspaceId: WORKSPACE, email: EMAIL } }),
393
+ );
394
+ for (const [args] of personFindFirst().mock.calls) {
395
+ expect((args as { where: Record<string, unknown> }).where).not.toHaveProperty("billingCustomerId");
396
+ }
397
+ });
398
+
399
+ it("a session naming a checkout another session completed makes no person call and logs person_skipped", async () => {
400
+ // The read is keyed on THIS session's id, so another session's row is no row.
401
+ (prisma.flowCheckout.findFirst as jest.Mock).mockResolvedValue(null);
402
+ mockVerifyWebhookSignature.mockReturnValue(liveEvent(true));
403
+ expect((await post()).statusCode).toBe(200);
404
+
405
+ expect(personCalls()).toBe(0);
406
+ expect(personLines().map((c) => (c[0] as { outcome: string }).outcome)).toEqual(["person_skipped"]);
407
+ });
408
+
409
+ it.each([
410
+ ["a foreign-site session", { metadata: { flowCheckoutId: FLOW_CHECKOUT_ID, site: "wksp_other-spoke" } }],
411
+ ["an unpaid session", { payment_status: "unpaid" }],
412
+ ["a session with no flowCheckoutId", { metadata: { flowSlug: "ailk-reco" } }],
413
+ ])("%s makes no person call", async (_label, object) => {
414
+ mockVerifyWebhookSignature.mockReturnValue(liveEvent(true, object));
415
+ expect((await post()).statusCode).toBe(200);
416
+ expect(prisma.flowCheckout.findFirst).not.toHaveBeenCalled();
417
+ expect(personCalls()).toBe(0);
418
+ });
419
+
420
+ it("a Person step that throws still answers 200 and logs person_failed with no email or customer id", async () => {
421
+ personFindFirst().mockRejectedValueOnce(new Error(`relation people down for ${EMAIL} cus_9`));
422
+ mockVerifyWebhookSignature.mockReturnValue(liveEvent(true));
423
+ expect((await post()).statusCode).toBe(200);
424
+
425
+ const lines = personLines();
426
+ expect(lines.map((c) => c[0])).toEqual([
427
+ { eventIdPrefix: "evt_test", flowCheckoutIdPrefix: FLOW_CHECKOUT_ID.substring(0, 8), outcome: "person_failed" },
428
+ ]);
429
+ for (const line of logCalls) {
430
+ const text = JSON.stringify(line);
431
+ expect(text).not.toContain(EMAIL);
432
+ expect(text).not.toContain("cus_9");
433
+ }
434
+ });
435
+ });
@@ -70,6 +70,12 @@
70
70
  * `"no_payment_required"` (a 100%-off promo code, #4862) — anything else
71
71
  * logs `outcome: "unpaid"` and leaves the row `created`.
72
72
  *
73
+ * Checkout Person (checkout.session.completed, #852 D6):
74
+ * Chained after that stamp, `recordFlowCheckoutPerson` finds or creates the
75
+ * Person of the checkout row THIS session completed (matched on its own
76
+ * `stripeSessionId`), with `session.customer` as the customer id only on a
77
+ * live-mode event. Logs an outcome kind only; never fails the 200.
78
+ *
73
79
  * Dunning (invoice.payment_failed / customer.subscription.updated, #6034):
74
80
  * Records a per-subscriber lifecycle state in a `UserData` row behind a
75
81
  * reserved-key guard (routes/auth/user.ts). `invoice.payment_failed` arms
@@ -111,6 +117,10 @@ import {
111
117
  type DunningEntry,
112
118
  type DunningRecord,
113
119
  } from "../../services/dunning-policy.js";
120
+ import {
121
+ recordCheckoutPerson,
122
+ type CheckoutPersonOutcome,
123
+ } from "../../services/person.js";
114
124
 
115
125
  const TELEMETRY_TIMEOUT_MS = 2000;
116
126
  const DUNNING_WRITE_MAX_ATTEMPTS = 3;
@@ -359,11 +369,19 @@ export const stripeWebhookHandler = async (
359
369
  await Promise.all([
360
370
  fireTelemetryWithTimeout(request, "purchase-completed", props),
361
371
  billingTask,
372
+ // #852 D6 — the Person step runs only after the stamp has settled,
373
+ // so it reads the row this very session completed.
362
374
  stampFlowCheckout(request, {
363
375
  session,
364
376
  eventIdPrefix,
365
377
  outcome: "completed",
366
- }),
378
+ }).then(() =>
379
+ recordFlowCheckoutPerson(request, {
380
+ session,
381
+ livemode: event.livemode === true,
382
+ eventIdPrefix,
383
+ }),
384
+ ),
367
385
  stampRequestPaid(request, { session, eventIdPrefix }),
368
386
  ]);
369
387
  break;
@@ -623,6 +641,59 @@ async function stampFlowCheckout(
623
641
  }
624
642
  }
625
643
 
644
+ /**
645
+ * The hub checkout's Person (#852 D6): after `stampFlowCheckout`, find or
646
+ * create the Person of the completed checkout's lead
647
+ * (`services/person.ts`'s `recordCheckoutPerson`). Runs only for a paid (or
648
+ * nothing-due) session of THIS site — the foreign-site filter has already
649
+ * returned — that carries `metadata.flowCheckoutId`. The customer id's ONE
650
+ * source is `session.customer`, written onto the Person only for a
651
+ * live-mode event (SEC-852-A). A replay matches the same completed row, so a
652
+ * delivery whose Person step failed is repaired by the next one.
653
+ *
654
+ * Logs `{ eventIdPrefix, flowCheckoutIdPrefix, outcome }` only — never the
655
+ * email or the customer id. Never throws: the response stays 200.
656
+ */
657
+ async function recordFlowCheckoutPerson(
658
+ request: FastifyRequest,
659
+ {
660
+ session,
661
+ livemode,
662
+ eventIdPrefix,
663
+ }: {
664
+ session: CheckoutSessionObject;
665
+ livemode: boolean;
666
+ eventIdPrefix: string;
667
+ },
668
+ ): Promise<void> {
669
+ const flowCheckoutId = session.metadata?.["flowCheckoutId"];
670
+ if (!flowCheckoutId) return;
671
+ if (
672
+ session.payment_status !== "paid" &&
673
+ session.payment_status !== "no_payment_required"
674
+ ) {
675
+ return;
676
+ }
677
+ const flowCheckoutIdPrefix = flowCheckoutId.substring(0, 8);
678
+ let outcome: CheckoutPersonOutcome;
679
+ try {
680
+ outcome = await recordCheckoutPerson({
681
+ flowCheckoutId,
682
+ stripeSessionId: session.id,
683
+ customer: session.customer,
684
+ livemode,
685
+ });
686
+ } catch {
687
+ outcome = "person_failed";
688
+ }
689
+ const line = { eventIdPrefix, flowCheckoutIdPrefix, outcome };
690
+ if (outcome === "person_failed") {
691
+ request.log.error(line, "flow checkout person failed — continuing");
692
+ } else {
693
+ request.log.info(line, "flow checkout person");
694
+ }
695
+ }
696
+
626
697
  /**
627
698
  * Stamp `paidAt` on a request-and-confirm callback row (#5970 AC-3).
628
699
  *
@@ -48,6 +48,10 @@ import {
48
48
  } from "./openapi/spec.js";
49
49
  import type { RegisteredRoute } from "./openapi/surface-drift.js";
50
50
  import { exampleAeoRoute, scoreAeoRoute } from "./routes/aeo/index.js";
51
+ import {
52
+ hubSignInClientRoutes,
53
+ hubSignInSessionRoutes,
54
+ } from "./routes/auth/hub-sign-in.js";
51
55
  import { tokenRoutes } from "./routes/auth/tokens.js";
52
56
  import { transactionalEmailRoute } from "./routes/auth/transactional-email.js";
53
57
  import { userRoutes } from "./routes/auth/user.js";
@@ -217,6 +221,16 @@ const WORKSPACES_MESSAGES = "./routes/workspaces/messages.js";
217
221
  // WORKSPACES_MESSAGES in the same workspace/multi-tenant scope.
218
222
  const WORKSPACES_SUBMISSIONS = "./routes/workspaces/submissions.js";
219
223
  const WORKSPACES_SUBSCRIBERS = "./routes/workspaces/subscribers.js";
224
+ // #852 D8 — the People reads (the workspace's clients), the SAME scope.
225
+ const WORKSPACES_PEOPLE = "./routes/workspaces/people.js";
226
+ // #7877 D9 — a client's pieces, plan and outbound messages, and the
227
+ // workspace's piece stages: the operator's writes, the SAME scope.
228
+ const WORKSPACES_PIECES = "./routes/workspaces/pieces.js";
229
+ const WORKSPACES_PLAN = "./routes/workspaces/plan.js";
230
+ const WORKSPACES_MESSAGES_OUT = "./routes/workspaces/messages-out.js";
231
+ // #7877 D11 — the member reads: auth only, under the SAME module predicate.
232
+ const MEMBER_PIECES = "./routes/member/pieces.js";
233
+ const MEMBER_MESSAGES = "./routes/member/messages.js";
220
234
  // #3591 PR-B — the agent seat's management routes and an agent's own
221
235
  // context, siblings of WORKSPACES_MEMBERS in the same workspace/multi-tenant
222
236
  // scope.
@@ -292,6 +306,33 @@ const SLICE_PLUGINS = {
292
306
  specifier: WORKSPACES_SUBSCRIBERS,
293
307
  load: () => import(WORKSPACES_SUBSCRIBERS),
294
308
  },
309
+ // #852 D8 — the People list and Person record, the SAME scope.
310
+ workspacePeopleRoutes: {
311
+ specifier: WORKSPACES_PEOPLE,
312
+ load: () => import(WORKSPACES_PEOPLE),
313
+ },
314
+ // #7877 D9 — the member area's operator routes, the SAME scope.
315
+ workspacePieceRoutes: {
316
+ specifier: WORKSPACES_PIECES,
317
+ load: () => import(WORKSPACES_PIECES),
318
+ },
319
+ workspacePlanRoutes: {
320
+ specifier: WORKSPACES_PLAN,
321
+ load: () => import(WORKSPACES_PLAN),
322
+ },
323
+ workspaceOutboundMessageRoutes: {
324
+ specifier: WORKSPACES_MESSAGES_OUT,
325
+ load: () => import(WORKSPACES_MESSAGES_OUT),
326
+ },
327
+ // #7877 D11 — the member reads, in authScope under the same predicate.
328
+ memberPieceRoutes: {
329
+ specifier: MEMBER_PIECES,
330
+ load: () => import(MEMBER_PIECES),
331
+ },
332
+ memberMessageRoutes: {
333
+ specifier: MEMBER_MESSAGES,
334
+ load: () => import(MEMBER_MESSAGES),
335
+ },
295
336
  // #3591 PR-B — the agent seat, the SAME scope.
296
337
  workspaceAgentRoutes: {
297
338
  specifier: WORKSPACES_AGENTS,
@@ -365,6 +406,12 @@ const SLICE_PLUGINS = {
365
406
  specifier: FLOWS_INDEX,
366
407
  load: () => import(FLOWS_INDEX),
367
408
  },
409
+ // #7876 D1 — a spoke seeds its declared flows; off until
410
+ // AILK_SPOKE_FLOW_SEED (the route answers 403 flow_seed_disabled).
411
+ putFlowDefinitionRoute: {
412
+ specifier: FLOWS_INDEX,
413
+ load: () => import(FLOWS_INDEX),
414
+ },
368
415
  // #6157 — the tenant-scoped magnet Admin authoring surface. ONE plugin,
369
416
  // ONE specifier (D2): all four routes register through this one entry.
370
417
  magnetRoutes: {
@@ -791,6 +838,14 @@ export async function build() {
791
838
  await authScope.register(tokenRoutes);
792
839
  await authScope.register(userRoutes);
793
840
 
841
+ // The hub signs in its spokes (#7894 D5) — `codes` and `me`. Auth only,
842
+ // NOT tenant-guarded: a person signing in to a spoke has no tenant here.
843
+ // Unconditional, like the leads inbox read below: a spoke's sign-in is not
844
+ // a module. `codes` answers a cookie session only; `me` the hub credential
845
+ // only (both enforced in the route). The credential's own admission is the
846
+ // preHandler's hub-credential branch (D6).
847
+ await authScope.register(hubSignInSessionRoutes);
848
+
794
849
  // Tenant-context routes (multi-tenant module) — auth only. They
795
850
  // establish/inspect the active workspace, so the tenant guard does not
796
851
  // apply to them; each enforces membership in the service layer.
@@ -862,6 +917,18 @@ export async function build() {
862
917
  await authScope.register(await sliceRoutePlugin("projectListingMeRoute"));
863
918
  }
864
919
 
920
+ // The member reads (#7877 D11) — auth only, NOT tenant-guarded: a client
921
+ // holds no membership (and an agent, which reaches only tenant-guarded
922
+ // routes, is refused 403 by the preHandler). Each handler resolves the
923
+ // caller through `memberCaller` and the workspace from the calling
924
+ // spoke's key, never a request value. Registered under the SAME module
925
+ // predicate as every routes/workspaces/* route, at the exact URLs the
926
+ // preHandler's `HUB_SESSION_ROUTES` admits a hub credential on.
927
+ if (isModuleEnabled("workspace") || isModuleEnabled("multi-tenant")) {
928
+ await authScope.register(await sliceRoutePlugin("memberPieceRoutes"));
929
+ await authScope.register(await sliceRoutePlugin("memberMessageRoutes"));
930
+ }
931
+
865
932
  // Tenant-guarded domain routes (multi-tenant module) — the tenant
866
933
  // preHandler (default-deny guard, see middleware/tenant.ts) runs after
867
934
  // auth for every route in this inner scope. No domain handler executes
@@ -1024,6 +1091,25 @@ export async function build() {
1024
1091
  await tenantScope.register(
1025
1092
  await sliceRoutePlugin("workspaceSubscriberRoutes"),
1026
1093
  );
1094
+ // The People reads (#852 D8) — the SAME scope and guard: the
1095
+ // workspace's clients and one Person with its derived timeline,
1096
+ // behind `people-read` (owner, admin, a granted agent).
1097
+ await tenantScope.register(
1098
+ await sliceRoutePlugin("workspacePeopleRoutes"),
1099
+ );
1100
+ // The member area's operator routes (#7877 D9) — the SAME scope and
1101
+ // guard: a client's pieces, plan and outbound messages, and the
1102
+ // workspace's declared piece stages. Writes need `people-write`
1103
+ // (owner, admin; never an agent).
1104
+ await tenantScope.register(
1105
+ await sliceRoutePlugin("workspacePieceRoutes"),
1106
+ );
1107
+ await tenantScope.register(
1108
+ await sliceRoutePlugin("workspacePlanRoutes"),
1109
+ );
1110
+ await tenantScope.register(
1111
+ await sliceRoutePlugin("workspaceOutboundMessageRoutes"),
1112
+ );
1027
1113
  // The agent seat (#3591 PR-B) — the SAME scope and guard: owners and
1028
1114
  // admins list, add, re-key and switch off agents (`agents-manage`),
1029
1115
  // and an agent reads its own context at GET /v1/agent/context.
@@ -1249,6 +1335,22 @@ export async function build() {
1249
1335
  await authEmailScope.register(transactionalEmailRoute);
1250
1336
  });
1251
1337
 
1338
+ // The hub signs in its spokes (#7894 D5) — `token` and `revoke`. Their OWN
1339
+ // scope, deliberately OUTSIDE `authScope`: the caller is a spoke's server,
1340
+ // authenticated by its client secret AND its spoke key inside the service,
1341
+ // not by a person's session — an auth preHandler in front would reject every
1342
+ // legitimate call. `scope: "hubsignin"` namespaces the buckets (the scope
1343
+ // name admits no hyphen). The per-IP limit keys on `req.ip` under
1344
+ // `trustProxy`, so it is a backstop only; the control is the per-client
1345
+ // bucket the service keys on the VERIFIED client (SEC-7894-8).
1346
+ await server.register(async (hubSignInScope) => {
1347
+ hubSignInScope.register(rateLimitMiddleware, {
1348
+ perIpPerMinute: 60,
1349
+ scope: "hubsignin",
1350
+ });
1351
+ await hubSignInScope.register(hubSignInClientRoutes);
1352
+ });
1353
+
1252
1354
  // Lead-capture status (#6730 D4) — a cheap read the web page makes before
1253
1355
  // choosing the form or the contact email. Top level, NOT in `leadsScope`:
1254
1356
  // the web server's single egress IP already spends that scope's strict
@@ -1360,6 +1462,9 @@ export async function build() {
1360
1462
  await flowsScope.register(
1361
1463
  await sliceRoutePlugin("getFlowAggregateRoute"),
1362
1464
  );
1465
+ await flowsScope.register(
1466
+ await sliceRoutePlugin("putFlowDefinitionRoute"),
1467
+ );
1363
1468
  }
1364
1469
  // The recommender's email-moment routes (#4841) — same anonymous,
1365
1470
  // rate-limited posture as the three above. Module-conditional (commerce,
@@ -354,7 +354,7 @@ describe("fulfillDeliverable", () => {
354
354
  (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
355
355
  workspaceId: WORKSPACE_ID,
356
356
  });
357
- (prisma.asset.findFirst as jest.Mock).mockResolvedValue({ id: NEW_ASSET_ID });
357
+ (prisma.asset.findFirst as jest.Mock).mockResolvedValue({ id: NEW_ASSET_ID, tags: [] });
358
358
 
359
359
  await fulfillDeliverable(baseInput());
360
360
 
@@ -366,7 +366,7 @@ describe("fulfillDeliverable", () => {
366
366
  );
367
367
  expect(prisma.asset.findFirst).toHaveBeenCalledWith({
368
368
  where: { workspaceId: WORKSPACE_ID, key: "guide-small" },
369
- select: { id: true },
369
+ select: { id: true, tags: true },
370
370
  });
371
371
  expect(prisma.deliverableAsset.findUnique).not.toHaveBeenCalled();
372
372
  expect(prisma.deliverableGrant.create).toHaveBeenCalledWith(
@@ -421,7 +421,7 @@ describe("fulfillDeliverable", () => {
421
421
  (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
422
422
  workspaceId: WORKSPACE_ID,
423
423
  });
424
- (prisma.asset.findFirst as jest.Mock).mockResolvedValue({ id: NEW_ASSET_ID });
424
+ (prisma.asset.findFirst as jest.Mock).mockResolvedValue({ id: NEW_ASSET_ID, tags: [] });
425
425
  // beforeEach's default deliverableAsset.findUnique mock already resolves
426
426
  // a row at this key — it must never even be consulted.
427
427
 
@@ -482,7 +482,7 @@ describe("fulfillDeliverable", () => {
482
482
 
483
483
  expect(prisma.asset.findFirst).toHaveBeenCalledWith({
484
484
  where: { workspaceId: WORKSPACE_ID, key: "guide-small" },
485
- select: { id: true },
485
+ select: { id: true, tags: true },
486
486
  });
487
487
  expect(prisma.asset.findFirst).not.toHaveBeenCalledWith(
488
488
  expect.objectContaining({