create-ailk 0.19.0 → 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 (197) hide show
  1. package/README.md +64 -7
  2. package/dist/add-module.js +7 -2
  3. package/dist/cli.js +35 -0
  4. package/dist/copy.d.ts +41 -0
  5. package/dist/copy.js +87 -21
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  9. package/dist/lib/fetch-template-snapshot.js +205 -0
  10. package/dist/lib/merge-three-way.d.ts +31 -0
  11. package/dist/lib/merge-three-way.js +65 -0
  12. package/dist/lib/scaffold-render-sweep.d.ts +23 -0
  13. package/dist/lib/scaffold-render-sweep.js +52 -0
  14. package/dist/module-architecture.js +64 -5
  15. package/dist/programmatic.d.ts +7 -1
  16. package/dist/programmatic.js +40 -3
  17. package/dist/site-roster.js +21 -4
  18. package/dist/spoke-checkout.d.ts +7 -0
  19. package/dist/spoke-checkout.js +8 -1
  20. package/dist/surfaces.d.ts +14 -0
  21. package/dist/surfaces.js +98 -3
  22. package/dist/template-record.d.ts +59 -0
  23. package/dist/template-record.js +183 -0
  24. package/dist/topology.js +29 -1
  25. package/dist/update.d.ts +48 -0
  26. package/dist/update.js +504 -0
  27. package/package.json +3 -3
  28. package/templates/.claude/cloudparity-repo-hooks.json +9 -0
  29. package/templates/.claude/hooks/architecture-check.sh +8 -0
  30. package/templates/.claude/rules/loop-contract.md +129 -0
  31. package/templates/.claude/settings.json +3 -2
  32. package/templates/.env.example +13 -0
  33. package/templates/CLAUDE.md +1 -1
  34. package/templates/CONVENTIONS.md +3 -2
  35. package/templates/_gitignore +162 -0
  36. package/templates/apps/api/.env.example +17 -1
  37. package/templates/apps/api/CLAUDE.md +18 -8
  38. package/templates/apps/api/package.json +2 -1
  39. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
  40. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  41. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  42. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  43. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  44. package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
  45. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  46. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  47. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  48. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  49. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  50. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  51. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  52. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  53. package/templates/apps/api/src/lib/stripe.ts +97 -5
  54. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
  55. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  56. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  57. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  58. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
  59. package/templates/apps/api/src/middleware/auth.ts +16 -4
  60. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  61. package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
  62. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  63. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  64. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  65. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  66. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  67. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  68. package/templates/apps/api/src/routes/leads/index.ts +15 -0
  69. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  70. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  71. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  72. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  73. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  74. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  75. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  76. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  77. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  78. package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
  79. package/templates/apps/api/src/server.ts +125 -18
  80. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  81. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  82. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  83. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
  84. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  85. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  86. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  87. package/templates/apps/api/src/services/audit-log.ts +72 -10
  88. package/templates/apps/api/src/services/consent.ts +488 -3
  89. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  90. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  91. package/templates/apps/api/src/services/lead-routing.ts +105 -12
  92. package/templates/apps/api/src/services/person.ts +501 -1
  93. package/templates/apps/api/src/services/project-listings.ts +7 -2
  94. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  95. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  96. package/templates/apps/api/src/services/revalidation.ts +11 -1
  97. package/templates/apps/api/vercel.json +1 -1
  98. package/templates/apps/mcp/CLAUDE.md +19 -7
  99. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
  100. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  101. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  102. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  103. package/templates/apps/web/_gitignore +6 -0
  104. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  105. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  106. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  107. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
  117. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
  120. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
  122. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  129. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  132. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
  134. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
  135. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
  137. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
  138. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
  139. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  141. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  142. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  143. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
  146. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  148. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
  149. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
  150. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  151. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  152. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
  153. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  154. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  155. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  156. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  157. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  158. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  159. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  160. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  161. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  162. package/templates/apps/web/auth.ts +20 -1
  163. package/templates/apps/web/jest.config.cjs +3 -0
  164. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  165. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  166. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  167. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
  168. package/templates/apps/web/lib/account-config.ts +31 -16
  169. package/templates/apps/web/lib/auth-email.ts +45 -10
  170. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  171. package/templates/apps/web/lib/relay.ts +20 -0
  172. package/templates/apps/web/lib/topology.generated.ts +9 -0
  173. package/templates/apps/web/messages/en.json +121 -18
  174. package/templates/apps/web/next.config.mjs +8 -2
  175. package/templates/database/CHANGELOG.md +55 -0
  176. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  177. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  178. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  179. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  180. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  181. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  182. package/templates/database/__tests__/person.db.test.ts +3 -1
  183. package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
  184. package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
  185. package/templates/database/_gitignore +8 -0
  186. package/templates/database/inbox/schema.prisma +114 -22
  187. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  188. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  189. package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
  190. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
  191. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
  192. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
  193. package/templates/database/ops/schema.prisma +16 -0
  194. package/templates/database/package.json +1 -1
  195. package/templates/project.yaml +6 -3
  196. package/versions.json +35 -35
  197. package/templates/.claude/rules/preserve-existing-style.md +0 -51
