create-ailk 0.19.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/README.md +64 -7
  2. package/dist/cli.js +35 -0
  3. package/dist/copy.d.ts +41 -0
  4. package/dist/copy.js +87 -21
  5. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  6. package/dist/lib/fetch-template-snapshot.js +205 -0
  7. package/dist/lib/merge-three-way.d.ts +31 -0
  8. package/dist/lib/merge-three-way.js +65 -0
  9. package/dist/module-architecture.js +55 -2
  10. package/dist/programmatic.d.ts +7 -1
  11. package/dist/programmatic.js +32 -2
  12. package/dist/spoke-checkout.d.ts +7 -0
  13. package/dist/spoke-checkout.js +8 -1
  14. package/dist/surfaces.js +67 -2
  15. package/dist/template-record.d.ts +59 -0
  16. package/dist/template-record.js +183 -0
  17. package/dist/update.d.ts +48 -0
  18. package/dist/update.js +504 -0
  19. package/package.json +3 -3
  20. package/templates/.claude/cloudparity-repo-hooks.json +9 -0
  21. package/templates/.claude/hooks/architecture-check.sh +8 -0
  22. package/templates/.claude/rules/loop-contract.md +129 -0
  23. package/templates/.claude/settings.json +3 -2
  24. package/templates/.env.example +13 -0
  25. package/templates/CLAUDE.md +1 -1
  26. package/templates/CONVENTIONS.md +3 -2
  27. package/templates/_gitignore +162 -0
  28. package/templates/apps/api/CLAUDE.md +14 -10
  29. package/templates/apps/api/package.json +2 -1
  30. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +22 -2
  31. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  32. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  33. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  34. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  35. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  36. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  37. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +5 -2
  38. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  39. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  40. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  41. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +21 -14
  42. package/templates/apps/api/src/middleware/auth.ts +8 -4
  43. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  44. package/templates/apps/api/src/middleware/rate-limit.ts +290 -82
  45. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  46. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  47. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  48. package/templates/apps/api/src/routes/webhooks/stripe.ts +184 -6
  49. package/templates/apps/api/src/server.ts +14 -2
  50. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  51. package/templates/apps/api/src/services/audit-log.ts +72 -10
  52. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  53. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  54. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  55. package/templates/apps/api/vercel.json +1 -1
  56. package/templates/apps/mcp/CLAUDE.md +19 -7
  57. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
  58. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  59. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  60. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  61. package/templates/apps/web/_gitignore +6 -0
  62. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  63. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  64. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  65. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  67. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +28 -83
  68. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +27 -13
  69. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  70. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +2 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +102 -18
  72. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  74. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +3 -2
  75. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  77. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  78. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  79. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  80. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  81. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  82. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  83. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  84. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  85. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  86. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +11 -2
  87. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +5 -24
  88. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +64 -18
  89. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  90. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  91. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  92. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  93. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  94. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  95. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +5 -3
  96. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  97. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  98. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
  99. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  100. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  101. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  102. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  103. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  104. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  105. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  106. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  107. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  108. package/templates/apps/web/jest.config.cjs +3 -0
  109. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  110. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  111. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
  112. package/templates/apps/web/lib/account-config.ts +31 -16
  113. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  114. package/templates/apps/web/lib/relay.ts +20 -0
  115. package/templates/apps/web/messages/en.json +67 -4
  116. package/templates/apps/web/next.config.mjs +8 -2
  117. package/templates/database/CHANGELOG.md +39 -0
  118. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  119. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  120. package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
  121. package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
  122. package/templates/database/_gitignore +8 -0
  123. package/templates/database/inbox/schema.prisma +38 -12
  124. package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
  125. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
  126. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
  127. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
  128. package/templates/database/ops/schema.prisma +16 -0
  129. package/templates/database/package.json +1 -1
  130. package/templates/project.yaml +6 -3
  131. package/versions.json +35 -35
  132. package/templates/.claude/rules/preserve-existing-style.md +0 -51
