create-ailk 0.25.1 → 0.26.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 (60) hide show
  1. package/README.md +12 -3
  2. package/dist/lib/fetch-template-snapshot.d.ts +2 -1
  3. package/dist/lib/fetch-template-snapshot.js +6 -5
  4. package/dist/lib/module-license-gate.js +2 -3
  5. package/dist/lib/module-tier.d.ts +2 -8
  6. package/dist/lib/module-tier.js +13 -12
  7. package/dist/module-architecture.js +12 -2
  8. package/dist/starter-grid.d.ts +6 -6
  9. package/dist/starter-grid.js +12 -5
  10. package/dist/surfaces.js +29 -0
  11. package/dist/template-record.d.ts +11 -3
  12. package/dist/template-record.js +20 -7
  13. package/dist/topology.js +23 -4
  14. package/dist/update.d.ts +1 -1
  15. package/dist/update.js +188 -18
  16. package/package.json +3 -3
  17. package/release-scope.json +1 -1
  18. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +8 -2
  19. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +2 -2
  20. package/site-templates/marketing-site/ailk-template.json +7 -1
  21. package/site-templates/marketing-site/provenance.json +7 -1
  22. package/site-templates/saas-product/ailk-template.json +2 -1
  23. package/site-templates/saas-product/provenance.json +1 -0
  24. package/templates/.env.example +15 -0
  25. package/templates/apps/api/.env.example +21 -0
  26. package/templates/apps/api/CLAUDE.md +18 -8
  27. package/templates/apps/api/src/lib/__mocks__/prisma-client.js +16 -0
  28. package/templates/apps/mcp/CLAUDE.md +27 -10
  29. package/templates/apps/web/CLAUDE.md +22 -1
  30. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +43 -4
  31. package/templates/apps/web/app/[locale]/(auth)/legal-footnote.tsx +35 -0
  32. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +117 -8
  33. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +14 -37
  34. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +6 -6
  35. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +20 -35
  36. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +108 -0
  37. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +111 -5
  38. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +78 -16
  39. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +132 -12
  40. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +53 -1
  41. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +38 -0
  42. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +4 -2
  43. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +47 -24
  44. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +23 -4
  45. package/templates/apps/web/app/[locale]/layout.tsx +33 -13
  46. package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +128 -0
  47. package/templates/apps/web/app/[locale]/meet/confirmed/__tests__/BookingHandOff.test.tsx +126 -0
  48. package/templates/apps/web/auth.ts +8 -4
  49. package/templates/apps/web/i18n/request.ts +49 -1
  50. package/templates/apps/web/lib/__tests__/account-config.test.ts +7 -0
  51. package/templates/apps/web/lib/__tests__/auth-email.test.ts +276 -0
  52. package/templates/apps/web/lib/auth-email.ts +102 -20
  53. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +72 -23
  54. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +94 -70
  55. package/templates/apps/web/lib/prisma-for-auth.ts +5 -2
  56. package/templates/apps/web/lib/sign-up-agreements.ts +291 -28
  57. package/templates/apps/web/lib/site-contributions.ts +18 -0
  58. package/templates/apps/web/messages/en.json +186 -20
  59. package/templates/apps/web/messages/site/en.json +1 -0
  60. package/versions.json +48 -48
@@ -10,25 +10,34 @@
10
10
  * route and starts a flow the visitor did not ask for.
11
11
  *
12
12
  * #8790 — the gate reads as one of the site's inner pages, not a bare