@@ -0,0 +1,170 @@
1
+ "use server";
2
+
3
+ /**
4
+ * account/agreements-actions.ts — the account home's carried
5
+ * sign-up-agreement CONSUME step (#8438 D13, spec
6
+ * docs/specs/8438-consent-registry-and-ledger-step-spec.md C6).
7
+ *
8
+ * `apps/web` never touches the database (the ledger writer is `apps/api`),
9
+ * and Better Auth creates the user from the sign-up card before any Person
10
+ * exists to record a grant against. So the card remembers which agreements
11
+ * the person ticked, in a short-lived, signed, email-bound cookie — this
12
+ * file's `acceptCarriedAgreementsAction`, called once on the account home's
13
+ * first signed-in render (`agreements-prompt.tsx`), decodes it and writes
14
+ * each missing agreement with `via: "sign_up"`, then deletes the cookie
15
+ * either way (single-use).
16
+ *
17
+ * The MINT side, `rememberSignUpAgreementsAction`, lives in
18
+ * `(auth)/sign-up/sign-up.actions.ts` instead of here, deliberately:
19
+ * `(auth)/**` is one of the sign-in SEEDS a sign-in-less spoke drops
20
+ * (`SPOKE_REMOVAL_SEEDS`, `packages/create-ailk/src/topology.ts` D4), and
21
+ * `agreements-prompt.tsx` — this directory's own file, not a sign-in path —
22
+ * must never reach into a dropped seed, or the scaffolder's own
23
+ * dangling-import check (`spokeExclusions`) refuses the spoke build. So the
24
+ * two sides duplicate the small cookie codec (encode there, decode here)
25
+ * rather than share a module, precisely so each side's dependencies stay
26
+ * native to where it lives.
27
+ *
28
+ * Kept as its OWN file, separate from this directory's `actions.ts`
29
+ * (Billing's `openBillingPortal`): that file imports `billing-source.ts`,
30
+ * which pulls in `account-data.ts`, `@working-theory/ui` and
31
+ * `lucide-react` — a far heavier footprint than this small, self-contained
32
+ * cookie codec needs. Keeping it apart means a test (or a bundler) that
33
+ * reaches this file touches nothing from that tree.
34
+ *
35
+ * Cookie value: `<id>@<version>,…|<issuedAtMs>|<sha256(lowercased email)>|
36
+ * <HMAC-SHA256 over the rest>`, keyed by `BETTER_AUTH_SECRET` — the same
37
+ * dev fallback `apps/web/auth.ts`'s own `resolveSecret()` publishes when it
38
+ * is unset. That secret is read directly HERE rather than importing
39
+ * `resolveSecret` from `auth.ts`: that module statically imports the
40
+ * ESM-only `better-auth` package, which every broad page-level test suite
41
+ * that renders the account area would then have to mock. Verified with a
42
+ * constant-time comparison; never trusted without it.
43
+ */
44
+
45
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
46
+
47
+ import { member } from "@working-theory/api-client";
48
+ import { cookies, headers } from "next/headers";
49
+
50
+ import { forwardedCookieInit } from "../../../../lib/server-api";
51
+ import { readSession } from "../../../../lib/topology.generated";
52
+
53
+ const SECURE_COOKIE_NAME = "__Host-ailk-signup-agreements";
54
+ const DEV_COOKIE_NAME = "ailk-signup-agreements";
55
+ const MAX_AGE_MS = 60 * 60 * 1000;
56
+
57
+ /** Mirrors `sign-up.actions.ts`'s own copy — see that file's header. */
58
+ const DEV_FALLBACK_SECRET =
59
+ "ailk-dev-fallback-secret-published-in-this-repo-never-use-in-production";
60
+
61
+ /** Mirrors `sign-up.actions.ts`'s own copy exactly — see that file's header. */
62
+ function signUpAgreementsSecret(): string {
63
+ const configured = process.env.BETTER_AUTH_SECRET;
64
+ if (configured) return configured;
65
+ const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
66
+ if (process.env.NODE_ENV === "production" && !isBuildPhase) {
67
+ throw new Error(
68
+ "BETTER_AUTH_SECRET is required in production: it verifies the carried sign-up-agreement cookie.",
69
+ );
70
+ }
71
+ return DEV_FALLBACK_SECRET;
72
+ }
73
+
74
+ function cookieName(): string {
75
+ return process.env.NODE_ENV === "production" ? SECURE_COOKIE_NAME : DEV_COOKIE_NAME;
76
+ }
77
+
78
+ function emailHash(email: string): string {
79
+ return createHash("sha256").update(email.trim().toLowerCase()).digest("hex");
80
+ }
81
+
82
+ function sign(payload: string): string {
83
+ return createHmac("sha256", signUpAgreementsSecret()).update(payload).digest("hex");
84
+ }
85
+
86
+ /** Constant-time string equality — never a `===` on attacker-reachable input. */
87
+ function safeEqual(a: string, b: string): boolean {
88
+ const bufA = Buffer.from(a);
89
+ const bufB = Buffer.from(b);
90
+ return bufA.length === bufB.length && timingSafeEqual(bufA, bufB);
91
+ }
92
+
93
+ interface CarriedPurpose {
94
+ id: string;
95
+ version: number;
96
+ }
97
+
98
+ interface DecodedCarriedAgreements {
99
+ purposes: CarriedPurpose[];
100
+ emailHash: string;
101
+ }
102
+
103
+ /** Verifies the HMAC and the one-hour window; `null` on any failure. */
104
+ function decodeCarriedAgreements(value: string): DecodedCarriedAgreements | null {
105
+ const parts = value.split("|");
106
+ if (parts.length !== 4) return null;
107
+ const [purposesPart, issuedAtPart, emailHashPart, signature] = parts as [
108
+ string,
109
+ string,
110
+ string,
111
+ string,
112
+ ];
113
+ if (!safeEqual(sign(`${purposesPart}|${issuedAtPart}|${emailHashPart}`), signature)) {
114
+ return null;
115
+ }
116
+
117
+ const issuedAtMs = Number(issuedAtPart);
118
+ if (!Number.isFinite(issuedAtMs) || Date.now() - issuedAtMs > MAX_AGE_MS) return null;
119
+
120
+ const purposes: CarriedPurpose[] = [];
121
+ for (const entry of purposesPart.split(",")) {
122
+ const [id, versionStr] = entry.split("@");
123
+ const version = Number(versionStr);
124
+ if (!id || !Number.isFinite(version)) return null;
125
+ purposes.push({ id, version });
126
+ }
127
+ if (purposes.length === 0) return null;
128
+
129
+ return { purposes, emailHash: emailHashPart };
130
+ }
131
+
132
+ export interface AcceptCarriedAgreementsResult {
133
+ accepted: boolean;
134
+ }
135
+
136
+ /**
137
+ * Consumes the sign-up card's carried-agreement cookie — see the file
138
+ * header for the mint/consume split. Writes every id in `missingPurposeIds`
139
+ * with `via: "sign_up"` only when the cookie is valid, fresh, bound to the
140
+ * signed-in (verified) session's own email, and covers every one of those
141
+ * ids; otherwise writes nothing. The cookie is deleted either way once
142
+ * found, so this never runs twice for the same cookie.
143
+ */
144
+ export async function acceptCarriedAgreementsAction(
145
+ missingPurposeIds: string[],
146
+ ): Promise<AcceptCarriedAgreementsResult> {
147
+ const store = await cookies();
148
+ const name = cookieName();
149
+ const raw = store.get(name)?.value;
150
+ if (!raw) return { accepted: false };
151
+ store.delete(name);
152
+
153
+ const decoded = decodeCarriedAgreements(raw);
154
+ if (!decoded) return { accepted: false };
155
+
156
+ const session = await readSession(await headers());
157
+ if (!session || !session.user.emailVerified) return { accepted: false };
158
+ if (decoded.emailHash !== emailHash(session.user.email)) return { accepted: false };
159
+
160
+ const carriedIds = new Set(decoded.purposes.map((p) => p.id));
161
+ if (!missingPurposeIds.every((id) => carriedIds.has(id))) return { accepted: false };
162
+
163
+ const init = await forwardedCookieInit();
164
+ const results = await Promise.all(
165
+ missingPurposeIds.map((purposeId) =>
166
+ member.setConsent({ purposeId, granted: true, via: "sign_up" }, init),
167
+ ),
168
+ );
169
+ return { accepted: results.every((r) => r.ok) };
170
+ }
@@ -0,0 +1,181 @@
1
+ "use client";
2
+
3
+ /**
4
+ * AgreementsPrompt — the account home's sign-up-agreement re-ask (#8438
5
+ * D13, spec docs/specs/8438-consent-registry-and-ledger-step-spec.md C6).
6
+ *
7
+ * Renders only when `missing` is non-empty — `A/page.tsx` computes that
8
+ * from `GET /v1/member/consents`: a `signUp` agreement with no OWN grant at
9
+ * its current `policyVersion` (D4 — never an any-source row an anonymous
10
+ * form attached, SEC-8438-1).
11
+ *
12
+ * On mount, tries the CARRIED case first: `acceptCarriedAgreementsAction`
13
+ * (this directory's own `agreements-actions.ts`) consumes the sign-up
14
+ * card's signed, email-bound cookie and writes each missing agreement with
15
+ * `via: "sign_up"`, so a member who just ticked the boxes on `/sign-up` is
16
+ * never asked twice. On success the page refreshes and this component is
17
+ * gone from the next render (`missing` becomes empty); on failure — no
18
+ * cookie, expired, wrong account, or it does not cover every missing id —
19
+ * the explicit card below shows instead.
20
+ *
21
+ * Dynamically imported, never a static import, mirroring the generated
22
+ * seam's own idiom (`topology.generated.ts`'s `readSession` imports
23
+ * `../auth` dynamically): `./agreements-actions` reads a session and posts
24
+ * through `@working-theory/api-client`, the same database/sign-in-adjacent
25
+ * surface the seam exists to keep lazy. The cookie it consumes is minted by
26
+ * `(auth)/sign-up/sign-up.actions.ts` — a sign-in SEED a sign-in-less spoke
27
+ * drops (`SPOKE_REMOVED_ROOTS`/`spokeExclusions`,
28
+ * `packages/create-ailk/src/topology.ts` D4) — but this file never reaches
29
+ * that module directly; the consume-side logic lives here instead,
30
+ * precisely so this directory carries no cross-boundary import into a seed
31
+ * a spoke prunes. A spoke member signs up on the hub (C6), so on a spoke
32
+ * there is simply no carried cookie to find — the "Otherwise" card below
33
+ * shows instead, exactly as it does for any other failed carried check.
34
+ *
35
+ * "Otherwise" card: the statement (plain text, as `AgreementList` reads it
36
+ * — no markdown-link parsing here either; see that component's own note)
37
+ * plus a link to its policy when the wire carries one, and one Accept
38
+ * button that posts every missing id through `setConsentAction` with no
39
+ * `via` (`account_settings`).
40
+ */
41
+
42
+ import { Button, Heading, Link, Text } from "@working-theory/ui/primitives";
43
+ import { useRouter } from "next/navigation";
44
+ import { useEffect, useRef, useState, type ReactElement } from "react";
45
+
46
+ import { setConsentAction } from "./privacy/actions";
47
+
48
+ export interface MissingAgreement {
49
+ id: string;
50
+ statement: string;
51
+ policyHref: string | null;
52
+ version: number;
53
+ /** True when this purpose has an OWN grant at an older version (D13's "updated" copy). */
54
+ hadPriorGrant: boolean;
55
+ }
56
+
57
+ export interface AgreementsPromptLabels {
58
+ titleNew: string;
59
+ titleUpdated: string;
60
+ accept: string;
61
+ error: string;
62
+ /** The policy link's label (the same text Privacy's own policy link uses). */
63
+ policyLink: string;
64
+ }
65
+
66
+ export interface AgreementsPromptProps {
67
+ missing: MissingAgreement[];
68
+ labels: AgreementsPromptLabels;
69
+ }
70
+
71
+ type CarriedCheck = "checking" | "hidden" | "show";
72
+
73
+ export function AgreementsPrompt({ missing, labels }: AgreementsPromptProps): ReactElement | null {
74
+ const router = useRouter();
75
+ const [state, setState] = useState<CarriedCheck>(missing.length === 0 ? "hidden" : "checking");
76
+ const [pending, setPending] = useState(false);
77
+ const [error, setError] = useState<string | null>(null);
78
+ const attempted = useRef(false);
79
+
80
+ useEffect(() => {
81
+ if (missing.length === 0) {
82
+ setState("hidden");
83
+ return;
84
+ }
85
+ if (attempted.current) return;
86
+ attempted.current = true;
87
+ let active = true;
88
+ void (async () => {
89
+ try {
90
+ const { acceptCarriedAgreementsAction } = await import("./agreements-actions");
91
+ const result = await acceptCarriedAgreementsAction(missing.map((m) => m.id));
92
+ if (!active) return;
93
+ if (result.accepted) {
94
+ setState("hidden");
95
+ router.refresh();
96
+ } else {
97
+ setState("show");
98
+ }
99
+ } catch {
100
+ if (active) setState("show");
101
+ }
102
+ })();
103
+ return () => {
104
+ active = false;
105
+ };
106
+
107
+ }, [router]);
108
+
109
+ async function acceptAll(): Promise<void> {
110
+ setPending(true);
111
+ setError(null);
112
+ try {
113
+ const results = await Promise.all(missing.map((m) => setConsentAction(m.id, true)));
114
+ if (results.every((r) => r.ok)) {
115
+ // Mirrors the carried-cookie path: hide now, rather than leaving
116
+ // `state` at "show" across the refresh below — the mount-time
117
+ // effect that would otherwise notice `missing` is now empty never
118
+ // re-runs (its deps are `[router]`, not `missing`), since this is
119
+ // the SAME component instance, not a fresh mount.
120
+ setState("hidden");
121
+ router.refresh();
122
+ } else {
123
+ setError(labels.error);
124
+ setPending(false);
125
+ }
126
+ } catch {
127
+ setError(labels.error);
128
+ setPending(false);
129
+ }
130
+ }
131
+
132
+ if (state !== "show") return null;
133
+
134
+ const title = missing.some((m) => m.hadPriorGrant) ? labels.titleUpdated : labels.titleNew;
135
+
136
+ return (
137
+ <div
138
+ className="mb-block-loose flex flex-col gap-3 rounded-control border-hairline border-(--color-border) bg-surface-secondary p-4"
139
+ data-testid="account-agreements-prompt"
140
+ >
141
+ <Heading level={2} visual={4}>
142
+ {title}
143
+ </Heading>
144
+ <div className="flex flex-col gap-2">
145
+ {missing.map((agreement) => (
146
+ <div key={agreement.id} data-testid={`account-agreements-prompt-${agreement.id}`}>
147
+ <Text as="p" variant="body-sm">
148
+ {agreement.statement}
149
+ </Text>
150
+ {agreement.policyHref ? (
151
+ <Link
152
+ href={agreement.policyHref}
153
+ className="text-body-sm"
154
+ data-testid={`account-agreements-prompt-${agreement.id}-policy`}
155
+ >
156
+ {labels.policyLink}
157
+ </Link>
158
+ ) : null}
159
+ </div>
160
+ ))}
161
+ </div>
162
+ {error ? (
163
+ <span role="alert" data-testid="account-agreements-prompt-error">
164
+ <Text as="span" variant="caption">
165
+ {error}
166
+ </Text>
167
+ </span>
168
+ ) : null}
169
+ <div>
170
+ <Button
171
+ type="button"
172
+ disabled={pending}
173
+ onClick={() => void acceptAll()}
174
+ data-testid="account-agreements-prompt-accept"
175
+ >
176
+ {labels.accept}
177
+ </Button>
178
+ </div>
179
+ </div>
180
+ );
181
+ }
@@ -22,6 +22,16 @@
22
22
  * decoded. No session, no match or a 404 read is a not-found; any other
