create-ailk 0.25.1 → 0.27.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 (75) hide show
  1. package/README.md +14 -5
  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 +14 -2
  8. package/dist/starter-grid.d.ts +6 -6
  9. package/dist/starter-grid.js +12 -5
  10. package/dist/surfaces.js +36 -2
  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 +240 -20
  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 +22 -0
  25. package/templates/CLAUDE.md +20 -20
  26. package/templates/README.md +19 -19
  27. package/templates/apps/api/.env.example +28 -0
  28. package/templates/apps/api/CLAUDE.md +35 -9
  29. package/templates/apps/api/src/lib/__mocks__/prisma-client.js +16 -0
  30. package/templates/apps/api/vercel.json +7 -1
  31. package/templates/apps/mcp/CLAUDE.md +27 -10
  32. package/templates/apps/web/.env.example +13 -0
  33. package/templates/apps/web/CLAUDE.md +34 -6
  34. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +43 -4
  35. package/templates/apps/web/app/[locale]/(auth)/legal-footnote.tsx +35 -0
  36. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
  37. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +168 -8
  38. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
  39. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +22 -39
  40. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +6 -6
  41. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +20 -35
  42. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +301 -0
  43. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +111 -5
  44. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -18
  45. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +132 -12
  46. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +53 -1
  47. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +38 -0
  48. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +4 -2
  49. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +47 -24
  50. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +23 -4
  51. package/templates/apps/web/app/[locale]/layout.tsx +48 -13
  52. package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +128 -0
  53. package/templates/apps/web/app/[locale]/meet/confirmed/__tests__/BookingHandOff.test.tsx +126 -0
  54. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +118 -0
  55. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +14 -0
  56. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
  57. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
  58. package/templates/apps/web/auth.ts +8 -4
  59. package/templates/apps/web/i18n/request.ts +49 -1
  60. package/templates/apps/web/lib/__tests__/account-config.test.ts +7 -0
  61. package/templates/apps/web/lib/__tests__/auth-email.test.ts +276 -0
  62. package/templates/apps/web/lib/__tests__/relay.test.ts +289 -1
  63. package/templates/apps/web/lib/auth-email.ts +102 -20
  64. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +92 -23
  65. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +97 -70
  66. package/templates/apps/web/lib/prisma-for-auth.ts +5 -2
  67. package/templates/apps/web/lib/relay.ts +116 -1
  68. package/templates/apps/web/lib/sign-up-agreements.ts +302 -28
  69. package/templates/apps/web/lib/site-contributions.ts +18 -0
  70. package/templates/apps/web/messages/en.json +271 -20
  71. package/templates/apps/web/messages/site/en.json +1 -0
  72. package/templates/apps/web/middleware.ts +8 -1
  73. package/templates/apps/web/next.config.mjs +9 -1
  74. package/templates/apps/web/package.json +2 -1
  75. package/versions.json +48 -48