13
- * full-height stop: the site's inner-page header (the back arrow at the start
14
- * edge, the mark centred — the shape `Nav bar="bare"` draws, composed here
15
- * from the same `NavBackLink` and `siteLogo()`), a heading at the label size
16
- * (`heading-2`), and an optional quiet prospect line a site turns on by
17
- * adding `account.gate.prospectPrompt` + `prospectLink` + `prospectHref`
18
- * to its messages — absent, the shipped default, renders nothing.
19
- * The document title is the account page's job (`generateMetadata` there
20
- * reads the session and names this state "Sign in · <site>").
13
+ * full-height stop: the back arrow at the start edge (`NavBackLink`), then the
14
+ * card. The document title is the account page's job (`generateMetadata`
15
+ * there reads the session and names this state "Sign in · <site>").
16
+ *
17
+ * The one card (#9662, operator 2026-10-24): the single hub action sits in
18
+ * `AuthCard`'s frame — the site's mark in the 48px tile, "Sign in to <site>"
19
+ * (`SITE_NAME`, the deployment's own name, never the request's), the
20
+ * "Don't have an account? Sign up" line to the hub's `/sign-up`, and the
21
+ * Terms + Privacy acknowledgement on this site's own policy routes. A site
22
+ * that fills `account.gate.prospectPrompt` + `prospectLink` + `prospectHref`
23
+ * replaces the toggle line with its own. The acknowledgement is built here
24
+ * rather than from the `(auth)` group's builder: a spoke carries no `(auth)`
25
+ * group (create-ailk's `SPOKE_REMOVAL_SEEDS`).
21
26
  *
22
27
  * Not configured (`hubSessionConfig()` is `null`: a client id, secret or
23
- * session secret missing), it renders the account area's existing "sign-in
24
- * isn't available yet" state, whose one action leads back to the site.
28
+ * session secret missing), the card says "sign-in isn't available yet" and
29
+ * its one action leads back to the site.
25
30
  */
26
31
  import { localeHref } from "@working-theory/ui";
32
+ import { AuthCard } from "@working-theory/ui/blocks";
27
33
  import { NavBackLink } from "@working-theory/ui/chrome";
28
- import { Button, Heading, Link, Text } from "@working-theory/ui/primitives";
34
+ import { Button, Link, Text } from "@working-theory/ui/primitives";
29
35
  import { getTranslations } from "next-intl/server";
30
36
  import type { ReactElement } from "react";
37
+
38
+ import { SITE_NAME } from "../site-brand";
31
39
  import { siteLogo } from "../site-logo";
40
+
32
41
  import { hubSessionConfig } from "./config";
33
42
  import { HUB_SESSION_FALLBACK_PATH } from "./session";
34
43
 
@@ -46,7 +55,8 @@ export async function HubSignInGate({
46
55
  requestHeaders,
47
56
  }: HubSignInGateProps): Promise<ReactElement> {
48
57
  const t = await getTranslations({ locale, namespace: "account.gate" });
49
- const configured = hubSessionConfig() !== null;
58
+ const signIn = await getTranslations({ locale, namespace: "signIn" });
59
+ const config = hubSessionConfig();
50
60
  const next =
51
61
  requestHeaders.get("x-pathname") ??
52
62
  localeHref(locale, HUB_SESSION_FALLBACK_PATH);
@@ -56,79 +66,93 @@ export async function HubSignInGate({
56
66
  // presence is the switch; hence the cast on `has`.
57
67
  type Key = Parameters<typeof t.has>[0];
58
68
  const has = (key: string): boolean => t.has(key as Key);
59
- const prospect =
60
- has("prospectPrompt") && has("prospectLink") && has("prospectHref")
69
+ // Only a configured gate offers a way in; the not-configured state has none.
70
+ const prospect = !config
71
+ ? undefined
72
+ : has("prospectPrompt") && has("prospectLink") && has("prospectHref")
61
73
  ? {
62
74
  prompt: t("prospectPrompt" as Key),
63
75
  linkLabel: t("prospectLink" as Key),
64
76
  href: t("prospectHref" as Key),
65
77
  }
66
- : null;
78
+ : {
79
+ prompt: signIn("prospectPrompt"),
80
+ linkLabel: signIn("prospectLink"),
81
+ href: `${config.hubOrigin}/sign-up`,
82
+ };
67
83
 
68
84
  return (
69
85
  <div
70
86
  className="flex min-h-screen flex-col"
71
87
  data-testid="account-sign-in-gate"
72
88
  >
73
- {/* The site's inner-page header: back arrow at the start, the mark centred (#8790 AC-1). */}
89
+ {/* The site's inner-page header: the back arrow at the start (#8790 AC-1); the card carries the mark (#9662). */}
74
90
  <header
75
- className="grid h-16 grid-cols-[1fr_auto_1fr] items-center px-5 md:px-8"
91
+ className="flex h-16 items-center px-5 md:px-8"
76
92
  data-testid="account-sign-in-gate-header"
77
93
  >
78
- <div className="justify-self-start">
79
- <NavBackLink href={home} label={t("homeLink")} />
80
- </div>
81
- <Link
82
- href={home}
83
- variant="unstyled"
84
- className="inline-flex h-7 items-center [&>img]:h-full [&>img]:w-auto [&>img]:object-contain"
85
- data-testid="account-sign-in-gate-mark"
86
- >
87
- {siteLogo()}
88
- </Link>
89
- <div />
94
+ <NavBackLink href={home} label={t("homeLink")} />
90
95
  </header>
91
- <div className="flex flex-1 flex-col items-center justify-center gap-4 py-section text-center">
92
- <Heading level={1} visual={2}>
93
- {configured ? t("hubHeadline") : t("emptyHeadline")}
94
- </Heading>
95
- <Text variant="body-sm" tone="muted" className="max-w-xs">
96
- {configured ? t("hubDescription") : t("emptyDescription")}
97
- </Text>
98
- {configured ? (
99
- // The primary action: 44px on a phone, 36px from md up.
100
- <Button asChild className="max-md:min-h-11">
101
- <Link
102
- href={`${HUB_SIGN_IN_ROUTE}?next=${encodeURIComponent(next)}`}
103
- variant="bare"
104
- data-testid="account-hub-sign-in"
105
- >
106
- {t("hubSignIn")}
107
- </Link>
108
- </Button>
109
- ) : (
110
- <Button asChild variant="secondary" className="max-md:min-h-11">
111
- <Link
112
- href={home}
113
- variant="bare"
114
- data-testid="account-sign-in-gate-home"
115
- >
116
- {t("homeLink")}
117
- </Link>
118
- </Button>
119
- )}
120
- {configured && prospect ? (
121
- // A wrapping div carries the hook: `Text` forwards no data-* attributes.
122
- <div data-testid="account-sign-in-gate-prospect">
123
- <Text as="p" variant="body-sm" tone="muted">
124
- {prospect.prompt}{" "}
125
- <Link href={prospect.href} variant="inline">
126
- {prospect.linkLabel}
127
- </Link>
128
- .
129
- </Text>
130
- </div>
131
- ) : null}
96
+ <div className="flex flex-1 flex-col items-center md:justify-center md:py-section">
97
+ {/* AuthShell's 400px column: the cap sits on the wrapper, never on the card (AuthShell.tsx). */}
98
+ <div className="w-full max-w-100">
99
+ <AuthCard
100
+ intent="sign-in"
101
+ footer="none"
102
+ // The page's one heading (#7894): the gate is the whole page.
103
+ headingLevel={1}
104
+ logo={siteLogo()}
105
+ labels={{
106
+ signInHeading: config
107
+ ? t("hubHeadline", { site: SITE_NAME })
108
+ : t("emptyHeadline"),
109
+ }}
110
+ subhead={config ? t("hubDescription") : t("emptyDescription")}
111
+ {...(prospect ? { prospect } : {})}
112
+ body={
113
+ config ? (
114
+ // The primary action: full width at `big` (44px).
115
+ <Button asChild size="big" className="w-full">
116
+ <Link
117
+ href={`${HUB_SIGN_IN_ROUTE}?next=${encodeURIComponent(next)}`}
118
+ variant="bare"
119
+ data-testid="account-hub-sign-in"
120
+ >
121
+ {t("hubSignIn")}
122
+ </Link>
123
+ </Button>
124
+ ) : (
125
+ <Button
126
+ asChild
127
+ variant="secondary"
128
+ size="big"
129
+ className="w-full"
130
+ >
131
+ <Link
132
+ href={home}
133
+ variant="bare"
134
+ data-testid="account-sign-in-gate-home"
135
+ >
136
+ {t("homeLink")}
137
+ </Link>
138
+ </Button>
139
+ )
140
+ }
141
+ footnote={
142
+ <Text as="p" variant="caption">
143
+ {signIn("acknowledgement")}{" "}
144
+ <Link variant="inline" href={localeHref(locale, "/terms")}>
145
+ {signIn("termsLink")}
146
+ </Link>{" "}
147
+ {signIn("acknowledgementAnd")}{" "}
148
+ <Link variant="inline" href={localeHref(locale, "/privacy")}>
149
+ {signIn("privacyLink")}
150
+ </Link>
151
+ {signIn("acknowledgementEnd")}
152
+ </Text>
153
+ }
154
+ />
155
+ </div>
132
156
  </div>
133
157
  </div>
134
158
  );
@@ -14,8 +14,11 @@
14
14
  * `lib/sign-up-agreements.ts`'s account hook writes the new user's `sign_up`
15
15
  * consent grants through `consentGrant.createMany` on the transaction client
16
16
  * Better Auth's sign-up transaction runs on — only rows for the user that
17
- * transaction is creating, never a read and never any other row. It imports
18
- * nothing from here; it reaches the client through the adapter.
17
+ * transaction is creating, never a read and never any other row. A
18
+ * magic-link sign-up (#9667 D5) writes the same rows from the session hook,
19
+ * on the transaction the adapter opens for them, for the user that verify
20
+ * just created. It imports nothing from here; it reaches the client through
21
+ * the adapter.
19
22
  *
20
23
  * Every other business-logic caller goes through apps/api, never the
21
24
  * database directly (architecture.yaml `apps/web` may_not_import_from
@@ -16,13 +16,22 @@
16
16
  * so the transaction client Better Auth's
17
17
  * sign-up transaction runs on is reachable
18
18
  * from a database hook.
19
- * signUpAgreementHooks() two database hooks: the user hook decides
19
+ * signUpAgreementHooks() three database hooks: the user hook decides
20
20
  * whether this request's new user carries
21
21
  * agreements; the account hook writes one
22
22
  * `sign_up` grant per agreement on the
23
23
  * captured transaction client, so the User,
24
24
  * its Account and the grants commit together
25
- * or not at all (NFR1).
25
+ * or not at all (NFR1); the session hook does
26
+ * the same for a magic-link sign-up, which
27
+ * has no Account (#9667 D5).
28
+ *
29
+ * THE MAGIC LINK (#9667). A request to `/sign-in/magic-link` whose `metadata`
30
+ * carries the agreements is a sign-up request: the plugin refuses it before
31
+ * the handler, `lib/auth-email.ts`'s callback binds the accepted agreements
32
+ * to the token (a verification row keyed on the token's hash), and the
33
+ * verify creates a user only for a token whose binding still covers the
34
+ * evidence.
26
35
  *
27
36
  * THE EVIDENCE IS THE API'S. Each grant's statement, hash and version come
28
37
  * from `GET /v1/consent/sign-up` — built by the same functions `apps/api`'s
@@ -56,6 +65,7 @@
56
65
  */
57
66
 
58
67
  import { AsyncLocalStorage } from "node:async_hooks";
68
+ import { createHash } from "node:crypto";
59
69
 
60
70
  import { consent } from "@working-theory/api-client";
61
71
  import {
@@ -70,6 +80,8 @@ import { isSignUpOpen } from "./auth-env";
70
80
 
71
81
  const SIGN_UP_PATH = "/sign-up/email";
72
82
  const CALLBACK_PREFIX = "/callback/";
83
+ const MAGIC_LINK_PATH = "/sign-in/magic-link";
84
+ const MAGIC_LINK_VERIFY_PATH = "/magic-link/verify";
73
85
  const DEFAULT_LOCALE = "en";
74
86
  const FRESH_MS = 60_000;
75
87
  const STALE_MS = 10 * 60_000;
@@ -179,7 +191,7 @@ function coversEvidence(
179
191
 
180
192
  async function apiError(
181
193
  status: "BAD_REQUEST" | "SERVICE_UNAVAILABLE",
182
- code: "AGREEMENTS_REQUIRED" | "AGREEMENTS_UNAVAILABLE",
194
+ code: "AGREEMENTS_REQUIRED" | "AGREEMENTS_UNAVAILABLE" | "SIGN_UP_CLOSED",
183
195
  message: string,
184
196
  ): Promise<Error> {
185
197
  const { APIError } = await import("better-auth/api");
@@ -200,6 +212,18 @@ const agreementsUnavailable = (): Promise<Error> =>
200
212
  "The sign-up agreements could not be read. Try again in a moment.",
201
213
  );
202
214
 
215
+ const warn = (fields: Record<string, unknown>): void =>
216
+ console.warn(
217
+ `[sign-up-agreements] ${JSON.stringify({ kind: "magic-link", ...fields })}`,
218
+ );
219
+
220
+ const signUpClosed = (): Promise<Error> =>
221
+ apiError(
222
+ "BAD_REQUEST",
223
+ "SIGN_UP_CLOSED",
224
+ "Public sign-up is not open on this site.",
225
+ );
226
+
203
227
  /** A request body, as the hooks see it: never trusted, read by key. */
204
228
  function bodyField(body: unknown, key: string): unknown {
205
229
  return typeof body === "object" && body !== null
@@ -207,12 +231,121 @@ function bodyField(body: unknown, key: string): unknown {
207
231
  : undefined;
208
232
  }
209
233
 
234
+ /**
235
+ * A magic-link request's `metadata` when it is a sign-up request — one that
236
+ * carries the agreements key (#9667 D2) — else `null`. Every `/sign-in`
237
+ * request, and the `(authed)` gate's, sends none.
238
+ */
239
+ export function signUpMetadata(body: unknown): object | null {
240
+ const metadata = bodyField(body, "metadata");
241
+ return typeof metadata === "object" &&
242
+ metadata !== null &&
243
+ SIGN_UP_AGREEMENTS_FIELD in metadata
244
+ ? metadata
245
+ : null;
246
+ }
247
+
248
+ // ── #9667 D4 — the magic link's agreements binding ──────────────────────────
249
+
250
+ /**
251
+ * The internal-adapter call the binding makes; Better Auth's satisfies it.
252
+ * Optional, so a sign-in-only double needs none — a binding on one fails.
253
+ */
254
+ export interface LinkAgreementsStore {
255
+ createVerificationValue?(data: {
256
+ identifier: string;
257
+ value: string;
258
+ expiresAt: Date;
259
+ }): Promise<unknown>;
260
+ }
261
+
262
+ interface LinkAgreementsReader {
263
+ findVerificationValue(
264
+ identifier: string,
265
+ ): Promise<{ value: string; expiresAt: Date } | null>;
266
+ }
267
+
268
+ export interface LinkAgreements {
269
+ agreements: string[];
270
+ locale: string;
271
+ }
272
+
273
+ /**
274
+ * The binding's identifier: SHA-256 of the token, base64url — never the token
275
+ * itself, so reading the table yields no live credential (the property
276
+ * `storeToken: "hashed"` gives the token row).
277
+ */
278
+ export function linkAgreementsIdentifier(token: string): string {
279
+ return `sign-up-agreements:${createHash("sha256").update(token).digest("base64url")}`;
280
+ }
281
+
282
+ const isStringArray = (raw: unknown): raw is string[] =>
283
+ Array.isArray(raw) && raw.every((item) => typeof item === "string");
284
+
285
+ /**
286
+ * Binds a sign-up request's agreements to its token, server-side. Called by
287
+ * `sendMagicLink` only after the plugin below accepted the request, so
288
+ * nothing in a URL or a later request can create or change it. It expires
289
+ * with the token. Returns what it wrote, which the sign-up email names
290
+ * (#9720 D6).
291
+ */
292
+ export async function bindLinkAgreements(
293
+ store: LinkAgreementsStore,
294
+ token: string,
295
+ metadata: object,
296
+ ttlSeconds: number,
297
+ ): Promise<LinkAgreements> {
298
+ if (!store.createVerificationValue) throw new Error("no verification store");
299
+ const agreements = bodyField(metadata, SIGN_UP_AGREEMENTS_FIELD);
300
+ const binding: LinkAgreements = {
301
+ agreements: isStringArray(agreements) ? agreements : [],
302
+ locale: normaliseLocale(bodyField(metadata, "locale")),
303
+ };
304
+ await store.createVerificationValue({
305
+ identifier: linkAgreementsIdentifier(token),
306
+ value: JSON.stringify(binding),
307
+ expiresAt: new Date(Date.now() + ttlSeconds * 1000),
308
+ });
309
+ return binding;
310
+ }
311
+
312
+ /**
313
+ * The binding for `token`, or `null` when there is none, it has expired
314
+ * (`findVerificationValue` returns expired rows) or its value is malformed.
315
+ * An empty `agreements` array is a valid binding. A failed store read
316
+ * rejects, so the verify can tell it from a missing binding.
317
+ */
318
+ async function readLinkAgreements(
319
+ token: unknown,
320
+ store: LinkAgreementsReader,
321
+ ): Promise<LinkAgreements | null> {
322
+ if (typeof token !== "string" || token.length === 0) return null;
323
+ const row = await store.findVerificationValue(
324
+ linkAgreementsIdentifier(token),
325
+ );
326
+ if (!row || new Date(row.expiresAt).getTime() <= Date.now()) return null;
327
+ let value: unknown;
328
+ try {
329
+ value = JSON.parse(row.value);
330
+ } catch {
331
+ return null;
332
+ }
333
+ const agreements = bodyField(value, "agreements");
334
+ const locale = bodyField(value, "locale");
335
+ return isStringArray(agreements) && typeof locale === "string"
336
+ ? { agreements, locale }
337
+ : null;
338
+ }
339
+
210
340
  // ── D3 — the plugin ─────────────────────────────────────────────────────────
211
341
 
212
342
  /**
213
343
  * Refuses `POST /sign-up/email` without the agreements (D3), and warms the
214
344
  * evidence cache on the OAuth callback so the user hook below finds it
215
- * without a network call inside the transaction (SEC-8765-I).
345
+ * without a network call inside the transaction (SEC-8765-I). The same two
346
+ * jobs for the magic link (#9667 D3, D5): a sign-up request to
347
+ * `/sign-in/magic-link` is refused before the handler mints a token or looks
348
+ * the address up, and `/magic-link/verify` warms the cache.
216
349
  */
217
350
  export function signUpAgreementsPlugin(): BetterAuthPlugin {
218
351
  return {
@@ -254,6 +387,40 @@ export function signUpAgreementsPlugin(): BetterAuthPlugin {
254
387
  );
255
388
  },
256
389
  },
390
+ {
391
+ matcher: (context) => context.path === MAGIC_LINK_PATH,
392
+ // A sign-in request is never refused here. A sign-up request is
393
+ // refused on configuration alone, then on the evidence — all
394
+ // before the handler, so no answer depends on the address.
395
+ handler: async (context) => {
396
+ const metadata = signUpMetadata(context.body);
397
+ if (!metadata) return;
398
+ if (!isSignUpOpen()) throw await signUpClosed();
399
+ const evidence = await readEvidence(
400
+ normaliseLocale(bodyField(metadata, "locale")),
401
+ );
402
+ if (!evidence) throw await agreementsUnavailable();
403
+ if (
404
+ evidence.agreements.length > 0 &&
405
+ !coversEvidence(
406
+ bodyField(metadata, SIGN_UP_AGREEMENTS_FIELD),
407
+ evidence,
408
+ )
409
+ ) {
410
+ throw await agreementsRequired();
411
+ }
412
+ },
413
+ },
414
+ {
415
+ matcher: (context) => context.path === MAGIC_LINK_VERIFY_PATH,
416
+ // Warms only, as the callback hook does. Never refuses.
417
+ handler: async () => {
418
+ if (!isSignUpOpen()) return;
419
+ await Promise.all(
420
+ enabledLocales().map((locale) => readEvidence(locale)),
421
+ );
422
+ },
423
+ },
257
424
  ],
258
425
  },
259
426
  };
@@ -347,6 +514,61 @@ type HookContext = Parameters<
347
514
  >
348
515
  >[1];
349
516
 
517
+ /**
518
+ * A refused magic-link verify: the redirect the plugin's own
519
+ * `redirectWithError` builds — the request's `errorCallbackURL`, else its
520
+ * `callbackURL`, else `/`, against the base URL, with `error=<code>`. The
521
+ * plugin's origin check has already vetted both URLs.
522
+ */
523
+ function verifyRefusal(
524
+ context: NonNullable<HookContext>,
525
+ code: string,
526
+ ): unknown {
527
+ const raw =
528
+ bodyField(context.query, "errorCallbackURL") ??
529
+ bodyField(context.query, "callbackURL");
530
+ const url = new URL(
531
+ typeof raw === "string" ? decodeURIComponent(raw) : "/",
532
+ context.context.baseURL,
533
+ );
534
+ url.searchParams.set("error", code);
535
+ return context.redirect(url.toString());
536
+ }
537
+
538
+ /**
539
+ * Writes one `sign_up` grant per agreement for `subjectId`, field-for-field
540
+ * from the evidence, on the transaction client captured for this call. No
541
+ * captured client fails closed: the user is not created without its grants.
542
+ */
543
+ async function writeGrants(
544
+ evidence: ConsentSignUpResponse,
545
+ subjectId: string,
546
+ ): Promise<void> {
547
+ const tx = capturedTx.getStore();
548
+ if (!isConsentGrantWriter(tx)) {
549
+ throw new Error(
550
+ "sign-up agreements: no transaction client captured; refusing to create the user without its grants",
551
+ );
552
+ }
553
+ await tx.consentGrant.createMany({
554
+ data: evidence.agreements.map((agreement) => ({
555
+ siteId: evidence.workspaceId,
556
+ workspaceId: evidence.workspaceId,
557
+ subjectType: "user",
558
+ subjectId,
559
+ personId: null,
560
+ purposeId: agreement.purposeId,
561
+ granted: true,
562
+ source: "sign_up",
563
+ sourceRef: null,
564
+ policyVersion: agreement.policyVersion,
565
+ locale: agreement.locale,
566
+ statement: agreement.statement,
567
+ statementHash: agreement.statementHash,
568
+ })),
569
+ });
570
+ }
571
+
350
572
  /**
351
573
  * The evidence the user being created agreed to, or `null` when none is to be
352
574
  * recorded. Reads the cache only — the transaction is already open.
@@ -377,6 +599,29 @@ async function agreedEvidence(
377
599
  return evidence;
378
600
  }
379
601
 
602
+ if (path === MAGIC_LINK_VERIFY_PATH) {
603
+ // The plugin creates a user only for an address with none (#9667 D5).
604
+ if (!isSignUpOpen())
605
+ throw verifyRefusal(context, "new_user_signup_disabled");
606
+ const binding = await readLinkAgreements(
607
+ bodyField(context.query, "token"),
608
+ context.context.internalAdapter,
609
+ ).catch(() => {
610
+ throw verifyRefusal(context, "AGREEMENTS_UNAVAILABLE");
611
+ });
612
+ const evidence = cachedEvidence(normaliseLocale(binding?.locale));
613
+ if (!evidence) throw verifyRefusal(context, "AGREEMENTS_UNAVAILABLE");
614
+ // Every creation needs a sign-up request behind it, so a sign-in
615
+ // request's token never becomes an account.
616
+ if (!binding) throw verifyRefusal(context, "AGREEMENTS_REQUIRED");
617
+ if (evidence.agreements.length === 0) return null;
618
+ // Checked against the evidence now, not the evidence at request time.
619
+ if (!coversEvidence(binding.agreements, evidence)) {
620
+ throw verifyRefusal(context, "AGREEMENTS_REQUIRED");
621
+ }
622
+ return evidence;
623
+ }
624
+
380
625
  if (path.startsWith(CALLBACK_PREFIX)) {
381
626
  const { getOAuthState } = await import("better-auth/api");
382
627
  const state: unknown = await getOAuthState().catch(() => null);
@@ -393,9 +638,16 @@ async function agreedEvidence(
393
638
  }
394
639
 
395
640
  /**
396
- * The two database hooks (D4). They share one request's context object —
397
- * Better Auth hands both the endpoint context it runs the handler in — and
398
- * that object keys the `WeakMap` between them.
641
+ * The database hooks (D4). They share one request's context object — Better
642
+ * Auth hands each the endpoint context it runs the handler in — and that
643
+ * object keys the `WeakMap` between them.
644
+ *
645
+ * The magic-link verify creates no Account row and no transaction around its
646
+ * user (better-auth 1.6.22), so its grants are written by the session hook
647
+ * (#9667 D5): after the user insert, before the session row and cookie exist,
648
+ * in a transaction of their own, and the user is deleted if that write fails.
649
+ * Not one transaction — a crash between the two inserts leaves a user with no
650
+ * grant and no session, whom the account interstitial stops (SEC-8765-C).
399
651
  */
400
652
  export function signUpAgreementHooks(): DatabaseHooks {
401
653
  const pending = new WeakMap<object, ConsentSignUpResponse>();
@@ -419,29 +671,40 @@ export function signUpAgreementHooks(): DatabaseHooks {
419
671
  const evidence = pending.get(context);
420
672
  if (!evidence) return;
421
673
  pending.delete(context);
422
- const tx = capturedTx.getStore();
423
- if (!isConsentGrantWriter(tx)) {
424
- throw new Error(
425
- "sign-up agreements: no transaction client captured; refusing to create the user without its grants",
674
+ await writeGrants(evidence, account.userId);
675
+ },
676
+ },
677
+ },
678
+ session: {
679
+ create: {
680
+ // Set only by the magic-link verify's user hook, for a new user.
681
+ before: async (session, context) => {
682
+ if (!context || context.path !== MAGIC_LINK_VERIFY_PATH) return;
683
+ const evidence = pending.get(context);
684
+ if (!evidence) return;
685
+ pending.delete(context);
686
+ const { adapter, internalAdapter } = context.context;
687
+ try {
688
+ await adapter.transaction(() =>
689
+ writeGrants(evidence, session.userId),
426
690
  );
691
+ } catch {
692
+ // Fields only — never the address or the token.
693
+ warn({ reason: "grant_write_failed" });
694
+ await internalAdapter.deleteUser(session.userId).catch(() => {
695
+ warn({
696
+ reason: "grant_write_failed",
697
+ errorKind: "delete_user_failed",
698
+ });
699
+ });
700
+ throw verifyRefusal(context, "AGREEMENTS_UNAVAILABLE");
701
+ }
702
+ const token = bodyField(context.query, "token");
703
+ if (typeof token === "string") {
704
+ await internalAdapter
705
+ .deleteVerificationByIdentifier(linkAgreementsIdentifier(token))
706
+ .catch(() => {});
427
707
  }
428
- await tx.consentGrant.createMany({
429
- data: evidence.agreements.map((agreement) => ({
430
- siteId: evidence.workspaceId,
431
- workspaceId: evidence.workspaceId,
432
- subjectType: "user",
433
- subjectId: account.userId,
434
- personId: null,
435
- purposeId: agreement.purposeId,
436
- granted: true,
437
- source: "sign_up",
438
- sourceRef: null,
439
- policyVersion: agreement.policyVersion,
440
- locale: agreement.locale,
441
- statement: agreement.statement,
442
- statementHash: agreement.statementHash,
443
- })),
444
- });
445
708
  },
446
709
  },
447
710
  },
@@ -0,0 +1,18 @@
1
+ // site-contributions.ts — what this site adds to its own account area (#9401 D7).
2
+ //
3
+ // This file is the site's, not the template's. AILK ships it empty and never
4
+ // changes its bytes, so `create-ailk update` meets a site's edit of it as
5
+ // "edited, template unchanged" and leaves that edit alone on every run. Record
6
+ // the edit in `customizations.yaml` (the template guard refuses an edit to an
7
+ // untouched template file the manifest does not name), and keep the section's
8
+ // own code in files the site adds beside it.
9
+ //
10
+ // A module contributes through `account-config.ts`; a site contributes here.
11
+ // The Billing page renders these sections after the modules' own. A section's
12
+ // title is a message key, resolved through `ctx.t`; put the site's strings in
13
+ // `messages/site/<locale>.json`, which the request config merges add-only.
14
+
15
+ import type { AccountBillingSection } from "@working-theory/templates/account";
16
+
17
+ /** Billing-page sections this site adds after the modules' sections. */
18
+ export const siteBillingSections: AccountBillingSection[] = [];