23
23
  * failed read renders the error row with Retry (spec 7423 D12). Titles,
24
24
  * notes and comments render as text only.
25
+ *
26
+ * A proposal (#8516, spec docs/specs/8516-proposals-as-an-artifact-kind-spec.md
27
+ * D19) is the same page with its own words: the caption "Proposal", its
28
+ * price ("Price <amount>", formatted from minor units), and the review form
29
+ * as "Your decision on version <n>" with Approve proposal / Request changes.
30
+ * In the latest version's place, once approved: Pay now (`pay-form.tsx`)
31
+ * while the payment is due, "Paid <date>" once paid, and — back from
32
+ * Stripe's success page (`?paid=1`) before the webhook lands — the pending
33
+ * line instead of Pay now. A deliverable, or an older hub's artifact with
34
+ * no `kind`, renders exactly as before.
25
35
  */
26
36
 
27
37
  import { InboxItemFrame } from "@working-theory/templates/account";
@@ -41,16 +51,20 @@ import { accountSession, logReadFailure } from "../../account-data";
41
51
  import { ErrorRow } from "../../error-row";
42
52
  import {
43
53
  artifactState,
54
+ isProposal,
44
55
  isReviewable,
45
56
  loadArtifacts,
57
+ paymentDue,
46
58
  STATE_KEYS,
47
59
  versionFileHref,
48
60
  } from "../artifact-data";