@@ -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,72 @@ 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>`.
521
+ *
522
+ * The query value is used as the router decoded it, never decoded again
523
+ * (#9896): a second decode turns `/%2F%2Fevil.com` into a URL on another
524
+ * host, and a malformed `%` into a throw. Whatever the plugin's origin check
525
+ * allowed, a value that does not resolve on the base URL's origin — or does
526
+ * not parse — is replaced by `/`.
527
+ */
528
+ function verifyRefusal(
529
+ context: NonNullable<HookContext>,
530
+ code: string,
531
+ ): unknown {
532
+ const raw =
533
+ bodyField(context.query, "errorCallbackURL") ??
534
+ bodyField(context.query, "callbackURL");
535
+ const { baseURL } = context.context;
536
+ let url = new URL("/", baseURL);
537
+ if (typeof raw === "string") {
538
+ try {
539
+ const resolved = new URL(raw, baseURL);
540
+ if (resolved.origin === url.origin) url = resolved;
541
+ } catch {
542
+ // Unparseable: stays `/`.
543
+ }
544
+ }
545
+ url.searchParams.set("error", code);
546
+ return context.redirect(url.toString());
547
+ }
548
+
549
+ /**
550
+ * Writes one `sign_up` grant per agreement for `subjectId`, field-for-field
551
+ * from the evidence, on the transaction client captured for this call. No
552
+ * captured client fails closed: the user is not created without its grants.
553
+ */
554
+ async function writeGrants(
555
+ evidence: ConsentSignUpResponse,
556
+ subjectId: string,
557
+ ): Promise<void> {
558
+ const tx = capturedTx.getStore();
559
+ if (!isConsentGrantWriter(tx)) {
560
+ throw new Error(
561
+ "sign-up agreements: no transaction client captured; refusing to create the user without its grants",
562
+ );
563
+ }
564
+ await tx.consentGrant.createMany({
565
+ data: evidence.agreements.map((agreement) => ({
566
+ siteId: evidence.workspaceId,
567
+ workspaceId: evidence.workspaceId,
568
+ subjectType: "user",
569
+ subjectId,
570
+ personId: null,
571
+ purposeId: agreement.purposeId,
572
+ granted: true,
573
+ source: "sign_up",
574
+ sourceRef: null,
575
+ policyVersion: agreement.policyVersion,
576
+ locale: agreement.locale,
577
+ statement: agreement.statement,
578
+ statementHash: agreement.statementHash,
579
+ })),
580
+ });
581
+ }
582
+
350
583
  /**
351
584
  * The evidence the user being created agreed to, or `null` when none is to be
352
585
  * recorded. Reads the cache only — the transaction is already open.
@@ -377,6 +610,29 @@ async function agreedEvidence(
377
610
  return evidence;
378
611
  }
379
612
 
613
+ if (path === MAGIC_LINK_VERIFY_PATH) {
614
+ // The plugin creates a user only for an address with none (#9667 D5).
615
+ if (!isSignUpOpen())
616
+ throw verifyRefusal(context, "new_user_signup_disabled");
617
+ const binding = await readLinkAgreements(
618
+ bodyField(context.query, "token"),
619
+ context.context.internalAdapter,
620
+ ).catch(() => {
621
+ throw verifyRefusal(context, "AGREEMENTS_UNAVAILABLE");
622
+ });
623
+ const evidence = cachedEvidence(normaliseLocale(binding?.locale));
624
+ if (!evidence) throw verifyRefusal(context, "AGREEMENTS_UNAVAILABLE");
625
+ // Every creation needs a sign-up request behind it, so a sign-in
626
+ // request's token never becomes an account.
627
+ if (!binding) throw verifyRefusal(context, "AGREEMENTS_REQUIRED");
628
+ if (evidence.agreements.length === 0) return null;
629
+ // Checked against the evidence now, not the evidence at request time.
630
+ if (!coversEvidence(binding.agreements, evidence)) {
631
+ throw verifyRefusal(context, "AGREEMENTS_REQUIRED");
632
+ }
633
+ return evidence;
634
+ }
635
+
380
636
  if (path.startsWith(CALLBACK_PREFIX)) {
381
637
  const { getOAuthState } = await import("better-auth/api");
382
638
  const state: unknown = await getOAuthState().catch(() => null);
@@ -393,9 +649,16 @@ async function agreedEvidence(
393
649
  }
394
650
 
395
651
  /**
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.
652
+ * The database hooks (D4). They share one request's context object — Better
653
+ * Auth hands each the endpoint context it runs the handler in — and that
654
+ * object keys the `WeakMap` between them.
655
+ *
656
+ * The magic-link verify creates no Account row and no transaction around its
657
+ * user (better-auth 1.6.22), so its grants are written by the session hook
658
+ * (#9667 D5): after the user insert, before the session row and cookie exist,
659
+ * in a transaction of their own, and the user is deleted if that write fails.
660
+ * Not one transaction — a crash between the two inserts leaves a user with no
661
+ * grant and no session, whom the account interstitial stops (SEC-8765-C).
399
662
  */
400
663
  export function signUpAgreementHooks(): DatabaseHooks {
401
664
  const pending = new WeakMap<object, ConsentSignUpResponse>();
@@ -419,29 +682,40 @@ export function signUpAgreementHooks(): DatabaseHooks {
419
682
  const evidence = pending.get(context);
420
683
  if (!evidence) return;
421
684
  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",
685
+ await writeGrants(evidence, account.userId);
686
+ },
687
+ },
688
+ },
689
+ session: {
690
+ create: {
691
+ // Set only by the magic-link verify's user hook, for a new user.
692
+ before: async (session, context) => {
693
+ if (!context || context.path !== MAGIC_LINK_VERIFY_PATH) return;
694
+ const evidence = pending.get(context);
695
+ if (!evidence) return;
696
+ pending.delete(context);
697
+ const { adapter, internalAdapter } = context.context;
698
+ try {
699
+ await adapter.transaction(() =>
700
+ writeGrants(evidence, session.userId),
426
701
  );
702
+ } catch {
703
+ // Fields only — never the address or the token.
704
+ warn({ reason: "grant_write_failed" });
705
+ await internalAdapter.deleteUser(session.userId).catch(() => {
706
+ warn({
707
+ reason: "grant_write_failed",
708
+ errorKind: "delete_user_failed",
709
+ });
710
+ });
711
+ throw verifyRefusal(context, "AGREEMENTS_UNAVAILABLE");
712
+ }
713
+ const token = bodyField(context.query, "token");
714
+ if (typeof token === "string") {
715
+ await internalAdapter
716
+ .deleteVerificationByIdentifier(linkAgreementsIdentifier(token))
717
+ .catch(() => {});
427
718
  }
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
719
  },
446
720
  },
447
721
  },
@@ -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[] = [];