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
@@ -0,0 +1,209 @@
1
+ /**
2
+ * @jest-environment node
3
+ * @jest-environment-options {"customExportConditions": ["react-server", "node", "require", "default"]}
4
+ */
5
+ // /[locale]/unsubscribe → real RSC render regression test (#8438 D17, the
6
+ // #7402 class).
7
+ //
8
+ // The create-ailk scaffold matrix's `organization-product · Free` leg answered
9
+ // `GET /unsubscribe` with a 500: `page.tsx` (a Server Component) handed
10
+ // `UnsubscribeButton` ("use client") a `labels.done` FUNCTION, and React
11
+ // refuses to serialize a function into the Flight payload ("Functions cannot
12
+ // be passed directly to Client Components"). `unsubscribe.test.tsx` renders
13
+ // the island in the same jsdom process, so it never crosses the boundary and
14
+ // stayed green while the real page crashed.
15
+ //
16
+ // This suite runs the page through React's own Flight serializer — the same
17
+ // `react-server-dom-webpack` server Next uses — under the `react-server`
18
+ // export condition, with every `"use client"` module in the render graph
19
+ // replaced by a real client reference, exactly as
20
+ // `apps/web/__tests__/signed-in-nav-rsc-boundary.test.tsx` does for the
21
+ // layout. A function crossing any server→client edge throws the production
22
+ // error here.
23
+
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+ import { Writable } from "node:stream";
27
+
28
+ import type { ReactElement, ReactNode } from "react";
29
+
30
+ // Next's compiled Flight server ships no types; it is the renderer Next itself runs.
31
+ const flight =
32
+ require("next/dist/compiled/react-server-dom-webpack/server.node") as {
33
+ createClientModuleProxy: (moduleId: string) => Record<string, unknown>;
34
+ renderToPipeableStream: (
35
+ model: ReactNode,
36
+ manifest: unknown,
37
+ options: { onError: (error: unknown) => void },
38
+ ) => { pipe: (destination: Writable) => void };
39
+ };
40
+
41
+ // ── The "use client" boundary ────────────────────────────────────────────────
42
+
43
+ const REPO_ROOT = path.resolve(__dirname, "../../../../../..");
44
+
45
+ /** This app and every workspace package its imports can resolve into. */
46
+ const WORKSPACE_DIRS = [
47
+ path.join(REPO_ROOT, "apps/web"),
48
+ // `packages-paid` is absent from the public tree the strip derives.
49
+ ...["packages", "packages-paid"]
50
+ .map((dir) => path.join(REPO_ROOT, dir))
51
+ .filter((dir) => fs.existsSync(dir))
52
+ .flatMap((dir) => fs.readdirSync(dir).map((pkg) => path.join(dir, pkg))),
53
+ ];
54
+
55
+ /** A file's directive prologue opens with "use client" (comments may precede it). */
56
+ const USE_CLIENT = /^(?:\s|\/\/[^\n]*\n|\/\*[\s\S]*?\*\/)*["']use client["']/;
57
+
58
+ /**
59
+ * Every source module that marks itself `"use client"` in the trees jest maps
60
+ * this app's imports onto: the app itself and the workspace packages' `src/`.
61
+ */
62
+ function clientModules(): string[] {
63
+ const roots = WORKSPACE_DIRS.map((dir) =>
64
+ dir.endsWith(path.join("apps", "web")) ? dir : path.join(dir, "src"),
65
+ );
66
+ const found: string[] = [];
67
+ const walk = (dir: string): void => {
68
+ if (!fs.existsSync(dir)) return;
69
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
70
+ if (["node_modules", ".next", "__tests__", "dist"].includes(entry.name))
71
+ continue;
72
+ const full = path.join(dir, entry.name);
73
+ if (entry.isDirectory()) walk(full);
74
+ else if (
75
+ /\.tsx?$/.test(entry.name) &&
76
+ !/\.(stories|test)\.tsx?$/.test(entry.name)
77
+ ) {
78
+ if (USE_CLIENT.test(fs.readFileSync(full, "utf8"))) found.push(full);
79
+ }
80
+ }
81
+ };
82
+ roots.forEach(walk);
83
+ return found;
84
+ }
85
+
86
+ /**
87
+ * The installed `@working-theory/*` packages' built `dist/` trees — the shape a
88
+ * create-ailk scaffold runs this test in (#8557). There jest maps nothing onto
89
+ * `src/`: `@working-theory/ui` is a tarball under `node_modules`, and its
90
+ * Radix-backed primitives carry the directive in their built files. Unmocked,
91
+ * they load as server code under the `react-server` condition and throw
92
+ * (`React.createContext is not a function`). In the monorepo the symlinked
93
+ * workspace package's `dist/` (when built) is walked too; those files are never
94
+ * reached, since jest maps the imports onto `src/`, so the extra mocks are inert.
95
+ */
96
+ function installedClientModules(): string[] {
97
+ const scope = path.join(REPO_ROOT, "apps/web/node_modules/@working-theory");
98
+ if (!fs.existsSync(scope)) return [];
99
+ const found: string[] = [];
100
+ const walk = (dir: string): void => {
101
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
102
+ const full = path.join(dir, entry.name);
103
+ if (entry.isDirectory()) walk(full);
104
+ else if (/\.(?:m|c)?js$/.test(entry.name)) {
105
+ if (USE_CLIENT.test(fs.readFileSync(full, "utf8"))) found.push(full);
106
+ }
107
+ }
108
+ };
109
+ for (const pkg of fs.readdirSync(scope)) {
110
+ let dist: string;
111
+ try {
112
+ dist = path.join(fs.realpathSync(path.join(scope, pkg)), "dist");
113
+ } catch {
114
+ continue;
115
+ }
116
+ if (fs.existsSync(dist)) walk(dist);
117
+ }
118
+ return found;
119
+ }
120
+
121
+ for (const file of [...clientModules(), ...installedClientModules()]) {
122
+ jest.doMock(file, () => flight.createClientModuleProxy(file));
123
+ }
124
+ // Third-party client entries the primitives barrel reaches (`next/image` via
125
+ // `Image.tsx`). pnpm can link a physically distinct `next` per workspace, so
126
+ // each entry is mocked at every copy a workspace resolves.
127
+ for (const specifier of ["next/link", "next/image", "next/script"]) {
128
+ const copies = new Set<string>();
129
+ for (const dir of WORKSPACE_DIRS) {
130
+ try {
131
+ copies.add(require.resolve(specifier, { paths: [dir] }));
132
+ } catch {
133
+ // This workspace does not depend on `next`.
134
+ }
135
+ }
136
+ for (const file of copies) {
137
+ jest.doMock(file, () => flight.createClientModuleProxy(specifier));
138
+ }
139
+ }
140
+ // `next-intl` is path-independent: jest.config.cjs maps every copy onto one stub.
141
+ jest.doMock("next-intl", () => flight.createClientModuleProxy("next-intl"));
142
+
143
+ // ── Render ───────────────────────────────────────────────────────────────────
144
+
145
+ /** Any client-reference id resolves to a chunk-less manifest entry. */
146
+ const CLIENT_MANIFEST = new Proxy(
147
+ {},
148
+ { get: (_target, id) => ({ id: String(id), chunks: [], name: "*" }) },
149
+ );
150
+
151
+ interface FlightResult {
152
+ errors: string[];
153
+ payload: string;
154
+ }
155
+
156
+ /** Serialize the unsubscribe page to a Flight payload, collecting render errors. */
157
+ async function renderPageToFlight(
158
+ token: string | undefined,
159
+ ): Promise<FlightResult> {
160
+ // Required, not imported: an `import` would be hoisted above the doMock calls.
161
+ const { default: UnsubscribePage } = require("../page") as {
162
+ default: (props: {
163
+ params: Promise<{ locale: string }>;
164
+ searchParams: Promise<{ t?: string }>;
165
+ }) => Promise<ReactElement>;
166
+ };
167
+
168
+ const errors: string[] = [];
169
+ const chunks: string[] = [];
170
+ await new Promise<void>((resolve, reject) => {
171
+ const destination = new Writable({
172
+ write(chunk: Buffer, _encoding, done) {
173
+ chunks.push(chunk.toString());
174
+ done();
175
+ },
176
+ final(done) {
177
+ done();
178
+ resolve();
179
+ },
180
+ });
181
+ destination.on("error", reject);
182
+ flight
183
+ .renderToPipeableStream(
184
+ <UnsubscribePage
185
+ params={Promise.resolve({ locale: "en" })}
186
+ searchParams={Promise.resolve(
187
+ token === undefined ? {} : { t: token },
188
+ )}
189
+ />,
190
+ CLIENT_MANIFEST,
191
+ { onError: (error) => void errors.push(String(error)) },
192
+ )
193
+ .pipe(destination);
194
+ });
195
+ return { errors, payload: chunks.join("") };
196
+ }
197
+
198
+ describe("/[locale]/unsubscribe crosses the RSC → client boundary cleanly", () => {
199
+ it("serializes with a token: nothing callable is handed to the island", async () => {
200
+ const { errors, payload } = await renderPageToFlight("tok_1");
201
+ expect(errors).toEqual([]);
202
+ expect(payload).toContain("unsubscribe-page");
203
+ });
204
+
205
+ it("serializes with no token", async () => {
206
+ const { errors } = await renderPageToFlight(undefined);
207
+ expect(errors).toEqual([]);
208
+ });
209
+ });
@@ -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"), {
@@ -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`.