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
|
@@ -4,21 +4,28 @@
|
|
|
4
4
|
*
|
|
5
5
|
* `memberSettingsFrame` supplies the member's half of the shared
|
|
6
6
|
* `AccountSettingsFrame` (@working-theory/templates/account, spec 8339 D2):
|
|
7
|
-
* `standalone:
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* directly (Security, Notifications,
|
|
7
|
+
* `standalone: true` — the Admin's shape, full height and flush, because the
|
|
8
|
+
* account layout renders these routes outside its AppShell (#8430) — Back to
|
|
9
|
+
* the Inbox, and the tabs of `SETTINGS_ROUTES`. Every member settings page
|
|
10
|
+
* calls it and renders the result through `AccountSettingsFrame` (General,
|
|
11
|
+
* via the shared `AccountSettings`) or directly (Security, Notifications,
|
|
12
|
+
* Billing).
|
|
12
13
|
*
|
|
13
14
|
* `SettingsSection` stays as it is: contributed sections
|
|
14
15
|
* (`accountSettingsSections`, `accountBillingSections`) still use it, as do
|
|
15
16
|
* General's and Billing's own trailing content.
|
|
17
|
+
*
|
|
18
|
+
* #8441 — `SETTINGS_ROUTES` is the ONE place the settings routes are named:
|
|
19
|
+
* the tabs here and `isSettingsPath` (which `account-frame.tsx` takes these
|
|
20
|
+
* routes out of its shell with) both read it, so a tab added here is a tab
|
|
21
|
+
* that renders outside the shell — never a second `<main>` nested in the
|
|
22
|
+
* shell's. Adding a tab is one line here (plus its page and message).
|
|
16
23
|
*/
|
|
17
24
|
|
|
18
25
|
import type { AccountSettingsFrameProps, AccountSettingsTab } from "@working-theory/templates/account";
|
|
19
26
|
import { localeHref } from "@working-theory/ui";
|
|
20
27
|
import { Heading, Icon, Text } from "@working-theory/ui/primitives";
|
|
21
|
-
import { Bell, CreditCard, ShieldCheck, UserRound } from "lucide-react";
|
|
28
|
+
import { Bell, CreditCard, Lock, ShieldCheck, UserRound } from "lucide-react";
|
|
22
29
|
import type { ReactElement, ReactNode } from "react";
|
|
23
30
|
|
|
24
31
|
/** The `next-intl` translator General/Security/Notifications/Billing each
|
|
@@ -29,42 +36,45 @@ import type { ReactElement, ReactNode } from "react";
|
|
|
29
36
|
* own `t` with `as unknown as Translate`. */
|
|
30
37
|
export type Translate = (key: string, values?: Record<string, unknown>) => string;
|
|
31
38
|
|
|
39
|
+
/** User settings' tabs, in the reference's order (#8339 D7; #8438 D14 adds
|
|
40
|
+
* Privacy before Billing) — each one a route that renders outside the
|
|
41
|
+
* account shell (#8430, #8441). */
|
|
42
|
+
export const SETTINGS_ROUTES = [
|
|
43
|
+
{ key: "general", path: "/account/general", titleKey: "settings.general.title", icon: UserRound },
|
|
44
|
+
{ key: "security", path: "/account/security", titleKey: "settings.security.title", icon: ShieldCheck },
|
|
45
|
+
{ key: "notifications", path: "/account/notifications", titleKey: "settings.notifications.title", icon: Bell },
|
|
46
|
+
{ key: "privacy", path: "/account/privacy", titleKey: "settings.privacy.title", icon: Lock },
|
|
47
|
+
{ key: "billing", path: "/account/billing", titleKey: "settings.billing.title", icon: CreditCard },
|
|
48
|
+
] as const;
|
|
49
|
+
|
|
50
|
+
export type SettingsTabKey = (typeof SETTINGS_ROUTES)[number]["key"];
|
|
51
|
+
|
|
52
|
+
/** Whether `pathname` is one of `SETTINGS_ROUTES`: the request locale's
|
|
53
|
+
* `/<locale>` prefix removed, then the rest compared whole — so a sub-route
|
|
54
|
+
* (`/account/billing/history`) is not a settings route unless it is listed. */
|
|
55
|
+
export function isSettingsPath(pathname: string, locale: string): boolean {
|
|
56
|
+
const prefix = `/${locale}`;
|
|
57
|
+
const bare =
|
|
58
|
+
pathname === prefix || pathname.startsWith(`${prefix}/`) ? pathname.slice(prefix.length) : pathname;
|
|
59
|
+
return SETTINGS_ROUTES.some(({ path }) => path === bare);
|
|
60
|
+
}
|
|
61
|
+
|
|
32
62
|
/** The member's half of `AccountSettingsFrameProps` — every settings page's
|
|
33
63
|
* `frame` prop, less `name` (the caller's own session) and `children`. */
|
|
34
64
|
export function memberSettingsFrame(
|
|
35
65
|
locale: string,
|
|
36
66
|
t: Translate,
|
|
37
|
-
active:
|
|
67
|
+
active: SettingsTabKey,
|
|
38
68
|
): Omit<AccountSettingsFrameProps, "name" | "children"> {
|
|
39
|
-
const tabs: readonly AccountSettingsTab[] =
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
key: "security",
|
|
48
|
-
label: t("settings.security.title"),
|
|
49
|
-
href: localeHref(locale, "/account/security"),
|
|
50
|
-
icon: <Icon icon={ShieldCheck} size="sm" decorative className="shrink-0" />,
|
|
51
|
-
},
|
|
52
|
-
{
|
|
53
|
-
key: "notifications",
|
|
54
|
-
label: t("settings.notifications.title"),
|
|
55
|
-
href: localeHref(locale, "/account/notifications"),
|
|
56
|
-
icon: <Icon icon={Bell} size="sm" decorative className="shrink-0" />,
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
key: "billing",
|
|
60
|
-
label: t("settings.billing.title"),
|
|
61
|
-
href: localeHref(locale, "/account/billing"),
|
|
62
|
-
icon: <Icon icon={CreditCard} size="sm" decorative className="shrink-0" />,
|
|
63
|
-
},
|
|
64
|
-
];
|
|
69
|
+
const tabs: readonly AccountSettingsTab[] = SETTINGS_ROUTES.map(({ key, path, titleKey, icon }) => ({
|
|
70
|
+
key,
|
|
71
|
+
label: t(titleKey),
|
|
72
|
+
href: localeHref(locale, path),
|
|
73
|
+
icon: <Icon icon={icon} size="sm" decorative className="shrink-0" />,
|
|
74
|
+
}));
|
|
65
75
|
|
|
66
76
|
return {
|
|
67
|
-
standalone:
|
|
77
|
+
standalone: true,
|
|
68
78
|
back: { label: t("settings.back"), href: localeHref(locale, "/account") },
|
|
69
79
|
railLabel: t("settings.title"),
|
|
70
80
|
accountLabel: t("settings.account"),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* account/sign-in-method.ts (server code) — how a member signs in, for User settings ›
|
|
3
|
-
*
|
|
3
|
+
* Security's sign-in block (#8232, spec
|
|
4
4
|
* docs/specs/8232-member-settings-account-general-and-spec.md D7).
|
|
5
5
|
*
|
|
6
6
|
* One of the account tree's TWO topology seams (roadmap D1; the other is
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ConsentBanner — a thin client wrapper around `Banner` (#8438 D12).
|
|
5
|
+
*
|
|
6
|
+
* `layout.tsx` (a Server Component) cannot pass `acceptCookiesAction` /
|
|
7
|
+
* `declineCookiesAction` straight into `Banner`'s `onAccept`/`onReject`
|
|
8
|
+
* props: those are plain functions, not JSX, and a function crossing the
|
|
9
|
+
* RSC → Client boundary as a PROP (rather than being called from inside
|
|
10
|
+
* client code) is the exact class of crash `signed-in-nav-rsc-boundary.
|
|
11
|
+
* test.tsx` guards against (#7402's `renderFlowTrigger` idiom next door
|
|
12
|
+
* solves the same problem the same way). This component is itself
|
|
13
|
+
* "use client" and imports the two Server Actions directly, so nothing
|
|
14
|
+
* callable crosses from the server — only the serializable props below do.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Banner, type BannerProps } from "@working-theory/ui/chrome";
|
|
18
|
+
import type { ReactElement } from "react";
|
|
19
|
+
|
|
20
|
+
import { acceptCookiesAction, declineCookiesAction } from "./consent.actions";
|
|
21
|
+
|
|
22
|
+
export function ConsentBanner(
|
|
23
|
+
props: Omit<BannerProps, "onAccept" | "onReject">,
|
|
24
|
+
): ReactElement {
|
|
25
|
+
return (
|
|
26
|
+
<Banner
|
|
27
|
+
{...props}
|
|
28
|
+
onAccept={() => void acceptCookiesAction()}
|
|
29
|
+
onReject={() => void declineCookiesAction()}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* acceptCookiesAction / declineCookiesAction — the cookie banner's two
|
|
5
|
+
* Server Actions (#8438 D12, spec
|
|
6
|
+
* docs/specs/8438-consent-registry-and-ledger-step-spec.md).
|
|
7
|
+
*
|
|
8
|
+
* Bound to Banner's `onAccept`/`onReject` in `layout.tsx`. Each ensures the
|
|
9
|
+
* visitor id (`consent-visitor.ts`), then calls `consent.recordCookieChoice`
|
|
10
|
+
* through `@working-theory/api-client` with the site's own spoke key (added
|
|
11
|
+
* by `http.ts`) and the visitor-forward headers a spoke attaches today.
|
|
12
|
+
*
|
|
13
|
+
* Neither ever throws to the client — Banner's `onAccept`/`onReject` are
|
|
14
|
+
* plain `() => void`, and the banner island already hides itself locally;
|
|
15
|
+
* a failed write here is logged and nothing more. Neither writes
|
|
16
|
+
* `ailk-consent` — the island owns that cookie, unchanged (C3).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { consent, visitorForwardHeaders } from "@working-theory/api-client";
|
|
20
|
+
import { cookies, headers } from "next/headers";
|
|
21
|
+
import { getLocale } from "next-intl/server";
|
|
22
|
+
|
|
23
|
+
import { ensureConsentVisitor } from "../../lib/consent-visitor";
|
|
24
|
+
|
|
25
|
+
async function recordChoice(choice: "accepted" | "declined"): Promise<void> {
|
|
26
|
+
try {
|
|
27
|
+
const cookieStore = await cookies();
|
|
28
|
+
const visitorId = ensureConsentVisitor(cookieStore);
|
|
29
|
+
|
|
30
|
+
let locale = "en";
|
|
31
|
+
try {
|
|
32
|
+
locale = await getLocale();
|
|
33
|
+
} catch {
|
|
34
|
+
// No request-scoped locale (e.g. outside next-intl's pipeline) — "en".
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let init: { headers: Record<string, string> } | undefined;
|
|
38
|
+
try {
|
|
39
|
+
const forwarded = visitorForwardHeaders(
|
|
40
|
+
await headers(),
|
|
41
|
+
process.env.AILK_VISITOR_FORWARD_KEY,
|
|
42
|
+
);
|
|
43
|
+
if (forwarded) init = { headers: forwarded };
|
|
44
|
+
} catch {
|
|
45
|
+
// No request scope to read — record with no forwarding headers.
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const body = { visitorId, choice, locale };
|
|
49
|
+
const res = init
|
|
50
|
+
? await consent.recordCookieChoice(body, init)
|
|
51
|
+
: await consent.recordCookieChoice(body);
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
console.error("[consent.actions] record cookie choice refused", {
|
|
54
|
+
outcome: res.status,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
} catch (err) {
|
|
58
|
+
// Never attach the visitor id or any request detail to the log line.
|
|
59
|
+
console.error("[consent.actions] record cookie choice failed", {
|
|
60
|
+
outcome: err instanceof Error ? err.name : "unknown",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function acceptCookiesAction(): Promise<void> {
|
|
66
|
+
await recordChoice("accepted");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function declineCookiesAction(): Promise<void> {
|
|
70
|
+
await recordChoice("declined");
|
|
71
|
+
}
|
|
@@ -48,7 +48,9 @@
|
|
|
48
48
|
import { leads, visitorForwardHeaders } from "@working-theory/api-client";
|
|
49
49
|
import { LEAD_CAPTURE_HONEYPOT, type LeadCaptureFormState } from "@working-theory/ui";
|
|
50
50
|
import { submitLeadInputSchema } from "@working-theory/validation";
|
|
51
|
-
import { headers } from "next/headers";
|
|
51
|
+
import { cookies, headers } from "next/headers";
|
|
52
|
+
|
|
53
|
+
import { consentVisitorHeader, readConsentVisitor } from "../../../lib/consent-visitor";
|
|
52
54
|
|
|
53
55
|
const GENERIC_ERROR = "Something went wrong, please try again.";
|
|
54
56
|
|
|
@@ -151,6 +153,16 @@ export async function captureLeadAction(
|
|
|
151
153
|
// No request scope to read — submit without forwarding.
|
|
152
154
|
}
|
|
153
155
|
|
|
156
|
+
// #8438 D12 — the banner's visitor id, so a later form submit links its
|
|
157
|
+
// cookie choices to the client record this submit creates or resolves
|
|
158
|
+
// (D11). Absent or invalid → no header, exactly as before this unit.
|
|
159
|
+
try {
|
|
160
|
+
const visitorId = readConsentVisitor(await cookies());
|
|
161
|
+
if (visitorId) init = { headers: { ...(init?.headers ?? {}), ...consentVisitorHeader(visitorId) } };
|
|
162
|
+
} catch {
|
|
163
|
+
// No request scope to read — submit with no visitor-link header.
|
|
164
|
+
}
|
|
165
|
+
|
|
154
166
|
try {
|
|
155
167
|
const res = init ? await leads.submit(body, init) : await leads.submit(body);
|
|
156
168
|
if (!res.ok) {
|
|
@@ -35,7 +35,9 @@ import {
|
|
|
35
35
|
flowSlugSchema,
|
|
36
36
|
submitFlowStepInputSchema,
|
|
37
37
|
} from "@working-theory/validation";
|
|
38
|
-
import { headers } from "next/headers";
|
|
38
|
+
import { cookies, headers } from "next/headers";
|
|
39
|
+
|
|
40
|
+
import { consentVisitorHeader, readConsentVisitor } from "../../../../lib/consent-visitor";
|
|
39
41
|
|
|
40
42
|
/**
|
|
41
43
|
* Reads the visitor's real IP from THIS action's own incoming request —
|
|
@@ -60,6 +62,22 @@ async function resolveVisitorIpInit(): Promise<
|
|
|
60
62
|
}
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
/**
|
|
66
|
+
* #8438 D12 — the banner's visitor id, so a magnet's step submit links its
|
|
67
|
+
* cookie choices to the Person lead-routing resolves (D11). `undefined`
|
|
68
|
+
* with no request scope, no cookie, or an invalid value.
|
|
69
|
+
*/
|
|
70
|
+
async function resolveConsentVisitorHeader(): Promise<
|
|
71
|
+
Record<string, string> | undefined
|
|
72
|
+
> {
|
|
73
|
+
try {
|
|
74
|
+
const visitorId = readConsentVisitor(await cookies());
|
|
75
|
+
return visitorId ? consentVisitorHeader(visitorId) : undefined;
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
63
81
|
export async function submitFlowStepAction(
|
|
64
82
|
slug: string,
|
|
65
83
|
payload: {
|
|
@@ -102,12 +120,16 @@ export async function submitFlowStepAction(
|
|
|
102
120
|
...(locale !== undefined ? { locale } : {}),
|
|
103
121
|
};
|
|
104
122
|
const visitorIpInit = await resolveVisitorIpInit();
|
|
123
|
+
const consentVisitor = await resolveConsentVisitorHeader();
|
|
124
|
+
const mergedHeaders = { ...(visitorIpInit?.headers ?? {}), ...(consentVisitor ?? {}) };
|
|
125
|
+
const stepInit =
|
|
126
|
+
Object.keys(mergedHeaders).length > 0 ? { headers: mergedHeaders } : undefined;
|
|
105
127
|
// Two call shapes, not one call + a conditional spread: this keeps the
|
|
106
128
|
// call at EXACTLY 2 args when there's no header to forward (never an
|
|
107
129
|
// explicit `undefined` 3rd arg) — collapsing it into a spread-args form
|
|
108
130
|
// would silently change call arity for every existing caller/test.
|
|
109
|
-
const res =
|
|
110
|
-
? await flows.submitStep(parsedSlug.data, stepPayload,
|
|
131
|
+
const res = stepInit
|
|
132
|
+
? await flows.submitStep(parsedSlug.data, stepPayload, stepInit)
|
|
111
133
|
: await flows.submitStep(parsedSlug.data, stepPayload);
|
|
112
134
|
if (!res.ok) {
|
|
113
135
|
if (res.status === 400) {
|
|
@@ -37,6 +37,11 @@ import {
|
|
|
37
37
|
type NavAction,
|
|
38
38
|
type NavActionFlow,
|
|
39
39
|
} from "@working-theory/ui/chrome";
|
|
40
|
+
import {
|
|
41
|
+
cookiePurpose,
|
|
42
|
+
resolveConsentStatement,
|
|
43
|
+
SITE_CONSENT_CONFIG_DEFAULTS,
|
|
44
|
+
} from "@working-theory/validation";
|
|
40
45
|
import {
|
|
41
46
|
buildHreflangAlternates,
|
|
42
47
|
getStealthRobots,
|
|
@@ -82,6 +87,7 @@ import {
|
|
|
82
87
|
} from "../../lib/topology.generated";
|
|
83
88
|
import { resolveBaseUrl } from "../../src/lib/routes";
|
|
84
89
|
|
|
90
|
+
import { ConsentBanner } from "./consent-banner";
|
|
85
91
|
import { readSiteContactLines } from "./contact/site-contact-lines";
|
|
86
92
|
|
|
87
93
|
/**
|
|
@@ -362,6 +368,15 @@ export default async function LocaleLayout({
|
|
|
362
368
|
// scaffold's messages file; the Banner below is UI-only — OSS ships no
|
|
363
369
|
// enforcement, the callbacks are the hook for @working-theory/consent.
|
|
364
370
|
const tConsent = await getTranslations({ locale, namespace: "consent" });
|
|
371
|
+
// #8438 C4/D12 — the banner's body is read FROM the registry, not a
|
|
372
|
+
// hard-coded string: the `analytics_cookies` purpose's statement, so the
|
|
373
|
+
// text the visitor reads and the text the ledger logs are one string by
|
|
374
|
+
// construction. No site carries its own registry row yet (C1), so this
|
|
375
|
+
// always reads the platform default — wiring a per-site row is a follow-up.
|
|
376
|
+
const consentCookiePurpose = cookiePurpose(SITE_CONSENT_CONFIG_DEFAULTS);
|
|
377
|
+
const consentBannerStatement = consentCookiePurpose
|
|
378
|
+
? resolveConsentStatement(consentCookiePurpose, locale).statement
|
|
379
|
+
: "";
|
|
365
380
|
|
|
366
381
|
// Right-side action cluster (#4984). Signed out → the auth pair: an
|
|
367
382
|
// outline-strong "Sign in" → /sign-in beside a primary "Sign up" → /sign-up
|
|
@@ -868,7 +883,7 @@ export default async function LocaleLayout({
|
|
|
868
883
|
* control (D2) instead of a hard server-side hide.
|
|
869
884
|
*/}
|
|
870
885
|
{isAccountRoute ? null : (
|
|
871
|
-
<
|
|
886
|
+
<ConsentBanner
|
|
872
887
|
variant="info"
|
|
873
888
|
placement="bottom"
|
|
874
889
|
consentCookieName={CONSENT_COOKIE_NAME}
|
|
@@ -876,7 +891,7 @@ export default async function LocaleLayout({
|
|
|
876
891
|
title={tConsent("title")}
|
|
877
892
|
message={
|
|
878
893
|
<>
|
|
879
|
-
{
|
|
894
|
+
{consentBannerStatement}{" "}
|
|
880
895
|
<Link
|
|
881
896
|
href={localeHref(locale, "/privacy")}
|
|
882
897
|
variant="unstyled"
|
|
@@ -891,6 +906,12 @@ export default async function LocaleLayout({
|
|
|
891
906
|
acceptLabel={tConsent("acceptLabel")}
|
|
892
907
|
secondaryAction={{ label: tConsent("rejectLabel") }}
|
|
893
908
|
rejectLabel={tConsent("rejectLabel")}
|
|
909
|
+
// #8438 D12 — each banner choice is logged against the
|
|
910
|
+
// visitor id through the one write path, `consent.actions.ts`,
|
|
911
|
+
// which `ConsentBanner` calls itself (never a function passed
|
|
912
|
+
// across the RSC boundary); the browser cookie
|
|
913
|
+
// (`CONSENT_COOKIE_NAME`) stays the operative switch the
|
|
914
|
+
// island itself still writes (C3).
|
|
894
915
|
locale={locale}
|
|
895
916
|
// #7742 D5 — the minimal chrome's consent bar: the page ground,
|
|
896
917
|
// a hairline, no shadow.
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
import { leads, visitorForwardHeaders } from "@working-theory/api-client";
|
|
23
23
|
import { LEAD_CAPTURE_HONEYPOT, type LeadCaptureFormState } from "@working-theory/ui";
|
|
24
24
|
import { subscribeNewsletterInputSchema } from "@working-theory/validation";
|
|
25
|
-
import { headers } from "next/headers";
|
|
25
|
+
import { cookies, headers } from "next/headers";
|
|
26
|
+
|
|
27
|
+
import { consentVisitorHeader, readConsentVisitor } from "../../../lib/consent-visitor";
|
|
26
28
|
|
|
27
29
|
const GENERIC_ERROR = "Something went wrong, please try again.";
|
|
28
30
|
|
|
@@ -58,6 +60,15 @@ export async function subscribeNewsletterAction(
|
|
|
58
60
|
// No request scope to read — subscribe without forwarding.
|
|
59
61
|
}
|
|
60
62
|
|
|
63
|
+
// #8438 D12 — the banner's visitor id, so a later subscribe links its
|
|
64
|
+
// cookie choices to the client record. Absent or invalid → no header.
|
|
65
|
+
try {
|
|
66
|
+
const visitorId = readConsentVisitor(await cookies());
|
|
67
|
+
if (visitorId) init = { headers: { ...(init?.headers ?? {}), ...consentVisitorHeader(visitorId) } };
|
|
68
|
+
} catch {
|
|
69
|
+
// No request scope to read — subscribe with no visitor-link header.
|
|
70
|
+
}
|
|
71
|
+
|
|
61
72
|
try {
|
|
62
73
|
const res = init
|
|
63
74
|
? await leads.subscribe(body, init)
|
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
|
+
});
|