create-ailk 0.19.1 → 0.20.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 (132) hide show
  1. package/README.md +64 -7
  2. package/dist/cli.js +35 -0
  3. package/dist/copy.d.ts +41 -0
  4. package/dist/copy.js +87 -21
  5. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  6. package/dist/lib/fetch-template-snapshot.js +205 -0
  7. package/dist/lib/merge-three-way.d.ts +31 -0
  8. package/dist/lib/merge-three-way.js +65 -0
  9. package/dist/module-architecture.js +55 -2
  10. package/dist/programmatic.d.ts +7 -1
  11. package/dist/programmatic.js +32 -2
  12. package/dist/spoke-checkout.d.ts +7 -0
  13. package/dist/spoke-checkout.js +8 -1
  14. package/dist/surfaces.js +67 -2
  15. package/dist/template-record.d.ts +59 -0
  16. package/dist/template-record.js +183 -0
  17. package/dist/update.d.ts +48 -0
  18. package/dist/update.js +504 -0
  19. package/package.json +3 -3
  20. package/templates/.claude/cloudparity-repo-hooks.json +9 -0
  21. package/templates/.claude/hooks/architecture-check.sh +8 -0
  22. package/templates/.claude/rules/loop-contract.md +129 -0
  23. package/templates/.claude/settings.json +3 -2
  24. package/templates/.env.example +13 -0
  25. package/templates/CLAUDE.md +1 -1
  26. package/templates/CONVENTIONS.md +3 -2
  27. package/templates/_gitignore +162 -0
  28. package/templates/apps/api/CLAUDE.md +14 -10
  29. package/templates/apps/api/package.json +2 -1
  30. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +22 -2
  31. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  32. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  33. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  34. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  35. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  36. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  37. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +5 -2
  38. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  39. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  40. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  41. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +21 -14
  42. package/templates/apps/api/src/middleware/auth.ts +8 -4
  43. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  44. package/templates/apps/api/src/middleware/rate-limit.ts +290 -82
  45. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  46. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  47. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  48. package/templates/apps/api/src/routes/webhooks/stripe.ts +184 -6
  49. package/templates/apps/api/src/server.ts +14 -2
  50. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  51. package/templates/apps/api/src/services/audit-log.ts +72 -10
  52. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  53. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  54. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  55. package/templates/apps/api/vercel.json +1 -1
  56. package/templates/apps/mcp/CLAUDE.md +19 -7
  57. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
  58. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  59. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  60. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  61. package/templates/apps/web/_gitignore +6 -0
  62. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  63. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  64. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  65. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  67. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +28 -83
  68. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +27 -13
  69. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  70. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +2 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +102 -18
  72. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  74. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +3 -2
  75. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  77. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  78. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  79. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  80. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  81. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  82. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  83. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  84. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  85. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  86. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +11 -2
  87. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +5 -24
  88. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +64 -18
  89. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  90. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  91. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  92. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  93. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  94. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  95. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +5 -3
  96. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  97. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  98. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
  99. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  100. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  101. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  102. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  103. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  104. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  105. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  106. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  107. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  108. package/templates/apps/web/jest.config.cjs +3 -0
  109. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  110. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  111. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
  112. package/templates/apps/web/lib/account-config.ts +31 -16
  113. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  114. package/templates/apps/web/lib/relay.ts +20 -0
  115. package/templates/apps/web/messages/en.json +67 -4
  116. package/templates/apps/web/next.config.mjs +8 -2
  117. package/templates/database/CHANGELOG.md +39 -0
  118. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  119. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  120. package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
  121. package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
  122. package/templates/database/_gitignore +8 -0
  123. package/templates/database/inbox/schema.prisma +38 -12
  124. package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
  125. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
  126. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
  127. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
  128. package/templates/database/ops/schema.prisma +16 -0
  129. package/templates/database/package.json +1 -1
  130. package/templates/project.yaml +6 -3
  131. package/versions.json +35 -35
  132. package/templates/.claude/rules/preserve-existing-style.md +0 -51
@@ -39,6 +39,12 @@
39
39
  * `outcome: "foreign_site"` and answers 200. An unstamped session is the
40
40
  * hub's own (roadmap D4) and is handled as before. The hub stamps its own