49
61
 
62
+ import { PayForm, type PayFormLabels } from "./pay-form";
50
63
  import { ReviewForm, type ReviewFormLabels } from "./review-form";
51
64
 
52
65
  interface Props {
53
66
  params: Promise<{ locale: string; pieceId: string }>;
67
+ searchParams?: Promise<Record<string, string | string[] | undefined>>;
54
68
  }
55
69
 
56
70
  /**
@@ -65,6 +79,25 @@ function decodeSegment(segment: string): string {
65
79
  }
66
80
  }
67
81
 
82
+ /**
83
+ * A proposal's price in the reader's locale. The hub's answer is not parsed
84
+ * here, so a currency Intl cannot format falls back to the amount and code
85
+ * rather than erroring the page.
86
+ */
87
+ function formatPrice(
88
+ price: { amount: number; currency: string },
89
+ locale: string,
90
+ ): string {
91
+ try {
92
+ return new Intl.NumberFormat(locale, {
93
+ style: "currency",
94
+ currency: price.currency.toUpperCase(),
95
+ }).format(price.amount / 100);
96
+ } catch {
97
+ return `${String(price.amount / 100)} ${price.currency.toUpperCase()}`;
98
+ }
99
+ }
100
+
68
101
  export async function generateMetadata({ params }: Props): Promise<Metadata> {
69
102
  const { locale } = await params;
70
103
  const t = await getTranslations({ locale, namespace: "account" });
@@ -73,6 +106,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
73
106
 
74
107
  export default async function AccountArtifactPage({
75
108
  params,
109
+ searchParams,
76
110
  }: Props): Promise<ReactElement> {
77
111
  const { locale, pieceId: segment } = await params;
78
112
  const t = await getTranslations({ locale, namespace: "account" });
@@ -108,11 +142,31 @@ export default async function AccountArtifactPage({
108
142
  const state = artifactState(artifact);
109
143
  const reviewable = isReviewable(artifact);
110
144
  const latest = artifact.versions[0]!.version;
145
+ const proposal = isProposal(artifact);
146
+ const price =
147
+ proposal && artifact.price ? formatPrice(artifact.price, locale) : null;
148
+ const due = proposal && paymentDue(artifact);
149
+ // Stripe's success page returns here with `?paid=1`; until the webhook
150
+ // stamps the payment, the page says so instead of offering Pay now again.
151
+ const returnedPaid = due && (await searchParams)?.paid === "1";
152
+ const paidAt =
153
+ proposal && artifact.payment?.status === "paid"
154
+ ? artifact.payment.paidAt
155
+ : null;
156
+ const payLabels: PayFormLabels = {
157
+ pay: t("artifacts.proposal.pay"),
158
+ paying: t("artifacts.proposal.paying"),
159
+ error: t("artifacts.proposal.payError"),
160
+ };
111
161
  const labels: ReviewFormLabels = {
112
- heading: t("artifacts.review.heading", { version: latest }),
162
+ heading: proposal
163
+ ? t("artifacts.proposal.heading", { version: latest })
164
+ : t("artifacts.review.heading", { version: latest }),
113
165
  comment: t("artifacts.review.comment"),
114
166
  commentHint: t("artifacts.review.commentHint"),
115
- approve: t("artifacts.review.approve"),
167
+ approve: proposal
168
+ ? t("artifacts.proposal.approve")
169
+ : t("artifacts.review.approve"),
116
170
  requestChanges: t("artifacts.review.requestChanges"),
117
171
  submitting: t("artifacts.review.submitting"),
118
172
  sent: t("artifacts.review.sent"),
@@ -122,6 +176,7 @@ export default async function AccountArtifactPage({
122
176
  not_latest: t("artifacts.review.errors.notLatest"),
123
177
  rate_limited: t("artifacts.review.errors.rateLimited"),
124
178
  failed: t("artifacts.review.errors.failed"),
179
+ payment_failed: t("artifacts.proposal.payError"),
125
180
  },
126
181
  };
127
182
 
@@ -163,7 +218,9 @@ export default async function AccountArtifactPage({
163
218
  href: localeHref(locale, "/account/artifacts"),
164
219
  label: t("artifacts.back"),
165
220
  }}
166
- caption={t("artifacts.caption")}
221
+ caption={
222
+ proposal ? t("artifacts.proposal.caption") : t("artifacts.caption")
223
+ }
167
224
  title={artifact.title}
168
225
  status={t(STATE_KEYS[state])}
169
226
  lead={
@@ -173,6 +230,13 @@ export default async function AccountArtifactPage({
173
230
  }
174
231
  testIdPrefix="artifact"
175
232
  >
233
+ {price !== null ? (
234
+ <div className="mt-block-tight" data-testid="artifact-price">
235
+ <Text as="p" variant="body" className="font-semibold">
236
+ {t("artifacts.proposal.price", { amount: price })}
237
+ </Text>
238
+ </div>
239
+ ) : null}
176
240
  <section className="mt-block-loose">
177
241
  <Heading level={2} visual={4} className="!text-body font-semibold">
178
242
  {t("artifacts.versions")}
@@ -231,8 +295,29 @@ export default async function AccountArtifactPage({
231
295
  pieceId={artifact.id}
232
296
  version={v.version}
233
297
  labels={labels}
298
+ pay={proposal ? payLabels : undefined}
234
299
  />
235
300
  ) : null}
301
+ {v.version === latest && due && !returnedPaid ? (
302
+ <PayForm pieceId={artifact.id} labels={payLabels} />
303
+ ) : null}
304
+ {v.version === latest && returnedPaid ? (
305
+ <div
306
+ className="mt-block-tight"
307
+ data-testid="artifact-paid-pending"
308
+ >
309
+ <Text as="p" variant="body-sm">
310
+ {t("artifacts.proposal.paidPending")}
311
+ </Text>
312
+ </div>
313
+ ) : null}
314
+ {v.version === latest && paidAt !== null ? (
315
+ <div className="mt-block-tight" data-testid="artifact-paid">
316
+ <Text as="p" variant="body-sm">
317
+ {t("artifacts.proposal.paid", { date: date(paidAt) })}
318
+ </Text>
319
+ </div>
320
+ ) : null}
236
321
  {v.version === latest && state === "changes-requested" ? (
237
322
  <div
238
323
  className="mt-block-tight"
@@ -0,0 +1,65 @@
1
+ "use client"; // holds the Pay now Server Action's state: its pending label and error line
2
+
3
+ /**
4
+ * PayForm — Pay now on an approved proposal (#8516, spec
5
+ * docs/specs/8516-proposals-as-an-artifact-kind-spec.md D19). The artifact
6
+ * page renders it in the latest version's place while the payment is due.
7
+ *
8
+ * One primary button and nothing else: no input of any kind, so no card,
9
+ * number or expiry field can ever live here — the client pays on Stripe's
10
+ * hosted Checkout page, which `payForProposal` redirects to. The piece goes
11
+ * as the button's own name and value, and the amount is the API's, from the
12
+ * stored proposal. A payment that could not open shows the error line; a
13
+ * successful one never returns. Every string arrives through `labels`.
14
+ */
15
+
16
+ import { Button, InlineMessage } from "@working-theory/ui/primitives";
17
+ import { useActionState, type ReactElement } from "react";
18
+
19
+ import { payForProposal, type ProposalPayState } from "../artifact-actions";
20
+
21
+ export interface PayFormLabels {
22
+ pay: string;
23
+ paying: string;
24
+ error: string;
25
+ }
26
+
27
+ export function PayForm({
28
+ pieceId,
29
+ labels,
30
+ }: {
31
+ pieceId: string;
32
+ labels: PayFormLabels;
33
+ }): ReactElement {
34
+ const [state, formAction, pending] = useActionState<
35
+ ProposalPayState,
36
+ FormData
37
+ >(payForProposal, { kind: "idle" });
38
+
39
+ return (
40
+ <form
41
+ action={formAction}
42
+ className="mt-block-tight flex flex-col gap-4"
43
+ data-testid="artifact-pay-form"
44
+ >
45
+ {state.kind === "error" ? (
46
+ <InlineMessage variant="error" data-testid="artifact-pay-error">
47
+ {labels.error}
48
+ </InlineMessage>
49
+ ) : null}
50
+ <div>
51
+ <Button
52
+ type="submit"
53
+ name="pieceId"
54
+ value={pieceId}
55
+ // The page's one primary action: a 44px target at every width.
56
+ size="big"
57
+ disabled={pending}
58
+ data-testid="artifact-pay"
59
+ >
60
+ {pending ? labels.paying : labels.pay}
61
+ </Button>
62
+ </div>
63
+ </form>
64
+ );
65
+ }
@@ -10,6 +10,13 @@
10
10
  * (AC-MAA-07). A review that went through shows the sent line and refreshes
11
11
  * the page once, which re-reads the artifact and shows Approved or the
12
12
  * waiting line in the form's place. Every string arrives through `labels`.
13
+ *
14
+ * A proposal (#8516, spec docs/specs/8516-proposals-as-an-artifact-kind-spec.md
15
+ * D19) passes its own heading and Approve label, and `pay`: an approval
16
+ * whose payment could not open (`payment_failed` — the review itself is
17
+ * recorded) shows the error line and Pay now in the form's place, so the
18
+ * client can try again at once. A deliverable passes no `pay` and renders
19
+ * as before.
13
20
  */
14
21
 
15
22
  import {
@@ -36,6 +43,8 @@ import {
36
43
  type ArtifactReviewState,
37
44
  } from "../artifact-actions";
38
45
 
46
+ import { PayForm, type PayFormLabels } from "./pay-form";
47
+
39
48
  export interface ReviewFormLabels {
40
49
  heading: string;
41
50
  comment: string;
@@ -51,10 +60,13 @@ export function ReviewForm({
51
60
  pieceId,
52
61
  version,
53
62
  labels,
63
+ pay,
54
64
  }: {
55
65
  pieceId: string;
56
66
  version: number;
57
67
  labels: ReviewFormLabels;
68
+ /** A proposal's Pay now labels, for an approval whose payment did not open. */
69
+ pay?: PayFormLabels | undefined;
58
70
  }): ReactElement {
59
71
  const commentId = useId();
60
72
  const hintId = useId();
@@ -71,6 +83,21 @@ export function ReviewForm({
71
83
  if (reviewed) router.refresh();
72
84
  }, [reviewed, router]);
73
85
 
86
+ if (
87
+ pay !== undefined &&
88
+ state.kind === "error" &&
89
+ state.reason === "payment_failed"
90
+ ) {
91
+ return (
92
+ <div className="mt-block-tight flex flex-col gap-4">
93
+ <InlineMessage variant="error" data-testid="artifact-review-error">
94
+ {labels.errors.payment_failed}
95
+ </InlineMessage>
96
+ <PayForm pieceId={pieceId} labels={pay} />
97
+ </div>
98
+ );
99
+ }
100
+
74
101
  if (reviewed) {
75
102
  return (
76
103
  <InlineMessage