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.
- package/README.md +64 -7
- package/dist/add-module.js +7 -2
- package/dist/cli.js +35 -0
- package/dist/copy.d.ts +41 -0
- package/dist/copy.js +87 -21
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- 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/lib/scaffold-render-sweep.d.ts +23 -0
- package/dist/lib/scaffold-render-sweep.js +52 -0
- package/dist/module-architecture.js +64 -5
- package/dist/programmatic.d.ts +7 -1
- package/dist/programmatic.js +40 -3
- package/dist/site-roster.js +21 -4
- package/dist/spoke-checkout.d.ts +7 -0
- package/dist/spoke-checkout.js +8 -1
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +98 -3
- package/dist/template-record.d.ts +59 -0
- package/dist/template-record.js +183 -0
- package/dist/topology.js +29 -1
- 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/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +18 -8
- package/templates/apps/api/package.json +2 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
- package/templates/apps/api/src/__tests__/setup.ts +7 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -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/__mocks__/prisma.ts +8 -0
- package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
- package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
- package/templates/apps/api/src/lib/consent-store.ts +335 -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/lib/stripe.ts +97 -5
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
- 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 +72 -14
- package/templates/apps/api/src/middleware/auth.ts +16 -4
- package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
- package/templates/apps/api/src/routes/checkout/session.ts +3 -3
- package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
- package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
- package/templates/apps/api/src/routes/consent/index.ts +18 -0
- package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
- package/templates/apps/api/src/routes/leads/index.ts +15 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- package/templates/apps/api/src/routes/webhooks/README.md +33 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -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 +201 -6
- package/templates/apps/api/src/server.ts +125 -18
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
- package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
- package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
- package/templates/apps/api/src/services/audit-log.ts +72 -10
- package/templates/apps/api/src/services/consent.ts +488 -3
- 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/lead-routing.ts +105 -12
- package/templates/apps/api/src/services/person.ts +501 -1
- package/templates/apps/api/src/services/project-listings.ts +7 -2
- package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
- package/templates/apps/api/src/services/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- 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__/account-frame-sign-out.test.tsx +175 -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__/billing.test.tsx +17 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -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__/security.test.tsx +290 -20
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
- 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 +15 -9
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
- 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/security/page.tsx +89 -37
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
- 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/auth.ts +20 -1
- package/templates/apps/web/jest.config.cjs +3 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -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/auth-email.ts +45 -10
- package/templates/apps/web/lib/consent-visitor.ts +103 -0
- package/templates/apps/web/lib/relay.ts +20 -0
- package/templates/apps/web/lib/topology.generated.ts +9 -0
- package/templates/apps/web/messages/en.json +121 -18
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/database/CHANGELOG.md +55 -0
- package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
- package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
- package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person-notes.db.test.ts +249 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- 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 +114 -22
- package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
- package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
- 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
|
@@ -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"), {
|
|
@@ -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
|
-
|
|
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 } });
|