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.
- package/README.md +64 -7
- package/dist/cli.js +35 -0
- package/dist/copy.d.ts +41 -0
- package/dist/copy.js +87 -21
- package/dist/lib/fetch-template-snapshot.d.ts +21 -0
- package/dist/lib/fetch-template-snapshot.js +205 -0
- package/dist/lib/merge-three-way.d.ts +31 -0
- package/dist/lib/merge-three-way.js +65 -0
- package/dist/module-architecture.js +55 -2
- package/dist/programmatic.d.ts +7 -1
- package/dist/programmatic.js +32 -2
- package/dist/spoke-checkout.d.ts +7 -0
- package/dist/spoke-checkout.js +8 -1
- package/dist/surfaces.js +67 -2
- package/dist/template-record.d.ts +59 -0
- package/dist/template-record.js +183 -0
- package/dist/update.d.ts +48 -0
- package/dist/update.js +504 -0
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +9 -0
- package/templates/.claude/hooks/architecture-check.sh +8 -0
- package/templates/.claude/rules/loop-contract.md +129 -0
- package/templates/.claude/settings.json +3 -2
- package/templates/.env.example +13 -0
- package/templates/CLAUDE.md +1 -1
- package/templates/CONVENTIONS.md +3 -2
- package/templates/_gitignore +162 -0
- package/templates/apps/api/CLAUDE.md +14 -10
- package/templates/apps/api/package.json +2 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +22 -2
- package/templates/apps/api/src/__tests__/setup.ts +7 -0
- package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
- package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
- package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
- package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
- package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +5 -2
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +21 -14
- package/templates/apps/api/src/middleware/auth.ts +8 -4
- package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +290 -82
- package/templates/apps/api/src/routes/webhooks/README.md +33 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +184 -6
- package/templates/apps/api/src/server.ts +14 -2
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
- package/templates/apps/api/src/services/audit-log.ts +72 -10
- package/templates/apps/api/src/services/default-tenant.ts +12 -3
- package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
- package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
- package/templates/apps/api/vercel.json +1 -1
- package/templates/apps/mcp/CLAUDE.md +19 -7
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
- package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
- package/templates/apps/mcp/src/tools/proposals.ts +27 -0
- package/templates/apps/mcp/src/workspace-server.ts +16 -0
- package/templates/apps/web/_gitignore +6 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +28 -83
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +27 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +2 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +102 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +3 -2
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +11 -2
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +5 -24
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +64 -18
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +5 -3
- package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
- package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
- package/templates/apps/web/app/robots.txt/route.ts +13 -10
- package/templates/apps/web/jest.config.cjs +3 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
- package/templates/apps/web/lib/account-config.ts +31 -16
- package/templates/apps/web/lib/consent-visitor.ts +103 -0
- package/templates/apps/web/lib/relay.ts +20 -0
- package/templates/apps/web/messages/en.json +67 -4
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/database/CHANGELOG.md +39 -0
- package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
- package/templates/database/__tests__/person-notes.db.test.ts +249 -0
- package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
- package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
- package/templates/database/_gitignore +8 -0
- package/templates/database/inbox/schema.prisma +38 -12
- package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
- package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
- package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
- package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
- package/templates/database/ops/schema.prisma +16 -0
- package/templates/database/package.json +1 -1
- package/templates/project.yaml +6 -3
- package/versions.json +35 -35
- package/templates/.claude/rules/preserve-existing-style.md +0 -51
package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx
ADDED
|
@@ -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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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`.
|