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,158 @@
1
+ /**
2
+ * Tests for /[locale]/unsubscribe (#8438 D17, AC-3 unsubscribe).
3
+ */
4
+ import { render, screen, waitFor } from "@testing-library/react";
5
+ import userEvent from "@testing-library/user-event";
6
+
7
+ /**
8
+ * One interpolating translator for both halves of the page: the server half
9
+ * (`getTranslations`, the heading and lead) and the client island
10
+ * (`useTranslations`, whose done line formats `{description}` in). The
11
+ * workspace's mapped `next-intl` stub ignores ICU values, so it is replaced
12
+ * here, the way this file already replaces `next-intl/server`.
13
+ */
14
+ function mockTranslator(ns: string): (key: string, values?: Record<string, unknown>) => string {
15
+ const msgs: unknown = require("../../../../messages/en.json");
16
+ const dig = (obj: unknown, path: string): unknown =>
17
+ path.split(".").reduce<unknown>(
18
+ (acc, k) => (acc !== null && typeof acc === "object" ? (acc as Record<string, unknown>)[k] : undefined),
19
+ obj,
20
+ );
21
+ const messages = dig(msgs, ns);
22
+ return (key, values) => {
23
+ const value = dig(messages, key);
24
+ if (typeof value !== "string") return `${ns}.${key}`;
25
+ return value.replace(/\{(\w+)\}/g, (m, name: string) =>
26
+ values && name in values ? String(values[name]) : m,
27
+ );
28
+ };
29
+ }
30
+
31
+ jest.mock("next-intl/server", () => ({
32
+ __esModule: true,
33
+ getTranslations: async (arg: string | { namespace?: string; locale?: string }) =>
34
+ mockTranslator(typeof arg === "string" ? arg : (arg.namespace ?? "")),
35
+ }));
36
+
37
+ jest.mock("next-intl", () => ({
38
+ __esModule: true,
39
+ useTranslations: (ns: string) => mockTranslator(ns),
40
+ }));
41
+
42
+ const mockUnsubscribe = jest.fn();
43
+ jest.mock("@working-theory/api-client", () => ({
44
+ __esModule: true,
45
+ consent: { unsubscribe: (...args: unknown[]) => mockUnsubscribe(...args) },
46
+ visitorForwardHeaders: () => undefined,
47
+ }));
48
+
49
+ jest.mock("next/headers", () => ({
50
+ __esModule: true,
51
+ headers: jest.fn().mockResolvedValue(new Headers()),
52
+ }));
53
+
54
+ import Page, { generateMetadata } from "../page";
55
+
56
+ const ok = <T,>(body: T) => ({ ok: true, body });
57
+ const refusal = (status: number) => ({ ok: false, status, body: null });
58
+
59
+ beforeEach(() => {
60
+ mockUnsubscribe.mockReset();
61
+ });
62
+
63
+ async function renderPage(token: string | undefined): Promise<void> {
64
+ render(
65
+ await Page({
66
+ params: Promise.resolve({ locale: "en" }),
67
+ searchParams: Promise.resolve(token === undefined ? {} : { t: token }),
68
+ }),
69
+ );
70
+ }
71
+
72
+ describe("metadata — noindex, no-referrer (D17, SEC-8438-9)", () => {
73
+ it("is noindex and sends no-referrer", async () => {
74
+ const meta = await generateMetadata({
75
+ params: Promise.resolve({ locale: "en" }),
76
+ searchParams: Promise.resolve({}),
77
+ });
78
+ expect(meta.robots).toEqual({ index: false, follow: true });
79
+ expect(meta.referrer).toBe("no-referrer");
80
+ });
81
+ });
82
+
83
+ describe("the page performs no write on GET", () => {
84
+ it("renders with no call to unsubscribeAction", async () => {
85
+ await renderPage("tok_1");
86
+ expect(mockUnsubscribe).not.toHaveBeenCalled();
87
+ expect(screen.getByTestId("unsubscribe-page")).toBeInTheDocument();
88
+ expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Unsubscribe");
89
+ expect(screen.getByText("Stop these emails?")).toBeInTheDocument();
90
+ });
91
+ });
92
+
93
+ describe("with no token", () => {
94
+ it("shows the invalid-link line and no button", async () => {
95
+ await renderPage(undefined);
96
+ expect(screen.getByTestId("unsubscribe-invalid")).toHaveTextContent("This link isn't valid.");
97
+ expect(screen.queryByTestId("unsubscribe-button")).not.toBeInTheDocument();
98
+ expect(mockUnsubscribe).not.toHaveBeenCalled();
99
+ });
100
+ });
101
+
102
+ describe("clicking Unsubscribe", () => {
103
+ it("a changed answer shows the done line with the purpose's description", async () => {
104
+ mockUnsubscribe.mockResolvedValue(
105
+ ok({ purpose: { id: "marketing_email", description: "product news and offers" }, changed: true }),
106
+ );
107
+ await renderPage("tok_1");
108
+ await userEvent.click(screen.getByTestId("unsubscribe-button"));
109
+ expect(mockUnsubscribe).toHaveBeenCalledWith({ token: "tok_1" });
110
+ expect(await screen.findByTestId("unsubscribe-done")).toHaveTextContent(
111
+ "You're unsubscribed from product news and offers.",
112
+ );
113
+ });
114
+
115
+ it("changed: false shows the already-unsubscribed line, with no second write implied", async () => {
116
+ mockUnsubscribe.mockResolvedValue(
117
+ ok({ purpose: { id: "marketing_email", description: "product news and offers" }, changed: false }),
118
+ );
119
+ await renderPage("tok_1");
120
+ await userEvent.click(screen.getByTestId("unsubscribe-button"));
121
+ expect(await screen.findByTestId("unsubscribe-already")).toHaveTextContent(
122
+ "You were already unsubscribed.",
123
+ );
124
+ });
125
+
126
+ it("any 400 shows the invalid-link line", async () => {
127
+ mockUnsubscribe.mockResolvedValue(refusal(400));
128
+ await renderPage("tok_1");
129
+ await userEvent.click(screen.getByTestId("unsubscribe-button"));
130
+ expect(await screen.findByTestId("unsubscribe-invalid")).toHaveTextContent("This link isn't valid.");
131
+ });
132
+
133
+ it("a 429 (rate-limited) shows the unavailable line, never the invalid-link line", async () => {
134
+ mockUnsubscribe.mockResolvedValue(refusal(429));
135
+ await renderPage("tok_1");
136
+ await userEvent.click(screen.getByTestId("unsubscribe-button"));
137
+ expect(await screen.findByTestId("unsubscribe-unavailable")).toHaveTextContent(
138
+ "Unsubscribing isn't available right now.",
139
+ );
140
+ expect(screen.queryByTestId("unsubscribe-invalid")).not.toBeInTheDocument();
141
+ });
142
+
143
+ it("a 5xx shows the unavailable line", async () => {
144
+ mockUnsubscribe.mockResolvedValue(refusal(503));
145
+ await renderPage("tok_1");
146
+ await userEvent.click(screen.getByTestId("unsubscribe-button"));
147
+ expect(await screen.findByTestId("unsubscribe-unavailable")).toHaveTextContent(
148
+ "Unsubscribing isn't available right now.",
149
+ );
150
+ });
151
+
152
+ it("a thrown error shows the unavailable line, never a crash", async () => {
153
+ mockUnsubscribe.mockRejectedValue(new TypeError("fetch failed"));
154
+ await renderPage("tok_1");
155
+ await userEvent.click(screen.getByTestId("unsubscribe-button"));
156
+ expect(await screen.findByTestId("unsubscribe-unavailable")).toBeInTheDocument();
157
+ });
158
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * /[locale]/unsubscribe — the one unsubscribe landing every signed link
3
+ * calls (#8438 D17, NEW). Public, `noindex`, `referrer: no-referrer` so the
4
+ * `?t=` token never leaks through a Referer header off this page (D17,
5
+ * SEC-8438-9).
6
+ *
7
+ * RSC: reads `?t=` and renders the heading, the lead and the one
8
+ * interactive piece (`UnsubscribeButton`, "use client") that posts
9
+ * `unsubscribeAction(token)` ON CLICK — never on this GET, so a mail
10
+ * scanner's prefetch writes nothing. The island gets the token and nothing
11
+ * else: it reads its own strings, because anything callable handed across
12
+ * this server→client edge fails Flight serialization (the scaffold matrix's
13
+ * `GET /unsubscribe -> 500`; `__tests__/unsubscribe-rsc-boundary.test.tsx`).
14
+ */
15
+
16
+ import { Heading, Text } from "@working-theory/ui/primitives";
17
+ import type { Metadata } from "next";
18
+ import { getTranslations } from "next-intl/server";
19
+ import type { ReactElement } from "react";
20
+
21
+ import { UnsubscribeButton } from "./unsubscribe-button";
22
+
23
+ interface Props {
24
+ params: Promise<{ locale: string }>;
25
+ searchParams: Promise<{ t?: string }>;
26
+ }
27
+
28
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
29
+ const { locale } = await params;
30
+ const t = await getTranslations({ locale, namespace: "unsubscribe" });
31
+ return {
32
+ title: t("title"),
33
+ robots: { index: false, follow: true },
34
+ referrer: "no-referrer",
35
+ };
36
+ }
37
+
38
+ export default async function UnsubscribePage({ params, searchParams }: Props): Promise<ReactElement> {
39
+ const { locale } = await params;
40
+ const { t: token } = await searchParams;
41
+ const t = await getTranslations({ locale, namespace: "unsubscribe" });
42
+
43
+ return (
44
+ <div className="mx-auto flex max-w-md flex-col gap-4 px-4 py-16" data-testid="unsubscribe-page">
45
+ <Heading level={1} visual={2}>
46
+ {t("title")}
47
+ </Heading>
48
+ <Text as="p" variant="body">
49
+ {t("lead")}
50
+ </Text>
51
+ <UnsubscribeButton token={token ?? null} />
52
+ </div>
53
+ );
54
+ }
@@ -0,0 +1,73 @@
1
+ "use client"; // owns the click → unsubscribeAction → result state
2
+
3
+ /**
4
+ * UnsubscribeButton — the unsubscribe page's one interactive piece (#8438
5
+ * D17). Posts `unsubscribeAction(token)` on click only — never on render,
6
+ * so a mail scanner's GET prefetch of the page writes nothing.
7
+ *
8
+ * Reads its own strings through `useTranslations` (the island idiom
9
+ * `FlowStepperClient` uses) rather than taking them as props: the done line
10
+ * needs the purpose's description formatted in, and a formatting FUNCTION
11
+ * handed across from the page (a Server Component) cannot be serialized
12
+ * into the Flight payload — the scaffold matrix's `GET /unsubscribe -> 500`
13
+ * (`unsubscribe-rsc-boundary.test.tsx` holds the regression).
14
+ */
15
+
16
+ import { Button, InlineMessage } from "@working-theory/ui/primitives";
17
+ import { useTranslations } from "next-intl";
18
+ import { useState, useTransition, type ReactElement } from "react";
19
+
20
+ import { unsubscribeAction, type UnsubscribeResult } from "./unsubscribe.actions";
21
+
22
+ export function UnsubscribeButton({ token }: { token: string | null }): ReactElement {
23
+ const t = useTranslations("unsubscribe");
24
+ const [result, setResult] = useState<UnsubscribeResult | null>(null);
25
+ const [pending, startTransition] = useTransition();
26
+
27
+ if (token === null || result?.kind === "invalid") {
28
+ return (
29
+ <InlineMessage variant="error" role="alert" data-testid="unsubscribe-invalid">
30
+ {t("invalid")}
31
+ </InlineMessage>
32
+ );
33
+ }
34
+
35
+ if (result?.kind === "done") {
36
+ return (
37
+ <InlineMessage variant="success" role="status" data-testid="unsubscribe-done">
38
+ {t("done", { description: result.description })}
39
+ </InlineMessage>
40
+ );
41
+ }
42
+
43
+ if (result?.kind === "already") {
44
+ return (
45
+ <InlineMessage variant="success" role="status" data-testid="unsubscribe-already">
46
+ {t("already")}
47
+ </InlineMessage>
48
+ );
49
+ }
50
+
51
+ if (result?.kind === "unavailable") {
52
+ return (
53
+ <InlineMessage variant="error" role="alert" data-testid="unsubscribe-unavailable">
54
+ {t("unavailable")}
55
+ </InlineMessage>
56
+ );
57
+ }
58
+
59
+ return (
60
+ <Button
61
+ type="button"
62
+ disabled={pending}
63
+ onClick={() => {
64
+ startTransition(async () => {
65
+ setResult(await unsubscribeAction(token));
66
+ });
67
+ }}
68
+ data-testid="unsubscribe-button"
69
+ >
70
+ {t("button")}
71
+ </Button>
72
+ );
73
+ }
@@ -0,0 +1,55 @@
1
+ "use server";
2
+
3
+ /**
4
+ * unsubscribeAction — the unsubscribe page's one write (#8438 D9, D17).
5
+ *
6
+ * Calls `POST /v1/consent/unsubscribe` (`consent.unsubscribe`) through
7
+ * `@working-theory/api-client`, carrying the visitor-forward headers a
8
+ * spoke attaches today — the same idiom `contact.actions.ts` uses. Never
9
+ * writes anything on its own: the page's GET never calls this, so a mail
10
+ * scanner's prefetch of the link changes nothing (D17).
11
+ *
12
+ * The token is the only authority (D9) — this action never names an email,
13
+ * a workspace or a person.
14
+ */
15
+
16
+ import { consent, visitorForwardHeaders } from "@working-theory/api-client";
17
+ import { headers } from "next/headers";
18
+
19
+ export type UnsubscribeResult =
20
+ | { kind: "done"; description: string }
21
+ | { kind: "already" }
22
+ | { kind: "invalid" }
23
+ | { kind: "unavailable" };
24
+
25
+ export async function unsubscribeAction(token: string): Promise<UnsubscribeResult> {
26
+ let init: { headers: Record<string, string> } | undefined;
27
+ try {
28
+ const forwarded = visitorForwardHeaders(
29
+ await headers(),
30
+ process.env.AILK_VISITOR_FORWARD_KEY,
31
+ );
32
+ if (forwarded) init = { headers: forwarded };
33
+ } catch {
34
+ // No request scope to read — call with no forwarding.
35
+ }
36
+
37
+ try {
38
+ const res = init
39
+ ? await consent.unsubscribe({ token }, init)
40
+ : await consent.unsubscribe({ token });
41
+ if (!res.ok) {
42
+ // 429 is transient (a rate limit), never the token's fault — "invalid"
43
+ // would tell a visitor the link itself is bad when retrying would work.
44
+ if (res.status >= 400 && res.status < 500 && res.status !== 429) return { kind: "invalid" };
45
+ return { kind: "unavailable" };
46
+ }
47
+ if (!res.body.changed) return { kind: "already" };
48
+ return { kind: "done", description: res.body.purpose.description };
49
+ } catch (err) {
50
+ // The api-client never attaches the request payload to a thrown error,
51
+ // so logging `err` carries no token.
52
+ console.error("[unsubscribeAction]", err);
53
+ return { kind: "unavailable" };
54
+ }
55
+ }
@@ -8,28 +8,31 @@
8
8
  * (comma-separated paths, e.g. "/admin/,/private/")
9
9
  * - STEALTH=true disallows every path for every crawler — layer 1 of the
10
10
  * three-layer stealth posture (#884, #7374); it subsumes any
11
- * AILK_ROBOTS_DISALLOW paths
11
+ * AILK_ROBOTS_DISALLOW paths and drops the Sitemap line, so a hidden
12
+ * site advertises no URLs (#8519). STEALTH is read through the shared
13
+ * parse (#8511), so 1, yes and on count too, and an unknown value throws.
12
14
  */
13
15
 
16
+ import { parseStealth } from "@working-theory/web-i18n/stealth";
17
+
14
18
  import { resolveBaseUrl } from "../../src/lib/routes";
15
19
 
16
20
  export function GET(): Response {
17
21
  const baseUrl = resolveBaseUrl();
18
22
  const disallowEnv = process.env["AILK_ROBOTS_DISALLOW"] ?? "";
23
+ const stealth = parseStealth(process.env["STEALTH"]);
19
24
 
20
- const disallowPaths =
21
- process.env["STEALTH"] === "true"
22
- ? ["/"]
23
- : disallowEnv
24
- .split(",")
25
- .map((p) => p.trim())
26
- .filter((p) => p.length > 0);
25
+ const disallowPaths = stealth
26
+ ? ["/"]
27
+ : disallowEnv
28
+ .split(",")
29
+ .map((p) => p.trim())
30
+ .filter((p) => p.length > 0);
27
31
 
28
32
  const lines = [
29
33
  "User-agent: *",
30
34
  ...disallowPaths.map((p) => `Disallow: ${p}`),
31
- "",
32
- `Sitemap: ${baseUrl}/sitemap.xml`,
35
+ ...(stealth ? [] : ["", `Sitemap: ${baseUrl}/sitemap.xml`]),
33
36
  ];
34
37
 
35
38
  return new Response(lines.join("\n"), {
@@ -324,6 +324,24 @@ export function createAuth(deps: CreateAuthDeps = {}) {
324
324
  const sendResetPassword = makeSendResetPassword({
325
325
  prisma,
326
326
  send: authEmailTransport,
327
+ /**
328
+ * Who is signed in on the reset request (#8431 D4) — the Security tab's
329
+ * Set password, for a person with no password yet. Read through THIS
330
+ * instance (assigned below; the callback runs only during a request, by
331
+ * which time it exists), never a second one, and never by verifying the
332
+ * cookie by hand. `disableRefresh`: the mail callback runs in the
333
+ * background after the response, where a refresh's cookie write would
334
+ * throw and drop the mail. No request (a server-side call carries no
335
+ * cookie) or no `api` (a test double of `betterAuth`) is no session.
336
+ */
337
+ sessionUserId: async (request) => {
338
+ if (request === undefined || Reflect.get(instance, "api") === undefined) return null;
339
+ const session = await instance.api.getSession({
340
+ headers: request.headers,
341
+ query: { disableRefresh: true },
342
+ });
343
+ return session?.user.id ?? null;
344
+ },
327
345
  });
328
346
  const sendVerificationEmail = makeSendVerificationEmail({
329
347
  send: authEmailTransport,
@@ -419,7 +437,7 @@ export function createAuth(deps: CreateAuthDeps = {}) {
419
437
  }
420
438
  };
421
439
 
422
- return betterAuth({
440
+ const instance = betterAuth({
423
441
  database: prismaAdapter(prisma, {
424
442
  provider: "postgresql",
425
443
  }),
@@ -643,6 +661,7 @@ export function createAuth(deps: CreateAuthDeps = {}) {
643
661
  ]
644
662
  : [nextCookies()],
645
663
  });
664
+ return instance;
646
665
  }
647
666
 
648
667
  // Lazy singleton: resolved on first access, not at module-load time.
@@ -89,6 +89,9 @@ module.exports = {
89
89
  "<rootDir>/node_modules/@working-theory/site-config/dist/schema/index.js",
90
90
  "^@working-theory/validation$": "<rootDir>/node_modules/@working-theory/validation/dist/index.js",
91
91
  "^@working-theory/web-i18n$": "<rootDir>/node_modules/@working-theory/web-i18n/dist/index.js",
92
+ // The Edge-safe `./stealth` subpath (#8511) → source, like the root entry.
93
+ "^@working-theory/web-i18n/stealth$":
94
+ "<rootDir>/node_modules/@working-theory/web-i18n/dist/stealth-metadata.js",
92
95
  // The dependency-free tenancy-constants subpath (database/tenancy.ts).
93
96
  // Mapped to source like every sibling above; `database/dist` is not
94
97
  // built in this workspace pre-`pnpm build`.
@@ -152,6 +152,90 @@ describe("sendResetPassword (§ D14 — the credential-account check)", () => {
152
152
  });
153
153
  });
154
154
 
155
+ describe("sendResetPassword — the signed-in carve-out (#8431 D4)", () => {
156
+ const USER = { id: "user_1", email: "person@example.com" };
157
+ const RESET_URL = "https://site.example/api/auth/reset-password/tok";
158
+ const REQUEST = { headers: new Headers({ cookie: "session=abc" }) } as Request;
159
+
160
+ it("sends ONE password-reset to the account's own address when the session is that person's", async () => {
161
+ const send = jest.fn().mockResolvedValue({ ok: true, body: { ok: true } });
162
+ const { lines, log } = makeLog();
163
+ const sessionUserId = jest.fn().mockResolvedValue("user_1");
164
+ const cb = makeSendResetPassword({ prisma: makePrisma(false), send, log, sessionUserId });
165
+
166
+ await cb({ user: USER, url: RESET_URL }, REQUEST);
167
+
168
+ expect(sessionUserId).toHaveBeenCalledWith(REQUEST);
169
+ expect(send).toHaveBeenCalledTimes(1);
170
+ const [rawBody] = send.mock.calls[0] as [string];
171
+ expect(JSON.parse(rawBody)).toEqual({
172
+ kind: "password-reset",
173
+ userId: "user_1",
174
+ to: "person@example.com",
175
+ url: RESET_URL,
176
+ expiresInMinutes: 60,
177
+ });
178
+ expect(lines.some((l) => l.fields.reason === "self_service_set")).toBe(true);
179
+ expect(JSON.stringify(lines)).not.toContain("person@example.com");
180
+ });
181
+
182
+ it.each<[string, () => Promise<string | null>]>([
183
+ ["another person's id", () => Promise.resolve("user_2")],
184
+ ["null (no session)", () => Promise.resolve(null)],
185
+ ["a rejection", () => Promise.reject(new Error("db down"))],
186
+ ])("sends NOTHING and logs no address when the session lookup answers %s", async (_label, impl) => {
187
+ const send = jest.fn();
188
+ const { lines, log } = makeLog();
189
+ const cb = makeSendResetPassword({ prisma: makePrisma(false), send, log, sessionUserId: jest.fn(impl) });
190
+
191
+ await expect(cb({ user: USER, url: RESET_URL }, REQUEST)).resolves.toBeUndefined();
192
+
193
+ expect(send).not.toHaveBeenCalled();
194
+ expect(lines.some((l) => l.fields.reason === "no_credential_account")).toBe(true);
195
+ expect(JSON.stringify(lines)).not.toContain("person@example.com");
196
+ });
197
+
198
+ it("logs the failed lookup's kind only", async () => {
199
+ const { lines, log } = makeLog();
200
+ const cb = makeSendResetPassword({
201
+ prisma: makePrisma(false),
202
+ send: jest.fn(),
203
+ log,
204
+ sessionUserId: jest.fn().mockRejectedValue(new Error("person@example.com broke")),
205
+ });
206
+ await cb({ user: USER, url: RESET_URL }, REQUEST);
207
+ expect(lines).toEqual([
208
+ {
209
+ level: "warn",
210
+ fields: {
211
+ userId: "user_1",
212
+ kind: "password-reset",
213
+ reason: "no_credential_account",
214
+ errorKind: "session_lookup_failed",
215
+ },
216
+ },
217
+ ]);
218
+ });
219
+
220
+ it("with no sessionUserId it sends nothing, as before", async () => {
221
+ const send = jest.fn();
222
+ const { log } = makeLog();
223
+ const cb = makeSendResetPassword({ prisma: makePrisma(false), send, log });
224
+ await cb({ user: USER, url: RESET_URL }, REQUEST);
225
+ expect(send).not.toHaveBeenCalled();
226
+ });
227
+
228
+ it("a person WITH a password never needs the session lookup", async () => {
229
+ const send = jest.fn().mockResolvedValue({ ok: true, body: { ok: true } });
230
+ const { log } = makeLog();
231
+ const sessionUserId = jest.fn();
232
+ const cb = makeSendResetPassword({ prisma: makePrisma(true), send, log, sessionUserId });
233
+ await cb({ user: USER, url: RESET_URL });
234
+ expect(sessionUserId).not.toHaveBeenCalled();
235
+ expect(send).toHaveBeenCalledTimes(1);
236
+ });
237
+ });
238
+
155
239
  describe("the § D12 PII boundary", () => {
156
240
  it("never logs the address, the token or the assembled link", async () => {
157
241
  const send = jest.fn().mockResolvedValue({ ok: true, body: { ok: true } });