@@ -3,9 +3,12 @@
3
3
  /**
4
4
  * artifacts/artifact-actions.ts — the review write (#8234, spec
5
5
  * docs/specs/8234-member-artifacts-account-artifacts-and-spec.md D6; the
6
- * write is #8170's `POST /v1/member/artifacts/:pieceId/review`).
6
+ * write is #8170's `POST /v1/member/artifacts/:pieceId/review`) and a
7
+ * proposal's payment (#8516, spec
8
+ * docs/specs/8516-proposals-as-an-artifact-kind-spec.md D19; the write is
9
+ * `POST /v1/member/artifacts/:pieceId/payment`).
7
10
  *
8
- * One export: `submitArtifactReview`, the review form's action. It posts
11
+ * `submitArtifactReview`, the review form's action, posts
9
12
  * Approve or Request changes on a piece's latest version through
10
13
  * `member.reviewArtifact` with `forwardedCookieInit()`'s init as built — the
11
14
  * session cookie plus the request's `Origin` on a single site, the hub
@@ -15,22 +18,38 @@
15
18
  * `pieceId` and `version` are untrusted: they are shape-checked here, and the
16
19
  * API scopes them to the Person the credential resolves.
17
20
  *
21
+ * A proposal's payment (#8516 D19). An approval whose answered artifact owes
22
+ * a payment (`paymentDue`) goes straight on to it; `payForProposal`, the Pay
23
+ * now form's action, does the same from the artifact page. Both run the one
24
+ * private `startProposalPayment`: `member.payArtifact` with the same init,
25
+ * then `redirect()` — only to a URL `isStripeCheckoutUrl` admits, so the
26
+ * client is never sent anywhere but Stripe's hosted Checkout page. The
27
+ * amount is the API's, from the stored proposal; nothing posted here names
28
+ * one. Any other outcome is `payment_failed`, and the review stays recorded,
29
+ * so the page offers Pay now. `redirect()` throws `NEXT_REDIRECT` by design,
30
+ * so it is called outside any try/catch (`../actions.ts`'s idiom).
31
+ *
18
32
  * Not a `*.actions.ts` file and no `*Action`-suffixed export: apps/mcp's
19
33
  * parity test reads that suffix, and the member writes have no MCP twin by
20
34
  * decision (8170's header). The reads the pages share live in
21
35
  * `artifact-data.ts`, never here — every export of this module is a POST
22
- * endpoint. A log line carries `err.name` only — never the comment or an id.
36
+ * endpoint. A log line carries `err.name` only — never the comment, an id
37
+ * or a Checkout URL.
23
38
  */
24
39
 
25
40
  import { member } from "@working-theory/api-client";
26
41
  import {
42
+ isStripeCheckoutUrl,
27
43
  memberReviewBodySchema,
28
44
  memberReviewParamsSchema,
29
45
  type MemberReviewBody,
30
46
  } from "@working-theory/validation";
47
+ import { redirect } from "next/navigation";
31
48
 
32
49
  import { forwardedCookieInit } from "../../../../../lib/server-api";
33
50
 
51
+ import { paymentDue } from "./artifact-data";
52
+
34
53
  /** What the review form shows after a submit. */
35
54
  export type ArtifactReviewState =
36
55
  | { kind: "idle" }
@@ -42,7 +61,8 @@ export type ArtifactReviewState =
42
61
  | "closed"
43
62
  | "not_latest"
44
63
  | "rate_limited"
45
- | "failed";
64
+ | "failed"
65
+ | "payment_failed";
46
66
  };
47
67
 
48
68
  export type ArtifactReviewReason = Extract<
@@ -50,6 +70,12 @@ export type ArtifactReviewReason = Extract<
50
70
  { kind: "error" }
51
71
  >["reason"];
52
72
 
73
+ /** What the Pay now form shows after a submit that did not leave for Stripe. */
74
+ export type ProposalPayState =
75
+ { kind: "idle" } | { kind: "error"; reason: "payment_failed" };
76
+
77
+ const PAYMENT_FAILED = { kind: "error", reason: "payment_failed" } as const;
78
+
53
79
  function error(reason: ArtifactReviewReason): ArtifactReviewState {
54
80
  return { kind: "error", reason };
55
81
  }