41
41
  * sessions through `siteStamp()`. See README.md § Site filter (#7868).
42
+ * Since #8024 one gate before the switch does the same for every
43
+ * `customer.*`, `customer.subscription.*` and `invoice.*` event, reading the
44
+ * stamp from the Customer's or Subscription's own metadata — for an invoice,
45
+ * its own metadata and then the subscription snapshot under
46
+ * `parent.subscription_details` or (pre-`basil`) `subscription_details` —
47
+ * and only when `data.object.object` is the family's own type.
42
48
  *
43
49
  * Billing upsert (customer.created / checkout.session.completed):
44
50
  * Billing-entity routing (org-scoped billing, #3601, spec D2): a checkout
@@ -70,6 +76,20 @@
70
76
  * `"no_payment_required"` (a 100%-off promo code, #4862) — anything else
71
77
  * logs `outcome: "unpaid"` and leaves the row `created`.
72
78
  *
79
+ * Proposal paid stamp (checkout.session.completed, #8516 D13):
80
+ * A session carrying the server-written `metadata.ailk_proposal` and
81
+ * `ailk_workspace` (the client's proposal Checkout,
82
+ * `services/proposal-payments.ts`) stamps that proposal's `paid_at` and
83
+ * `paid_session_id` once, through the base-tree boundary
84
+ * `lib/proposal-paid-store.ts` (this file names no `piece` delegate), and
85
+ * writes one `people.proposal.paid` audit row as the Stripe system actor
86
+ * keyed by the event id: `succeeded` when stamped, `failed`
87
+ * (`already_paid`) when a different session already paid it, none on a
88
+ * same-session replay, a `warn` when no such proposal exists, and `failed`
89
+ * (`not_paid`) with no stamp for a session that is not paid. A session
90
+ * without both keys makes no query. Runs BESIDE the other stamps; never
91
+ * throws; logs an 8-character prefix and the outcome only.
92
+ *
73
93
  * Checkout Person (checkout.session.completed, #852 D6):
74
94
  * Chained after that stamp, `recordFlowCheckoutPerson` finds or creates the
75
95
  * Person of the checkout row THIS session completed (matched on its own
@@ -87,10 +107,12 @@
87
107
  */
88
108
  import { track } from "@working-theory/analytics/server";
89
109
  import type { Prisma } from "@working-theory/database";
110
+ import { isId } from "@working-theory/ids";
90
111
  import type { FastifyReply, FastifyRequest } from "fastify";
91
112
 
92
113
  import { config } from "../../config/index.js";
93
114
  import { prisma } from "../../lib/prisma.js";
115
+ import { stampProposalPaid as writeProposalPaid } from "../../lib/proposal-paid-store.js";
94
116
  import {
95
117
  resolveCommerceClaim,
96
118
  commerceDisabledResponse,
@@ -110,6 +132,7 @@ import {
110
132
  verifyWebhookSignature,
111
133
  } from "../../lib/stripe.js";
112
134
  import { hasSeen, markSeen } from "../../lib/webhook-idempotency.js";
135
+ import { recordWorkspaceAuditAction } from "../../services/audit-log.js";
113
136
  import { isUniqueViolation } from "../../services/default-tenant.js";
114
137
  import {
115
138
  DUNNING_MAX_SUBSCRIPTIONS,
@@ -309,6 +332,20 @@ export const stripeWebhookHandler = async (
309
332
  }
310
333
  markSeen(event.id);
311
334
 
335
+ // #8024 — the billing families' site filter, after `markSeen` as #7868's
336
+ // in-arm check is, so a foreign redelivery is a duplicate. One gate rather
337
+ // than a check per arm: the `default` arm's types are filtered too.
338
+ const foreignStamp = siteStampSourcesOf(event).find(isForeignSiteStamp);
339
+ if (foreignStamp) {
340
+ logForeignSiteSkip(request, {
341
+ stamp: foreignStamp[SITE_STAMP_KEY],
342
+ eventType: event.type,
343
+ eventIdPrefix,
344
+ });
345
+ await reply.code(200).send({ received: true });
346
+ return;
347
+ }
348
+
312
349
  switch (event.type) {
313
350
  case "customer.created": {
314
351
  const customer = event.data.object as {
@@ -400,6 +437,7 @@ export const stripeWebhookHandler = async (
400
437
  }),
401
438
  ),
402
439
  stampRequestPaid(request, { session, eventIdPrefix }),
440
+ stampProposalPaid(request, { session, eventId: event.id, eventIdPrefix }),
403
441
  ]);
404
442
  break;
405
443
  }
@@ -497,11 +535,11 @@ type CheckoutSessionObject = {
497
535
  };
498
536
 
499
537
  /**
500
- * The one log line for a session another site stamped (#7868 D3). Carries the
501
- * foreign stamp (first 64 characters) and this site's own alias, so a spoke
502
- * event correctly skipped can be told from a hub event whose alias drifted —
503
- * site labels only: it takes the stamp, never the session, so no buyer field
504
- * or amount can reach it.
538
+ * The one log line for an event another site stamped (#7868 D3, #8024 D3).
539
+ * Carries the foreign stamp (first 64 characters) and this site's own alias,
540
+ * so a spoke event correctly skipped can be told from a hub event whose alias
541
+ * drifted — site labels only: it takes the stamp, never the object, so no
542
+ * buyer field, customer id or amount can reach it.
505
543
  */
506
544
  function logForeignSiteSkip(
507
545
  request: FastifyRequest,
@@ -523,10 +561,61 @@ function logForeignSiteSkip(
523
561
  stamp: stamp?.slice(0, 64),
524
562
  ownSite: currentSiteId(),
525
563
  },
526
- "checkout session stamped for another site — skipped",
564
+ "stripe event stamped for another site — skipped",
527
565
  );
528
566
  }
529
567
 
568
+ /** The object type whose metadata carries the site for each billing family (#8024 D2). */
569
+ function stampedObjectTypeOf(eventType: string): "subscription" | "customer" | "invoice" | null {
570
+ if (eventType.startsWith("customer.subscription.")) return "subscription";
571
+ if (eventType.startsWith("customer.")) return "customer";
572
+ if (eventType.startsWith("invoice.")) return "invoice";
573
+ return null;
574
+ }
575
+
576
+ /** `value` when it is a non-null object, else `null` — every step of a stamp read is guarded (NF1). */
577
+ function objectOrNull(value: unknown): Record<string, unknown> | null {
578
+ return typeof value === "object" && value !== null
579
+ ? (value as Record<string, unknown>)
580
+ : null;
581
+ }
582
+
583
+ /**
584
+ * The metadata objects that may carry an event's site stamp (#8024 D2), in the
585
+ * order they are checked: a Subscription's or a Customer's own metadata; an
586
+ * invoice's own metadata, then the subscription snapshot at the pinned API
587
+ * version (`parent.subscription_details.metadata`), then at a pre-`basil` one
588
+ * (`subscription_details.metadata`). Empty for every other event type, and
589
+ * when `data.object.object` is not the family's own type — a
590
+ * `customer.source.*` card, say. Reads the payload alone and never throws on
591
+ * any shape: an absent, `null` or non-object step yields no source, and
592
+ * `isForeignSiteStamp` treats a non-string stamp as unstamped.
593
+ */
594
+ function siteStampSourcesOf(event: {
595
+ type: string;
596
+ data?: unknown;
597
+ }): Record<string, string>[] {
598
+ const objectType = stampedObjectTypeOf(event.type);
599
+ const object = objectOrNull(objectOrNull(event.data)?.["object"]);
600
+ if (objectType === null || object?.["object"] !== objectType) return [];
601
+
602
+ const candidates =
603
+ objectType === "invoice"
604
+ ? [
605
+ object["metadata"],
606
+ objectOrNull(objectOrNull(object["parent"])?.["subscription_details"])?.[
607
+ "metadata"
608
+ ],
609
+ objectOrNull(object["subscription_details"])?.["metadata"],
610
+ ]
611
+ : [object["metadata"]];
612
+ // The cast only narrows the value type for `isForeignSiteStamp`, which
613
+ // reads the stamp as `unknown` and `typeof`-checks it.
614
+ return candidates
615
+ .map(objectOrNull)
616
+ .filter((source) => source !== null) as Record<string, string>[];
617
+ }
618
+
530
619
  /**
531
620
  * Minimal projected shape of a Stripe Invoice object for the dunning arms
532
621
  * (#6034 FR-1). `subscription` and `customer` are EXPANDABLE Stripe fields —
@@ -803,6 +892,95 @@ async function stampRequestPaid(
803
892
  }
804
893
  }
805
894
 
895
+ /**
896
+ * #8516 D13 — stamp a client's proposal paid from its own Checkout Session,
897
+ * and audit the outcome as Stripe. The two ids come only from the session's
898
+ * metadata, which this server wrote when it opened the session — a payer can
899
+ * never set it — and the stamp is scoped by both. Never throws.
900
+ */
901
+ async function stampProposalPaid(
902
+ request: FastifyRequest,
903
+ {
904
+ session,
905
+ eventId,
906
+ eventIdPrefix,
907
+ }: { session: CheckoutSessionObject; eventId: string; eventIdPrefix: string },
908
+ ): Promise<void> {
909
+ const pieceId = session.metadata?.["ailk_proposal"];
910
+ const workspaceId = session.metadata?.["ailk_workspace"];
911
+ const sessionId = session.id;
912
+ if (
913
+ typeof pieceId !== "string" ||
914
+ typeof workspaceId !== "string" ||
915
+ typeof sessionId !== "string" ||
916
+ !isId("piece", pieceId) ||
917
+ !isId("workspace", workspaceId)
918
+ ) {
919
+ return;
920
+ }
921
+ const ref = pieceId.substring(6, 14);
922
+
923
+ const audit = async (outcome: { ok: true } | { ok: false; reason: string }) => {
924
+ const result = await recordWorkspaceAuditAction({
925
+ workspaceId,
926
+ actor: { type: "system", systemId: "stripe" },
927
+ domain: "people",
928
+ tool: "proposal.paid",
929
+ outcome: outcome.ok ? "succeeded" : "failed",
930
+ ...(!outcome.ok && { failureReason: outcome.reason }),
931
+ idempotencyKey: eventId,
932
+ targetType: "piece",
933
+ targetId: pieceId,
934
+ });
935
+ if (!result.ok) {
936
+ request.log.error(
937
+ { eventIdPrefix, ref, error: result.error.kind },
938
+ "proposal paid: audit write failed",
939
+ );
940
+ }
941
+ };
942
+
943
+ if (
944
+ session.payment_status !== "paid" &&
945
+ session.payment_status !== "no_payment_required"
946
+ ) {
947
+ request.log.warn(
948
+ { eventIdPrefix, ref, outcome: "not_paid" },
949
+ "proposal paid: completed session is not paid — not stamping",
950
+ );
951
+ await audit({ ok: false, reason: "not_paid" });
952
+ return;
953
+ }
954
+
955
+ try {
956
+ const outcome = await writeProposalPaid(workspaceId, pieceId, sessionId, new Date());
957
+ if (outcome === "stamped") {
958
+ request.log.info({ eventIdPrefix, ref, outcome }, "proposal stamped paid");
959
+ await audit({ ok: true });
960
+ } else if (outcome === "already_paid") {
961
+ request.log.warn(
962
+ { eventIdPrefix, ref, outcome },
963
+ "proposal paid: already paid by another session — a second charge",
964
+ );
965
+ await audit({ ok: false, reason: "already_paid" });
966
+ } else if (outcome === "not_found") {
967
+ request.log.warn({ eventIdPrefix, ref, outcome }, "proposal paid: no such proposal");
968
+ } else {
969
+ request.log.info({ eventIdPrefix, ref, outcome }, "proposal paid: replay");
970
+ }
971
+ } catch (err) {
972
+ request.log.error(
973
+ {
974
+ eventIdPrefix,
975
+ ref,
976
+ outcome: "stamp_failed",
977
+ errName: err instanceof Error ? err.name : "UnknownError",
978
+ },
979
+ "proposal paid stamp failed — continuing",
980
+ );
981
+ }
982
+ }
983
+
806
984
  /**
807
985
  * The hosted-invoice twin of `stampRequestPaid` (#5970 D4/D8).
808
986
  *
@@ -235,7 +235,8 @@ const WORKSPACES_SUBSCRIBERS = "./routes/workspaces/subscribers.js";
235
235
  // #852 D8 — the People reads (the workspace's clients), the SAME scope.
236
236
  const WORKSPACES_PEOPLE = "./routes/workspaces/people.js";
237
237
  // #7877 D9 — a client's pieces, plan and outbound messages, and the
238
- // workspace's piece stages: the operator's writes, the SAME scope.
238
+ // workspace's piece stages: the writes owners, admins and (#8514) members
239
+ // make, the SAME scope.
239
240
  const WORKSPACES_PIECES = "./routes/workspaces/pieces.js";
240
241
  const WORKSPACES_PLAN = "./routes/workspaces/plan.js";
241
242
  const WORKSPACES_MESSAGES_OUT = "./routes/workspaces/messages-out.js";
@@ -267,6 +268,9 @@ const MEMBER_BOOKINGS = "./routes/member/bookings.js";
267
268
  // other member routes. D8 — the Admin owner's consents to Working Theory,
268
269
  // under multi-tenant alone (the module that contributes the Admin app).
269
270
  const MEMBER_CONSENTS = "./routes/member/consents.js";
271
+ // #8515 D13 — the client's onboarding read and write, under the tenancy
272
+ // predicate beside the request-quote pair.
273
+ const MEMBER_ONBOARDING = "./routes/member/onboarding.js";
270
274
  const CONSENT_PLATFORM = "./routes/consent/platform.js";
271
275
  // #3591 PR-B — the agent seat's management routes and an agent's own
272
276
  // context, siblings of WORKSPACES_MEMBERS in the same workspace/multi-tenant
@@ -348,7 +352,8 @@ const SLICE_PLUGINS = {
348
352
  specifier: WORKSPACES_PEOPLE,
349
353
  load: () => import(WORKSPACES_PEOPLE),
350
354
  },
351
- // #7877 D9 — the member area's operator routes, the SAME scope.
355
+ // #7877 D9 — the member area's operator routes (owners, admins and, since
356
+ // #8514, members), the SAME scope.
352
357
  workspacePieceRoutes: {
353
358
  specifier: WORKSPACES_PIECES,
354
359
  load: () => import(WORKSPACES_PIECES),
@@ -410,6 +415,11 @@ const SLICE_PLUGINS = {
410
415
  specifier: MEMBER_REQUESTS,
411
416
  load: () => import(MEMBER_REQUESTS),
412
417
  },
418
+ // #8515 D13 — the onboarding read and write.
419
+ memberOnboardingRoutes: {
420
+ specifier: MEMBER_ONBOARDING,
421
+ load: () => import(MEMBER_ONBOARDING),
422
+ },
413
423
  memberArtifactReviewRoutes: {
414
424
  specifier: MEMBER_ARTIFACT_REVIEW,
415
425
  load: () => import(MEMBER_ARTIFACT_REVIEW),
@@ -1063,6 +1073,8 @@ export async function build() {
1063
1073
  await authScope.register(await sliceRoutePlugin("memberBillingRoutes"));
1064
1074
  // #8170 D11 — the member writes, under the same predicate.
1065
1075
  await authScope.register(await sliceRoutePlugin("memberRequestRoutes"));
1076
+ // #8515 D13 — the onboarding pair, under the same predicate.
1077
+ await authScope.register(await sliceRoutePlugin("memberOnboardingRoutes"));
1066
1078
  await authScope.register(await sliceRoutePlugin("memberArtifactReviewRoutes"));
1067
1079
  await authScope.register(await sliceRoutePlugin("memberProfileRoutes"));
1068
1080
  await authScope.register(await sliceRoutePlugin("memberDeletionRequestRoutes"));
@@ -51,6 +51,7 @@ import {
51
51
  freshAuditIdempotencyKey,
52
52
  recordAgentWrite,
53
53
  recordAuditAction,
54
+ recordWorkspaceAuditAction,
54
55
  redactPersonalData,
55
56
  type AuditActor,
56
57
  type AuditRowDigestInput,
@@ -935,3 +936,93 @@ describe("recordAgentWrite given the request (#3591 PR-B)", () => {
935
936
  expect(mockLoggerError.mock.calls[0]![1]).toBe("[audit-log] agent write audit refused");
936
937
  });
937
938
  });
939
+
940
+ // ─── #8516 D14 — recordWorkspaceAuditAction, a row without a tenant ────────────
941
+
942
+ describe("#8516 AC-7 — recordWorkspaceAuditAction", () => {
943
+ const PIECE_ID = "piece_0190f3a2-7c1d-7e4a-9b2c-000000000004";
944
+ const ROW = {
945
+ domain: "people",
946
+ tool: "proposal.client_review",
947
+ outcome: "succeeded" as const,
948
+ idempotencyKey: "req_proposal_review",
949
+ targetType: "piece",
950
+ targetId: PIECE_ID,
951
+ summary: '{"by":"client","review":"approved","priced":true}',
952
+ };
953
+
954
+ it("writes the row recordAuditAction would for the same workspace and user actor", async () => {
955
+ const tenantWrite = stubDb();
956
+ const tenantResult = await recordAuditAction(
957
+ { ...ROW, tenant: TENANT, actor: userActor(USER_ID) },
958
+ tenantWrite.db,
959
+ );
960
+ const workspaceWrite = stubDb();
961
+ const workspaceResult = await recordWorkspaceAuditAction(
962
+ { ...ROW, workspaceId: WORKSPACE_ID, actor: { type: "user", userId: USER_ID as Id<"user"> } },
963
+ workspaceWrite.db,
964
+ );
965
+
966
+ expect(workspaceWrite.create).toHaveBeenCalledTimes(1);
967
+ expect(firstWrittenRow(workspaceWrite.create)).toEqual(firstWrittenRow(tenantWrite.create));
968
+ expect(workspaceResult).toEqual(tenantResult);
969
+ expect(workspaceResult).toMatchObject({ ok: true, outcome: "recorded" });
970
+ });
971
+
972
+ it("the system actor stores actor_type system and actor_id stripe", async () => {
973
+ const { db, create } = stubDb();
974
+ const result = await recordWorkspaceAuditAction(
975
+ {
976
+ workspaceId: WORKSPACE_ID,
977
+ actor: { type: "system", systemId: "stripe" },
978
+ domain: "people",
979
+ tool: "proposal.paid",
980
+ outcome: "failed",
981
+ failureReason: "already_paid",
982
+ idempotencyKey: "evt_test_proposal",
983
+ targetType: "piece",
984
+ targetId: PIECE_ID,
985
+ },
986
+ db,
987
+ );
988
+
989
+ expect(result).toMatchObject({ ok: true, outcome: "recorded" });
990
+ expect(create).toHaveBeenCalledTimes(1);
991
+ const row = firstWrittenRow(create);
992
+ expect(row).toMatchObject({
993
+ workspaceId: WORKSPACE_ID,
994
+ actorType: "system",
995
+ actorId: "stripe",
996
+ action: "people.proposal.paid",
997
+ targetType: "piece",
998
+ targetId: PIECE_ID,
999
+ });
1000
+ expect(row.metadata).toEqual({
1001
+ v: 2,
1002
+ outcome: "failed",
1003
+ tool: "proposal.paid",
1004
+ idempotencyKey: "evt_test_proposal",
1005
+ failureReason: "already_paid",
1006
+ });
1007
+ expect(row.metadata).not.toHaveProperty("onBehalfOf");
1008
+ expect(zAuditLogId.safeParse(row.id).success).toBe(true);
1009
+ });
1010
+
1011
+ it("an invalid workspaceId is invalid_input and writes nothing", async () => {
1012
+ const { db, create } = stubDb();
1013
+ const result = await recordWorkspaceAuditAction(
1014
+ { ...ROW, workspaceId: "not-a-workspace", actor: { type: "system", systemId: "stripe" } },
1015
+ db,
1016
+ );
1017
+
1018
+ expect(result).toEqual({
1019
+ ok: false,
1020
+ error: {
1021
+ kind: "invalid_input",
1022
+ message: "workspaceId is not a valid workspace id",
1023
+ persisted: false,
1024
+ },
1025
+ });
1026
+ expect(create).not.toHaveBeenCalled();
1027
+ });
1028
+ });
@@ -99,8 +99,8 @@ import { isUniqueViolation, type TenantContext } from "./default-tenant.js";
99
99
  // ─── Types ──────────────────────────────────────────────────────────────────
100
100
 
101
101
  /** The full actor-kind vocabulary the model comments (not a DB CHECK). This
102
- * file writes `"user"` and `"agent"` (#3591 D8); `"system"` is kept so a
103
- * future writer for that actor shares one union. */
102
+ * file writes `"user"` and `"agent"` (#3591 D8), and `"system"` for Stripe's
103
+ * payment outcome (#8516 D14, `recordWorkspaceAuditAction`). */
104
104
  export type AuditActorType = "user" | "agent" | "system";
105
105
 
106
106
  /**
@@ -210,6 +210,12 @@ export interface RecordAuditActionInput {
210
210
  targetId?: string;
211
211
  }
212
212
 
213
+ /** Every member of a row's input but who and where — shared by both writers. */
214
+ type AuditRowInput = Omit<RecordAuditActionInput, "tenant" | "actor">;
215
+
216
+ /** Every actor a row can name: a tenant caller's, or a tenant-less one's (D14). */
217
+ type WriterActor = AuditActor | { type: "system"; systemId: "stripe" };
218
+
213
219
  export type RecordAuditActionResult =
214
220
  | { ok: true; outcome: "recorded" | "duplicate"; id: Id<"auditLog"> }
215
221
  | {
@@ -487,7 +493,7 @@ function classifyError(error: unknown): { code: string; name: string } {
487
493
  interface ValidatedFields {
488
494
  workspaceId: string;
489
495
  actorId: string;
490
- actorType: "user" | "agent";
496
+ actorType: AuditActorType;
491
497
  onBehalfOf: string | undefined;
492
498
  tool: string;
493
499
  targetType: string | undefined;
@@ -547,15 +553,24 @@ function actorIdRefusal(id: string, field: string): string | undefined {
547
553
  * (never a silent rewrite of an identifier field) rather than as a
548
554
  * redaction, unlike `summary` / `failureReason`.
549
555
  */
550
- function validateInput(input: RecordAuditActionInput): ValidationResult {
551
- if (!zWorkspaceId.safeParse(input.tenant.workspaceId).success) {
556
+ function validateInput(
557
+ workspaceId: string,
558
+ workspaceField: string,
559
+ actor: WriterActor,
560
+ input: AuditRowInput,
561
+ ): ValidationResult {
562
+ if (!zWorkspaceId.safeParse(workspaceId).success) {
552
563
  return {
553
564
  ok: false,
554
- message: "tenant.workspaceId is not a valid workspace id",
565
+ message: `${workspaceField} is not a valid workspace id`,
555
566
  };
556
567
  }
557
- const { actor } = input;
558
- const actorId = actor.type === "agent" ? actor.agentId : actor.userId;
568
+ const actorId =
569
+ actor.type === "agent"
570
+ ? actor.agentId
571
+ : actor.type === "system"
572
+ ? actor.systemId
573
+ : actor.userId;
559
574
  const actorRefusal = actorIdRefusal(actorId, "actorId");
560
575
  if (actorRefusal !== undefined) {
561
576
  return { ok: false, message: actorRefusal };
@@ -628,7 +643,7 @@ function validateInput(input: RecordAuditActionInput): ValidationResult {
628
643
  return {
629
644
  ok: true,
630
645
  value: {
631
- workspaceId: input.tenant.workspaceId,
646
+ workspaceId,
632
647
  actorId,
633
648
  actorType: actor.type,
634
649
  onBehalfOf,
@@ -674,7 +689,54 @@ export async function recordAuditAction(
674
689
  input: RecordAuditActionInput,
675
690
  db: AuditWriteClient = prisma,
676
691
  ): Promise<RecordAuditActionResult> {
677
- const validated = validateInput(input);
692
+ const { tenant, actor, ...row } = input;
693
+ return writeAuditRow(tenant.workspaceId, "tenant.workspaceId", actor, row, db);
694
+ }
695
+
696
+ /**
697
+ * Who acted on a tenant-less row (#8516 D14): a client — a signed-in person
698
+ * with no membership — or Stripe itself, reporting a payment outcome.
699
+ */
700
+ export type WorkspaceAuditActor =
701
+ | { type: "user"; userId: Id<"user"> }
702
+ | { type: "system"; systemId: "stripe" };
703
+
704
+ /** `recordWorkspaceAuditAction`'s input: a workspace id in place of a tenant. */
705
+ export type RecordWorkspaceAuditActionInput = AuditRowInput & {
706
+ workspaceId: string;
707
+ actor: WorkspaceAuditActor;
708
+ };
709
+
710
+ /**
711
+ * One `audit_logs` row for a caller that holds no `TenantContext` (#8516
712
+ * D14): a client acting in the member area, or a Stripe webhook. Writes the
713
+ * row `recordAuditAction` would for the same workspace and user actor; the
714
+ * system actor stores `actor_type` `system` and `actor_id` `stripe`. Never
715
+ * throws; never blocks the caller's action.
716
+ *
717
+ * CALLER OBLIGATION — `workspaceId` MUST come from `resolveMember`'s
718
+ * resolved workspace, or from a Stripe-signed event's server-written
719
+ * metadata matched to a stored row — NEVER from request input. A client
720
+ * actor's row says so in its `tool` and summary (`by: "client"`), so it is
721
+ * never read as a staff member's action.
722
+ */
723
+ export async function recordWorkspaceAuditAction(
724
+ input: RecordWorkspaceAuditActionInput,
725
+ db: AuditWriteClient = prisma,
726
+ ): Promise<RecordAuditActionResult> {
727
+ const { workspaceId, actor, ...row } = input;
728
+ return writeAuditRow(workspaceId, "workspaceId", actor, row, db);
729
+ }
730
+
731
+ /** The body of both writers: one validated, digested, idempotent insert. */
732
+ async function writeAuditRow(
733
+ workspaceIdInput: string,
734
+ workspaceField: string,
735
+ actor: WriterActor,
736
+ input: AuditRowInput,
737
+ db: AuditWriteClient,
738
+ ): Promise<RecordAuditActionResult> {
739
+ const validated = validateInput(workspaceIdInput, workspaceField, actor, input);
678
740
  if (!validated.ok) {
679
741
  return {
680
742
  ok: false,
@@ -74,6 +74,13 @@ export interface TenantContext {
74
74
  * `@working-theory/validation`'s `allows`.
75
75
  */
76
76
  grants?: readonly AgentGrantablePermission[];
77
+ /**
78
+ * Set ONLY by `agentTenant` (#8514 D4); a person's tenant never carries it.
79
+ * `@working-theory/validation`'s `roleAllows` / `allows` read it to withhold
80
+ * `AGENT_WITHHELD_PERMISSIONS` from an agent's role, and both tenant guards
81
+ * refuse an agent request whose tenant lacks it (fail closed).
82
+ */
83
+ agent?: true;
77
84
  }
78
85
 
79
86
  export type TenantErrorKind = "forbidden" | "not_found" | "conflict";
@@ -222,7 +229,9 @@ export async function resolveDefaultTenantForOperator(
222
229
  * An agent's tenant (#3591 D5, #7864): its person's tenant with the role
223
230
  * clamped to the agent's ceiling and, when any survive, the agent's grants
224
231
  * that its person's own role holds. `grants` is omitted when none survive,
225
- * so an agent with no effective grant is the same shape as before.
232
+ * so an agent with no effective grant is the same shape as before. It always
233
+ * carries `agent: true` (#8514 D4), which withholds the client-keeping rows a
234
+ * member holds by role.
226
235
  *
227
236
  * The one clamp both tenant guards apply — `resolveAgentTenant` (multi-tenant)
228
237
  * and `resolveDefaultTenantForAgent` (base build) — so the two builds cannot
@@ -238,8 +247,8 @@ export function agentTenant(
238
247
  (p) => agent.grants.includes(p) && can(personTenant.role, p),
239
248
  );
240
249
  return grants.length > 0
241
- ? { ...personTenant, role, grants }
242
- : { ...personTenant, role };
250
+ ? { ...personTenant, role, agent: true, grants }
251
+ : { ...personTenant, role, agent: true };
243
252
  }
244
253
 
245
254
  /**
@@ -114,7 +114,8 @@ function refuse<T = never>(
114
114
  // keyed on an identity the request has already proved (the user the cookie
115
115
  // session resolved to; the client that passed secret AND spoke key), so an
116
116
  // invented key never gets one (SEC-7894-8). The shape of `middleware/auth.ts`'s
117
- // per-agent limiter. SINGLE-REPLICA, like every limiter in this app.
117
+ // per-agent limiter. SINGLE-REPLICA, like the in-process limiters in this
118
+ // app (the per-IP middleware shares a database store, #5040).
118
119
 
119
120
  interface Bucket {
120
121
  windowStartMs: number;
@@ -352,14 +352,33 @@ export async function resolvePublicContentAdapter(
352
352
  if (site.kind === "not-required" || resolvedAdapterKind() !== "database") {
353
353
  return getContentAdapter();
354
354
  }
355
+ return workspaceContentAdapter(site.workspaceId);
356
+ }
357
+
358
+ /**
359
+ * The content adapter holding ONE workspace's own pages, or `null` when no
360
+ * content is that workspace's (#8514 D9, C13). On the database adapter it is
361
+ * that workspace's database adapter. Off it, the shared content is the
362
+ * deployment's one site — the default workspace's — so only that workspace
363
+ * gets it and every other workspace gets `null`, never the hub's pages
364
+ * (SEC-8514-I). The Admin's plan picker reads its offerings through it.
365
+ */
366
+ export async function workspaceContentAdapter(
367
+ workspaceId: string,
368
+ ): Promise<ContentAdapter | null> {
369
+ if (resolvedAdapterKind() !== "database") {
370
+ // Deferred — see the module-load note in this file's header (#5852 D6).
371
+ const { resolveDefaultWorkspaceId } = await import("./default-tenant.js");
372
+ return (await resolveDefaultWorkspaceId()) === workspaceId ? getContentAdapter() : null;
373
+ }
355
374
 
356
- // The database path, and the only caller that needs apps/api's client. The
375
+ // The database path, and the only one that needs apps/api's client. The
357
376
  // import is deferred for the reason the header gives (#5852 D6), and the
358
377
  // generated client is structurally wider than the narrow read surface the
359
378
  // adapter declares — the cast names that, and nothing else.
360
379
  const { prisma } = await import("../lib/prisma.js");
361
380
  return getContentAdapter({
362
- workspaceId: site.workspaceId,
381
+ workspaceId,
363
382
  prisma: prisma as unknown as ArtifactPrismaClient,
364
383
  });
365
384
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 2,
3
- "ignoreCommand": "[ -n \"$VERCEL_GIT_PREVIOUS_SHA\" ] && cd ../.. && git diff --quiet \"$VERCEL_GIT_PREVIOUS_SHA\" HEAD -- apps/api packages database pnpm-lock.yaml || exit 1",
3
+ "ignoreCommand": "[ -n \"$VERCEL_GIT_PREVIOUS_SHA\" ] && cd ../.. && git diff --quiet \"$VERCEL_GIT_PREVIOUS_SHA\" HEAD -- apps/api packages database pnpm-lock.yaml package.json pnpm-workspace.yaml turbo.json 'tsconfig*.json' || exit 1",
4
4
  "buildCommand": "cd ../.. && pnpm run --filter @working-theory/api build:vercel",
5
5
  "installCommand": "cd ../.. && pnpm install --frozen-lockfile",
6
6
  "outputDirectory": "public",