@@ -119,5 +145,40 @@ export async function submitArtifactReview(
119
145
  return error("failed");
120
146
  }
121
147
  if (!res.ok) return error(refusalReason(res.status, res.body));
148
+ if (body.review === "approved" && paymentDue(res.body.artifact))
149
+ return startProposalPayment(pieceId);
122
150
  return { kind: "reviewed" };
123
151
  }
152
+
153
+ /** Pay now: open the approved proposal's payment and go to it. */
154
+ export async function payForProposal(
155
+ _prev: ProposalPayState,
156
+ formData: FormData,
157
+ ): Promise<ProposalPayState> {
158
+ const pieceId = field(formData, "pieceId");
159
+ if (!memberReviewParamsSchema.safeParse({ pieceId }).success)
160
+ return PAYMENT_FAILED;
161
+ return startProposalPayment(pieceId);
162
+ }
163
+
164
+ /**
165
+ * Ask the API for the proposal's Stripe Checkout page and redirect there.
166
+ * Never returns on success; anything short of a Stripe Checkout URL is
167
+ * `payment_failed`.
168
+ */
169
+ async function startProposalPayment(
170
+ pieceId: string,
171
+ ): Promise<typeof PAYMENT_FAILED> {
172
+ let url: string | null = null;
173
+ try {
174
+ const res = await member.payArtifact(pieceId, await forwardedCookieInit());
175
+ if (res.ok && isStripeCheckoutUrl(res.body.url)) url = res.body.url;
176
+ } catch (err) {
177
+ console.error(
178
+ `[account] payment failed errName=${err instanceof Error ? err.name : "unknown"}`,
179
+ );
180
+ }
181
+ if (url === null) return PAYMENT_FAILED;
182
+ // Outside any try/catch on purpose — see the file header.
183
+ redirect(url);
184
+ }
@@ -64,6 +64,23 @@ export function isReviewable(a: MemberArtifact): boolean {
64
64
  return a.approvedAt === null && a.versions[0]?.review === null;
65
65
  }
66
66
 
67
+ /**
68
+ * True for a proposal (#8516 D19). An older hub serves no `kind` (D4: the
69
+ * key is optional), and such an artifact is a deliverable.
70
+ */
71
+ export function isProposal(a: MemberArtifact): boolean {
72
+ return a.kind === "proposal";
73
+ }
74
+
75
+ /**
76
+ * True while an approved, priced proposal waits on the client's payment
77
+ * (#8516 D19). The API serves `due` only where a payment surface answers,
78
+ * so anything else — `null`, `paid` or an older hub's missing key — is not.
79
+ */
80
+ export function paymentDue(a: MemberArtifact): boolean {
81
+ return a.payment?.status === "due";
82
+ }
83
+
67
84
  /** The artifact page of one piece. */
68
85
  export function artifactHref(locale: string, pieceId: string): string {
69
86
  return localeHref(
@@ -18,6 +18,10 @@
18
18
  * failed read renders today's error row with Retry and no frame (spec 7423
19
19
  * D12). Nothing here tests the topology: the same file serves a single site
20
20
  * and a hub-mode spoke.
21
+ *
22
+ * A proposal (#8516, spec docs/specs/8516-proposals-as-an-artifact-kind-spec.md
23
+ * D19) reads "Proposal · Version <n>" in its caption and Latest cell; a
24
+ * deliverable reads as before.
21
25
  */
22
26
 
23
27
  import {
@@ -36,6 +40,7 @@ import { ErrorRow } from "../error-row";
36
40
  import {
37
41
  artifactHref,
38
42
  artifactState,
43
+ isProposal,
39
44
  loadArtifacts,
40
45
  STATE_KEYS,
41
46
  type ArtifactState,
@@ -120,14 +125,18 @@ export default async function AccountArtifactsPage({
120
125
  timeZone: "UTC",
121
126
  }).format(new Date(latest.deliveredAt));
122
127
  const reviewWord = t(STATE_KEYS[state]);
128
+ const versionWord = t("artifacts.version", { version: latest.version });
129
+ const latestWord = isProposal(artifact)
130
+ ? `${t("artifacts.proposal.caption")} · ${versionWord}`
131
+ : versionWord;
123
132
  return {
124
133
  id: artifact.id,
125
134
  title: artifact.title,
126
135
  href: artifactHref(locale, artifact.id),
127
136
  facet: state,
128
- caption: `${t("artifacts.version", { version: latest.version })} · ${reviewWord}`,
137
+ caption: `${latestWord} · ${reviewWord}`,
129
138
  cells: {
130
- latest: t("artifacts.version", { version: latest.version }),
139
+ latest: latestWord,
131
140
  delivered,
132
141
  review: reviewWord,
133
142
  },
@@ -3,19 +3,20 @@
3
3
  * #8232 D2).
4
4
  *
5
5
  * RSC, outside the account layout's SignedInShell (#8430), in the member's
6
- * four-tab settings frame (D7) — standalone, the Admin's shape. Renders the shared
6
+ * settings frame (D7; five tabs since #8438 D14) — standalone, the Admin's
7
+ * shape. Renders the shared
7
8
  * `AccountSettings` composition (@working-theory/templates/account, spec
8
9
  * 8339 D4) — the same General form the Admin renders — through the client
9
10
  * adapter `GeneralSettings` (`profile-form.tsx`). Below the form:
10
11
  *
11
12
  * - the contact details (`AccountContactDetails`, #8435 D13) — only on the
12
13
  * member path, seeded from `member.contact` and saved by `saveContact`;
13
- * - Request account deletion (`DeletionRequest`, #8232 D5);
14
14
  * - any section a module contributes (`accountSettingsSections`);
15
15
  * - `BillingAnchor`, which forwards a `#billing` fragment to Billing.
16
16
  *
17
- * Sign-in moved to Security and cookie settings to Notifications (#8339
18
- * C6) — General no longer renders either.
17
+ * Sign-in moved to Security, cookie settings moved to Notifications then to
18
+ * Privacy (#8339 C6), and Request account deletion moved to Privacy (#8438
19
+ * D16) — General no longer renders any of the three.
19
20
  *
20
21
  * Whether the profile and deletion go through the member API is
21
22
  * `loadProfileSource` (D3), never a topology test here.
@@ -28,7 +29,6 @@ import {
28
29
  AccountSettingsPane,
29
30
  type AccountContact,
30
31
  } from "@working-theory/templates/account";
31
- import { localeHref } from "@working-theory/ui";
32
32
  import { Separator, Text } from "@working-theory/ui/primitives";
33
33
  import type { Metadata } from "next";
34
34
  import { getTranslations } from "next-intl/server";
@@ -41,7 +41,6 @@ import { memberSettingsFrame, SettingsSection, type Translate } from "../setting
41
41
 
42
42
  import { BillingAnchor } from "./billing-anchor";
43
43
  import { saveContact } from "./contact-actions";
44
- import { DeletionRequest } from "./deletion-request";
45
44
  import { GeneralSettings } from "./profile-form";
46
45
  import { loadProfileSource } from "./profile-source";
47
46
 
@@ -164,24 +163,6 @@ export default async function AccountGeneralPage({ params }: Props): Promise<Rea
164
163
  </div>
165
164
  </>
166
165
  ) : null}
167
- <Separator />
168
- <div data-testid="account-deletion">
169
- <DeletionRequest
170
- via={source.via}
171
- supportHref={localeHref(locale, "/account/support")}
172
- labels={{
173
- lead: t("settings.privacy.deletionLead"),
174
- action: t("settings.privacy.deletion"),
175
- sentSite: t("settings.privacy.deletionSentSite"),
176
- sentHub: t("settings.privacy.deletionSentHub"),
177
- unverified: t("settings.privacy.deletionUnverified"),
178
- unavailable: t("settings.privacy.deletionUnavailable"),
179
- error: t("settings.privacy.deletionError"),
180
- viaSupport: t("settings.privacy.deletionViaSupport"),
181
- supportLink: t("settings.privacy.deletionSupportLink"),
182
- }}
183
- />
184
- </div>
185
166
  {accountSettingsSections.map((section) => (
186
167
  <SettingsSection key={section.id} id={section.id} title={ctx.t(section.title)}>
187
168
  {section.render(ctx)}
@@ -1,23 +1,35 @@
1
1
  /**
2
2
  * /[locale]/account/notifications — User settings › Notifications (#8339
3
- * D8, NEW).
3
+ * D8; #8438 D15 — what we send you).
4
4
  *
5
5
  * RSC, outside the account layout's SignedInShell (#8430), in the member's
6
- * four-tab settings frame (D7) — standalone, the Admin's shape. Holds the former Privacy
7
- * section's cookie-settings link, moved here verbatim from General (#8339
8
- * C6) — for now; notification preferences are a later unit's scope.
6
+ * settings frame (D7) — standalone, the Admin's shape. Always renders
7
+ * (decision A, operator 2026-10-12): a `ConsentSwitchList` of the site
8
+ * registry's `area: "communications"` purposes (`consent` and
9
+ * `preference`), each read from `GET /v1/member/consents`'s OWN state and
10
+ * `effective` default (no row ⇒ on for a preference, off for a consent).
11
+ * With none declared, one `account-notifications-empty` line.
12
+ *
13
+ * The cookie-settings link moved to Privacy (#8438 D14, C6) — this tab no
14
+ * longer renders it.
9
15
  */
10
16
 
11
- import { AccountSettingsFrame, AccountSettingsPane } from "@working-theory/templates/account";
12
- import { localeHref } from "@working-theory/ui";
13
- import { Link } from "@working-theory/ui/primitives";
17
+ import { member } from "@working-theory/api-client";
18
+ import {
19
+ AccountSettingsFrame,
20
+ AccountSettingsPane,
21
+ ConsentSwitchList,
22
+ type ConsentSwitchRow,
23
+ } from "@working-theory/templates/account";
14
24
  import type { Metadata } from "next";
15
25
  import { getTranslations } from "next-intl/server";
16
26
  import type { ReactElement } from "react";
17
27
 
18
28
  import { accountSession } from "../account-data";
29
+ import { setConsentAction } from "../privacy/actions";
19
30
  import { memberSettingsFrame, type Translate } from "../settings-frame";
20
31
 
32
+
21
33
  interface Props {
22
34
  params: Promise<{ locale: string }>;
23
35
  }
@@ -25,7 +37,7 @@ interface Props {
25
37
  export async function generateMetadata({ params }: Props): Promise<Metadata> {
26
38
  const { locale } = await params;
27
39
  const t = await getTranslations({ locale, namespace: "account" });
28
- return { title: t("settings.notifications.title"), description: t("settings.privacy.lead") };
40
+ return { title: t("settings.notifications.title"), description: t("settings.notifications.lead") };
29
41
  }
30
42
 
31
43
  export default async function AccountNotificationsPage({ params }: Props): Promise<ReactElement> {
@@ -35,23 +47,57 @@ export default async function AccountNotificationsPage({ params }: Props): Promi
35
47
  const frame = memberSettingsFrame(locale, t as unknown as Translate, "notifications");
36
48
  const name = session?.ctx.user.name || session?.ctx.user.email || "";
37
49
 
50
+ let rows: ConsentSwitchRow[] = [];
51
+ let unavailable = false;
52
+ if (session) {
53
+ try {
54
+ const res = await member.consents(session.ctx.init, { locale });
55
+ if (res.ok) {
56
+ rows = res.body.purposes
57
+ .filter((purpose) => purpose.area === "communications")
58
+ .map((purpose) => ({
59
+ id: purpose.id,
60
+ label: purpose.statement,
61
+ granted: purpose.effective,
62
+ ...(purpose.description ? { description: purpose.description } : {}),
63
+ ...(purpose.state
64
+ ? {
65
+ dateLine: purpose.state.granted
66
+ ? t("settings.privacy.given", { date: formatDate(purpose.state.at, locale) })
67
+ : t("settings.privacy.withdrawn", { date: formatDate(purpose.state.at, locale) }),
68
+ }
69
+ : {}),
70
+ }));
71
+ } else {
72
+ unavailable = true;
73
+ }
74
+ } catch {
75
+ unavailable = true;
76
+ }
77
+ }
78
+
38
79
  return (
39
80
  <AccountSettingsFrame {...frame} name={name}>
40
81
  <AccountSettingsPane
41
82
  id="account-settings-notifications"
42
83
  title={t("settings.notifications.title")}
43
- lead={t("settings.privacy.lead")}
84
+ lead={t("settings.notifications.lead")}
44
85
  >
45
- <div>
46
- <Link
47
- href={localeHref(locale, "/privacy#cookie-settings")}
48
- className="inline-flex min-h-6 items-center"
49
- data-testid="account-cookie-settings"
50
- >
51
- {t("settings.privacy.cookies")}
52
- </Link>
53
- </div>
86
+ {unavailable ? (
87
+ <span data-testid="account-notifications-unavailable">{t("settings.privacy.unavailable")}</span>
88
+ ) : (
89
+ <ConsentSwitchList
90
+ rows={rows}
91
+ onChange={setConsentAction}
92
+ emptyLine={t("settings.notifications.empty")}
93
+ testIdPrefix="account-notifications"
94
+ />
95
+ )}
54
96
  </AccountSettingsPane>
55
97
  </AccountSettingsFrame>
56
98
  );
57
99
  }
100
+
101
+ function formatDate(iso: string, locale: string): string {
102
+ return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(new Date(iso));
103
+ }
@@ -0,0 +1,120 @@
1
+ "use server";
2
+
3
+ /**
4
+ * account/onboarding/actions.ts — the onboarding page's read and write
5
+ * (#8515, spec docs/specs/8515-standard-client-onboarding-diagnostic-and-spec.md
6
+ * D12; the routes are D7's `GET` and D8's `POST /v1/member/onboarding`).
7
+ *
8
+ * - `readOnboardingForm` — `member.onboarding`, the standard onboarding
9
+ * magnet's questions, or `form: null` while it is not open to the
10
+ * caller. A 404 (no member area on this build, or a hub that does not
11
+ * admit the route) is that same "not open" state; any other refusal is
12
+ * `error`, so the page shows its error row with Retry.
13
+ * - `submitOnboarding` — `member.submitOnboarding` with the answers.
14
+ *
15
+ * Both send `forwardedCookieInit()`'s init as built — the session cookie
16
+ * plus the request's `Origin` on a single site, the hub credential on a
17
+ * hub-mode spoke — so nothing here tests the topology, and no identity is
18
+ * posted: the API takes the Person from the credential.
19
+ *
20
+ * The answers are the entries named `answer.<name>`; a repeated name becomes
21
+ * an array, and so does a name the form lists under `multi` (a multi
22
+ * question with one box checked still posts an array, which is what the API
23
+ * asks of it). A value is sent as typed; one left empty or all-space is
24
+ * omitted, because the API refuses `""`. The API checks every answer
25
+ * against the form it serves — nothing here is trusted.
26
+ *
27
+ * Not a `*.actions.ts` file and no `*Action`-suffixed export: apps/mcp's
28
+ * parity test reads that suffix, and the member routes have no MCP twin by
29
+ * decision (8515 C4). Logs carry the function, the status and the code only
30
+ * — never an answer or a credential.
31
+ */
32
+
33
+ import { member } from "@working-theory/api-client";
34
+ import type { MemberOnboardingFormResponse } from "@working-theory/validation";
35
+
36
+ import { forwardedCookieInit } from "../../../../../lib/server-api";
37
+
38
+ /** The refusal codes the form has its own words for; anything else is `failed`. */
39
+ const KNOWN_CODES = [
40
+ "onboarding_off",
41
+ "answers_invalid",
42
+ "link_not_https",
43
+ "too_many_requests",
44
+ ] as const;
45
+
46
+ export type OnboardingErrorCode = (typeof KNOWN_CODES)[number] | "failed";
47
+
48
+ /** What the form shows after a submit. */
49
+ export type OnboardingState =
50
+ | { kind: "idle" }
51
+ | { kind: "sent" }
52
+ | { kind: "error"; code: OnboardingErrorCode };
53
+
54
+ export type OnboardingForm = NonNullable<MemberOnboardingFormResponse["form"]>;
55
+
56
+ export type OnboardingRead = { kind: "ok"; form: OnboardingForm | null } | { kind: "error" };
57
+
58
+ /** PII-safe error identity for logs (`err.name` only). */
59
+ function errorName(err: unknown): string {
60
+ return err instanceof Error ? err.name : "unknown";
61
+ }
62
+
63
+ /** The `error.code` of a member refusal body, when it has one. */
64
+ function errorCode(body: unknown): string | null {
65
+ const code = (body as { error?: { code?: unknown } } | null)?.error?.code;
66
+ return typeof code === "string" ? code : null;
67
+ }
68
+
69
+ function knownCode(status: number, code: string | null): OnboardingErrorCode {
70
+ const known = KNOWN_CODES.find((candidate) => candidate === code);
71
+ if (known) return known;
72
+ // The hub's per-person budget and the per-IP limiter answer `429 rate_limited`.
73
+ return status === 429 ? "too_many_requests" : "failed";
74
+ }
75
+
76
+ export async function readOnboardingForm(): Promise<OnboardingRead> {
77
+ try {
78
+ const res = await member.onboarding(await forwardedCookieInit());
79
+ if (res.ok) return { kind: "ok", form: res.body.form };
80
+ if (res.status === 404) return { kind: "ok", form: null };
81
+ console.error(
82
+ `[onboarding] readOnboardingForm status=${res.status} code=${errorCode(res.body) ?? "none"}`,
83
+ );
84
+ } catch (err) {
85
+ console.error(`[onboarding] readOnboardingForm errName=${errorName(err)}`);
86
+ }
87
+ return { kind: "error" };
88
+ }
89
+
90
+ /** `(prev, formData)` for `useActionState`. */
91
+ export async function submitOnboarding(
92
+ _prev: OnboardingState,
93
+ formData: FormData,
94
+ ): Promise<OnboardingState> {
95
+ // Action input is untrusted: a hand-built call may post anything.
96
+ if (!(formData instanceof FormData)) return { kind: "error", code: "failed" };
97
+
98
+ const multi = new Set(formData.getAll("multi").filter((v) => typeof v === "string"));
99
+ const answers: Record<string, string | string[]> = {};
100
+ for (const key of new Set(formData.keys())) {
101
+ if (!key.startsWith("answer.")) continue;
102
+ const name = key.slice("answer.".length);
103
+ const values = formData
104
+ .getAll(key)
105
+ .filter((v): v is string => typeof v === "string" && v.trim() !== "");
106
+ if (values.length === 0) continue;
107
+ answers[name] = values.length > 1 || multi.has(name) ? values : values[0]!;
108
+ }
109
+
110
+ try {
111
+ const res = await member.submitOnboarding({ answers }, await forwardedCookieInit());
112
+ if (res.ok) return { kind: "sent" };
113
+ const code = knownCode(res.status, errorCode(res.body));
114
+ console.error(`[onboarding] submitOnboarding status=${res.status} code=${code}`);
115
+ return { kind: "error", code };
116
+ } catch (err) {
117
+ console.error(`[onboarding] submitOnboarding errName=${errorName(err)}`);
118
+ return { kind: "error", code: "failed" };
119
+ }
120
+ }