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
@@ -19,7 +19,8 @@
19
19
  *
20
20
  * After any of the three, one plain line when some of the member's billing
21
21
  * is on a Stripe account this spoke's key cannot read (#8217 D12), then
22
- * each section a module contributes (`accountBillingSections`, #8231 D4).
22
+ * each section a module contributes (`accountBillingSections`, #8231 D4),
23
+ * then each section the site adds (`siteBillingSections`, #9401 D7).
23
24
  */
24
25
 
25
26
  import { AccountSettingsFrame, AccountSettingsPane, CurrentPlanCard } from "@working-theory/templates/account";
@@ -29,6 +30,7 @@ import { getTranslations } from "next-intl/server";
29
30
  import type { ReactElement } from "react";
30
31
 
31
32
  import { accountBillingSections } from "../../../../../lib/account-config";
33
+ import { siteBillingSections } from "../../../../../lib/site-contributions";
32
34
  import { accountSession } from "../account-data";
33
35
  import { openBillingPortal } from "../actions";
34
36
  import { type BillingState, loadBilling } from "../billing-source";
@@ -161,7 +163,7 @@ export default async function AccountBillingPage({ params }: Props): Promise<Rea
161
163
  </div>
162
164
  ) : null}
163
165
 
164
- {accountBillingSections.map((section) => (
166
+ {[...accountBillingSections, ...siteBillingSections].map((section) => (
165
167
  <SettingsSection key={section.id} id={section.id} title={ctx.t(section.title)}>
166
168
  {section.render(ctx)}
167
169
  </SettingsSection>
@@ -20,18 +20,23 @@
20
20
  * current pathname (via `usePathname()`) so a successful social sign-in
21
21
  * lands back on the exact dashboard page the visitor requested.
22
22
  *
23
- * Email sign-in link (#7435 § D9): when the layout passes `emailLink`, the
24
- * gate renders `/sign-in`'s own `SignInCard` — the same providers plus "Email
25
- * me a sign-in link" — in place of `AuthPanel`, whose email + password form
26
- * has no submit wiring and would otherwise sit inert beside the new field.
27
- * Without the prop every render is what it was before.
23
+ * Email sign-in link (#7435 § D9): when the layout passes `card` with
24
+ * `emailLink`, the gate renders `/sign-in`'s own `SignInCard` — the same
25
+ * providers plus "Send me a magic link" — in place of `AuthPanel`, whose
26
+ * email + password form has no submit wiring and would otherwise sit inert
27
+ * beside the new field. Without the prop every render is what it was before.
28
+ *
29
+ * The one card (#9662): on the member `/account` gate the layout passes
30
+ * `card` whenever a method is on, with the site's mark, the "Don't have an
31
+ * account? Sign up" line and the Terms + Privacy acknowledgement, so the gate
32
+ * renders the same card `/sign-in` does.
28
33
  */
29
34
 
30
35
  import { EmptyState, Link } from "@working-theory/ui/primitives";
31
36
  import { AuthPanel, type AuthPanelProvider } from "@working-theory/ui/sections";
32
37
  import { createAuthClient } from "better-auth/react";
33
38
  import { usePathname } from "next/navigation";
34
- import type { ReactElement } from "react";
39
+ import type { ReactElement, ReactNode } from "react";
35
40
 
36
41
  import { SignInCard } from "../(auth)/sign-in/sign-in-card";
37
42
  import type { SignInCardText } from "../(auth)/sign-in/sign-in-text";
@@ -52,17 +57,25 @@ export interface SignInGateProps {
52
57
  /** The active social providers, resolved server-side by the layout (#7419). */
53
58
  providers: AuthPanelProvider[];
54
59
  /**
55
- * Present iff the email sign-in link is on (#7435) — the sign-in card's
56
- * copy, resolved server-side by the layout.
60
+ * Renders `SignInCard` in place of `AuthPanel` — present when the email
61
+ * sign-in link is on (#7435) or on the member gate (#9662). The card's copy,
62
+ * mark, prospect line and footnote are resolved server-side by the layout.
57
63
  */
58
- emailLink?: { text: SignInCardText };
64
+ card?: {
65
+ text: SignInCardText;
66
+ /** Offer "Send me a magic link" — the email sign-in link is on. */
67
+ emailLink: boolean;
68
+ logo?: ReactNode;
69
+ prospect?: { prompt: string; linkLabel: string; href: string };
70
+ footnote?: ReactNode;
71
+ };
59
72
  }
60
73
 
61
74
  export function SignInGate({
62
75
  labels,
63
76
  signInPath,
64
77
  providers,
65
- emailLink,
78
+ card,
66
79
  }: SignInGateProps): ReactElement {
67
80
  const pathname = usePathname();
68
81
  const signInHref = `${signInPath}?next=${encodeURIComponent(pathname)}`;
@@ -79,25 +92,35 @@ export function SignInGate({
79
92
  </Link>
80
93
  );
81
94
 
82
- if (emailLink) {
83
- const { text } = emailLink;
95
+ if (card) {
96
+ const { text } = card;
84
97
  return (
85
98
  <div
86
99
  className="flex min-h-screen flex-col items-center justify-center gap-4 py-section"
87
100
  data-testid="sign-in-gate"
88
101
  >
89
- <SignInCard
90
- providers={providers}
91
- method="sso"
92
- next={pathname}
93
- labels={text.labels}
94
- notConfiguredSubhead={text.notConfiguredSubhead}
95
- errorLabels={text.errorLabels}
96
- emailLink
97
- errorCallbackURL={signInHref}
98
- signInHref={signInHref}
99
- emailLinkLabels={text.emailLinkLabels}
100
- />
102
+ {/* AuthShell's 400px column: the cap sits on the wrapper, never on the card (AuthShell.tsx). */}
103
+ <div className="w-full max-w-100">
104
+ <SignInCard
105
+ providers={providers}
106
+ method="sso"
107
+ next={pathname}
108
+ labels={text.labels}
109
+ notConfiguredSubhead={text.notConfiguredSubhead}
110
+ errorLabels={text.errorLabels}
111
+ emailLink={card.emailLink}
112
+ errorCallbackURL={signInHref}
113
+ signInHref={signInHref}
114
+ emailLinkLabels={text.emailLinkLabels}
115
+ {...(card.logo !== undefined ? { logo: card.logo } : {})}
116
+ {...(card.prospect !== undefined
117
+ ? { prospect: card.prospect }
118
+ : {})}
119
+ {...(card.footnote !== undefined
120
+ ? { footnote: card.footnote }
121
+ : {})}
122
+ />
123
+ </div>
101
124
  {signInLink}
102
125
  </div>
103
126
  );
@@ -11,9 +11,11 @@ import { readLocales } from "@working-theory/web-i18n";
11
11
  import { getTranslations } from "next-intl/server";
12
12
  import type { ReactElement } from "react";
13
13
 
14
+ import { legalFootnote } from "../(auth)/legal-footnote";
14
15
  import { resolveSignInCardText } from "../(auth)/sign-in/sign-in-text";
15
16
  import { getActiveProviderIds } from "../../../lib/auth-env";
16
17
  import { isAccountPath } from "../../../lib/auth-routes";
18
+ import { siteLogo } from "../../../lib/site-logo";
17
19
 
18
20
  import { SignInGate } from "./sign-in-gate";
19
21
 
@@ -39,9 +41,7 @@ export async function SignedOutGate({
39
41
  namespace: isAccount ? "account.gate" : "waitlistDashboard.gate",
40
42
  });
41
43
  const active = getActiveProviderIds();
42
- const emailLink = active.includes("magic-link")
43
- ? { text: await resolveSignInCardText(locale) }
44
- : undefined;
44
+ const emailLink = active.includes("magic-link");
45
45
  const providers = active.filter((id): id is AuthPanelProvider =>
46
46
  (SUPPORTED_PROVIDERS as readonly string[]).includes(id),
47
47
  );
@@ -75,6 +75,25 @@ export async function SignedOutGate({
75
75
  );
76
76
  }
77
77
 
78
+ // The sign-in card: with the email link on, and on the member gate, which
79
+ // also wears the one card's frame — the mark, the toggle line to /sign-up
80
+ // and the Terms + Privacy acknowledgement (#9662).
81
+ const text =
82
+ emailLink || isAccount ? await resolveSignInCardText(locale) : undefined;
83
+ const card = text
84
+ ? {
85
+ text,
86
+ emailLink,
87
+ ...(isAccount
88
+ ? {
89
+ logo: siteLogo(),
90
+ prospect: { ...text.prospect, href: localeHref(locale, "/sign-up") },
91
+ footnote: legalFootnote(locale, text.acknowledgement),
92
+ }
93
+ : {}),
94
+ }
95
+ : undefined;
96
+
78
97
  return (
79
98
  <SignInGate
80
99
  labels={{
@@ -84,7 +103,7 @@ export async function SignedOutGate({
84
103
  }}
85
104
  signInPath={signInPath}
86
105
  providers={providers}
87
- {...(emailLink ? { emailLink } : {})}
106
+ {...(card ? { card } : {})}
88
107
  />
89
108
  );
90
109
  }
@@ -952,7 +952,9 @@ export default async function LocaleLayout({
952
952
  * cookie read — same #5557 hydration-safety property, no gate
953
953
  * needed. This also means the banner never blocks the page: it
954
954
  * reserves its own space (D1) and carries a close-without-choosing
955
- * control (D2) instead of a hard server-side hide.
955
+ * control (D2) instead of a hard server-side hide. The standard
956
+ * chrome's compact card has no close control (#9656 D5): it is a
957
+ * non-modal corner card and Decline All is one tap.
956
958
  */}
957
959
  {isAccountRoute ? null : (
958
960
  <ConsentBanner
@@ -961,18 +963,36 @@ export default async function LocaleLayout({
961
963
  consentCookieName={CONSENT_COOKIE_NAME}
962
964
  consentDecided={consentDecided}
963
965
  title={tConsent("title")}
964
- message={
965
- <>
966
- {consentBannerStatement}{" "}
967
- <Link
968
- href={localeHref(locale, "/privacy")}
969
- variant="unstyled"
970
- className="font-semibold text-current underline underline-offset-4"
971
- >
972
- {tConsent("policyLinkLabel")}
973
- </Link>
974
- </>
975
- }
966
+ // #9656 D7 — the standard chrome mounts the compact floating
967
+ // card on every width, its Privacy Policy link on the button
968
+ // row; the minimal chrome keeps its hairline band (#7742 D5),
969
+ // the statement with the link inline.
970
+ {...(isMinimalChrome
971
+ ? {
972
+ message: (
973
+ <>
974
+ {consentBannerStatement}{" "}
975
+ <Link
976
+ href={localeHref(locale, "/privacy")}
977
+ variant="unstyled"
978
+ className="font-semibold text-current underline underline-offset-4"
979
+ >
980
+ {tConsent("policyLinkLabel")}
981
+ </Link>
982
+ </>
983
+ ),
984
+ }
985
+ : {
986
+ message: consentBannerStatement,
987
+ presentation: "floating" as const,
988
+ corner: "bottom-left" as const,
989
+ consentAppearance: "compact" as const,
990
+ mobileConsent: "inherit" as const,
991
+ consentPolicyLink: {
992
+ label: tConsent("policyLinkLabel"),
993
+ href: "/privacy",
994
+ },
995
+ })}
976
996
  dismissible={false}
977
997
  dismissLabel={tConsent("dismissLabel")}
978
998
  acceptLabel={tConsent("acceptLabel")}
@@ -0,0 +1,128 @@
1
+ // oss: false
2
+ "use client"; // Reads sessionStorage (a browser API) once on mount — a server page cannot.
3
+
4
+ /**
5
+ * BookingHandOff — `/meet/confirmed`'s booked time and Slack next step
6
+ * (#9853 D10). The booker writes one `sessionStorage` item before it
7
+ * navigates here (`BOOKING_CONFIRMATION_STORAGE_KEY`); this reads it once and
8
+ * removes it, the `/thanks` page's `AccountInviteLink` pattern, so the page's
9
+ * address carries nothing about the booking and a later visit shows nothing.
10
+ *
11
+ * It renders only an item written for this page (`to`'s path, without its
12
+ * query or hash, is `/meet/confirmed` or `/<locale>/meet/confirmed`) in the last ten
13
+ * minutes. The time is formatted in the zone Cal.com recorded, with the zone
14
+ * named, so the page and the Slack welcome name the same one; a zone `Intl`
15
+ * does not know renders nothing. Every string is a prop the server page read
16
+ * from the `schedule` namespace.
17
+ */
18
+ import { Container, Heading, Text } from "@working-theory/ui";
19
+ import {
20
+ BOOKING_CONFIRMATION_STORAGE_KEY,
21
+ BookingConfirmationHandOffSchema,
22
+ } from "@working-theory/validation";
23
+ import type { ReactElement } from "react";
24
+ import { useEffect, useState } from "react";
25
+
26
+ /** A reserved name that never resolves: only used to read the path of `to`. */
27
+ const PLACEHOLDER_ORIGIN = "https://placeholder.invalid";
28
+ const CONFIRMED_PATH = "/meet/confirmed";
29
+ const MAX_AGE_MS = 10 * 60_000;
30
+
31
+ interface Props {
32
+ locale: string;
33
+ /** `schedule.confirmed.bookedTimeHeading`. */
34
+ bookedTimeHeading: string;
35
+ /** `schedule.confirmed.slackHeadline`. */
36
+ slackHeadline: string;
37
+ /** `schedule.confirmed.slackBody`. */
38
+ slackBody: string;
39
+ }
40
+
41
+ interface Shown {
42
+ time: string;
43
+ slackOptIn: boolean;
44
+ }
45
+
46
+ /** Whether `to` names this page, with or without this page's `/<locale>`. */
47
+ function namesThisPage(to: string, locale: string): boolean {
48
+ let url: URL;
49
+ try {
50
+ url = new URL(to, PLACEHOLDER_ORIGIN);
51
+ } catch {
52
+ return false;
53
+ }
54
+ if (url.origin !== PLACEHOLDER_ORIGIN) return false;
55
+ return (
56
+ url.pathname === CONFIRMED_PATH ||
57
+ url.pathname === `/${locale}${CONFIRMED_PATH}`
58
+ );
59
+ }
60
+
61
+ /** Read the hand-off once, remove it, and answer what to show (or `null`). */
62
+ function takeHandOff(locale: string): Shown | null {
63
+ try {
64
+ const stored = window.sessionStorage.getItem(
65
+ BOOKING_CONFIRMATION_STORAGE_KEY,
66
+ );
67
+ if (stored === null) return null;
68
+ window.sessionStorage.removeItem(BOOKING_CONFIRMATION_STORAGE_KEY);
69
+ const parsed = BookingConfirmationHandOffSchema.safeParse(
70
+ JSON.parse(stored),
71
+ );
72
+ if (!parsed.success) return null;
73
+ const { to, savedAt, start, timeZone, slackOptIn } = parsed.data;
74
+ const age = Date.now() - savedAt;
75
+ if (!namesThisPage(to, locale) || age < 0 || age >= MAX_AGE_MS) return null;
76
+ const time = new Intl.DateTimeFormat(locale, {
77
+ weekday: "long",
78
+ month: "long",
79
+ day: "numeric",
80
+ hour: "numeric",
81
+ minute: "2-digit",
82
+ timeZone,
83
+ timeZoneName: "longGeneric",
84
+ }).format(new Date(start));
85
+ return { time, slackOptIn };
86
+ } catch {
87
+ // Unparseable JSON, refused storage, or a zone `Intl` does not know.
88
+ return null;
89
+ }
90
+ }
91
+
92
+ export function BookingHandOff({
93
+ locale,
94
+ bookedTimeHeading,
95
+ slackHeadline,
96
+ slackBody,
97
+ }: Props): ReactElement | null {
98
+ const [shown, setShown] = useState<Shown | null>(null);
99
+
100
+ useEffect(() => {
101
+ // Only ever sets: React's development double-mount runs this twice, and
102
+ // the second run finds the item already removed.
103
+ const found = takeHandOff(locale);
104
+ if (found !== null) setShown(found);
105
+ }, [locale]);
106
+
107
+ if (shown === null) return null;
108
+ return (
109
+ <Container className="pt-block-loose">
110
+ <div role="status" data-testid="booking-hand-off">
111
+ <Heading level={2} visual={4}>
112
+ {bookedTimeHeading}
113
+ </Heading>
114
+ <Text className="mt-2">{shown.time}</Text>
115
+ {shown.slackOptIn && (
116
+ <>
117
+ <Heading level={2} visual={4} className="mt-block-tight">
118
+ {slackHeadline}
119
+ </Heading>
120
+ <Text tone="muted" className="mt-2">
121
+ {slackBody}
122
+ </Text>
123
+ </>
124
+ )}
125
+ </div>
126
+ </Container>
127
+ );
128
+ }
@@ -0,0 +1,126 @@
1
+ // oss: false
2
+ /**
3
+ * Tests for `/meet/confirmed`'s booking hand-off (#9853 D9, D10; AC-7).
4
+ *
5
+ * The booker hands this page the booked time, its zone and the Slack opt-in
6
+ * through one `sessionStorage` item, never the address. The component reads
7
+ * it once and removes it, and shows it only when it was written for this page
8
+ * in the last ten minutes, formatted in the zone Cal.com recorded — the
9
+ * prospect's, not the viewer's. Anything else renders nothing.
10
+ */
11
+ import React from "react";
12
+ import { act, render, screen } from "@testing-library/react";
13
+
14
+ import { BookingHandOff } from "../BookingHandOff";
15
+
16
+ const STORAGE_KEY = "ailk:booking-confirmation";
17
+ const PROPS = {
18
+ locale: "en",
19
+ bookedTimeHeading: "copy:confirmed.bookedTimeHeading",
20
+ slackHeadline: "copy:confirmed.slackHeadline",
21
+ slackBody: "copy:confirmed.slackBody",
22
+ };
23
+
24
+ function handOff(overrides: Record<string, unknown> = {}) {
25
+ return {
26
+ to: "/meet/confirmed",
27
+ savedAt: Date.now(),
28
+ start: "2026-10-30T20:00:00.000Z",
29
+ timeZone: "America/Denver",
30
+ slackOptIn: true,
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ function store(value: unknown): void {
36
+ window.sessionStorage.setItem(
37
+ STORAGE_KEY,
38
+ typeof value === "string" ? value : JSON.stringify(value),
39
+ );
40
+ }
41
+
42
+ async function renderHandOff() {
43
+ let utils!: ReturnType<typeof render>;
44
+ await act(async () => {
45
+ utils = render(<BookingHandOff {...PROPS} />);
46
+ });
47
+ return utils;
48
+ }
49
+
50
+ beforeEach(() => {
51
+ window.sessionStorage.clear();
52
+ window.history.replaceState(null, "", "/en/meet/confirmed");
53
+ });
54
+
55
+ describe("<BookingHandOff/> (#9853 AC-7)", () => {
56
+ it("shows the booked time in the prospect's zone, named, and the Slack step; then forgets it", async () => {
57
+ store(handOff());
58
+ const { container } = await renderHandOff();
59
+
60
+ expect(screen.getByText(PROPS.bookedTimeHeading)).toBeInTheDocument();
61
+ expect(container.textContent).toContain("2:00");
62
+ expect(container.textContent).toContain("Mountain Time");
63
+ expect(screen.getByText(PROPS.slackHeadline)).toBeInTheDocument();
64
+ expect(screen.getByText(PROPS.slackBody)).toBeInTheDocument();
65
+ expect(screen.getByRole("status")).toBeInTheDocument();
66
+ expect(window.sessionStorage.getItem(STORAGE_KEY)).toBeNull();
67
+ });
68
+
69
+ it("accepts a destination written with a locale prefix, a query or a hash", async () => {
70
+ store(handOff({ to: "/en/meet/confirmed?x=1#top" }));
71
+ const { container } = await renderHandOff();
72
+ expect(container.textContent).toContain("Mountain Time");
73
+ });
74
+
75
+ it("shows no Slack text when the box was not ticked", async () => {
76
+ store(handOff({ slackOptIn: false }));
77
+ const { container } = await renderHandOff();
78
+ expect(container.textContent).toContain("Mountain Time");
79
+ expect(screen.queryByText(PROPS.slackHeadline)).toBeNull();
80
+ expect(screen.queryByText(PROPS.slackBody)).toBeNull();
81
+ });
82
+
83
+ it.each<[string, () => void]>([
84
+ ["no item", () => undefined],
85
+ ["an unparseable item", () => store("{not json")],
86
+ [
87
+ "an item with an extra email key",
88
+ () => store(handOff({ email: "ada@example.com" })),
89
+ ],
90
+ [
91
+ "an item for another page",
92
+ () => store(handOff({ to: "/before-your-call" })),
93
+ ],
94
+ [
95
+ "an item eleven minutes old",
96
+ () => store(handOff({ savedAt: Date.now() - 11 * 60_000 })),
97
+ ],
98
+ [
99
+ "a zone Intl does not know",
100
+ () => store(handOff({ timeZone: "Mars/Olympus" })),
101
+ ],
102
+ ])("renders nothing, and removes the item, for %s", async (_label, seed) => {
103
+ seed();
104
+ const { container } = await renderHandOff();
105
+ expect(container).toBeEmptyDOMElement();
106
+ expect(window.sessionStorage.getItem(STORAGE_KEY)).toBeNull();
107
+ });
108
+
109
+ it("shows nothing on a later visit", async () => {
110
+ store(handOff());
111
+ const first = await renderHandOff();
112
+ first.unmount();
113
+ const { container } = await renderHandOff();
114
+ expect(container).toBeEmptyDOMElement();
115
+ });
116
+
117
+ it.each([
118
+ "/fr/meet/confirmed",
119
+ "/anything/meet/confirmed",
120
+ "//evil.example/meet/confirmed",
121
+ ])("renders nothing for a destination of %s", async (to) => {
122
+ store(handOff({ to }));
123
+ const { container } = await renderHandOff();
124
+ expect(container).toBeEmptyDOMElement();
125
+ });
126
+ });
@@ -459,7 +459,9 @@ export function createAuth(deps: CreateAuthDeps = {}) {
459
459
  /**
460
460
  * The sign-up grants (#8765 D4): the user hook decides whether the new
461
461
  * user carries agreements, the account hook writes them on the captured
462
- * transaction client. No other database hook is installed.
462
+ * transaction client. A magic-link sign-up creates no Account, so the
463
+ * session hook writes its grants, in their own transaction, before the
464
+ * session exists (#9667 D5). No other database hook is installed.
463
465
  */
464
466
  databaseHooks: signUpAgreementHooks(),
465
467
  secret: resolveSecret(),
@@ -671,9 +673,11 @@ export function createAuth(deps: CreateAuthDeps = {}) {
671
673
  /**
672
674
  * `magicLink` only when its predicate holds (#7435 § D2): absent, its
673
675
  * `/sign-in/magic-link` and `/magic-link/verify` endpoints do not exist.
674
- * Sign-in only — the callback mails existing users and nobody else, and
675
- * `disableSignUp` refuses to create one even for a live token
676
- * (lib/auth-email.ts).
676
+ * Sign-in only while sign-up is closed — the callback mails existing users
677
+ * and nobody else, and `disableSignUp` refuses to create one even for a
678
+ * live token (lib/auth-email.ts). While `isSignUpOpen()` a request
679
+ * carrying the agreements is a sign-up: its token is bound to them, and
680
+ * the verify creates the account only for a token so bound (#9667).
677
681
  *
678
682
  * `signUpAgreementsPlugin` always (#8765 D3): while sign-up is open,
679
683
  * `/sign-up/email` without every sign-up agreement is refused before the
@@ -4,6 +4,14 @@
4
4
  // English when no catalog exists (e.g. a locale is enabled in project.yaml
5
5
  // but its messages/<locale>.json has not been translated yet).
6
6
  //
7
+ // The site's own strings (#9401 D8) live in messages/site/<locale>.json, an
8
+ // overlay the template ships as `{}` and never changes, so `create-ailk
9
+ // update` keeps a site's edit of it. It falls back to the English overlay,
10
+ // then to nothing, and merges add-only: a key the template catalog already
11
+ // has keeps the template's value. The merge builds new objects, descends only
12
+ // into plain objects, takes only string leaves and skips prototype keys, so
13
+ // it can neither mutate the imported catalog nor pollute `Object.prototype`.
14
+ //
7
15
  // Spec: docs/internal/specs/i18n-phase1-s2-next-intl-foundation-spec.md
8
16
 
9
17
  import type { AbstractIntlMessages } from "next-intl";
@@ -11,6 +19,42 @@ import { getRequestConfig } from "next-intl/server";
11
19
 
12
20
  const DEFAULT_LOCALE = "en";
13
21
 
22
+ const UNSAFE_KEYS: ReadonlySet<string> = new Set([
23
+ "__proto__",
24
+ "constructor",
25
+ "prototype",
26
+ ]);
27
+
28
+ type Catalog = Record<string, unknown>;
29
+
30
+ function isCatalog(value: unknown): value is Catalog {
31
+ return typeof value === "object" && value !== null && !Array.isArray(value);
32
+ }
33
+
34
+ /** `base` with every key of `overlay` it lacks added — new objects, never a mutation. */
35
+ function mergeAddOnly(base: Catalog, overlay: Catalog): Catalog {
36
+ const out: Catalog = { ...base };
37
+ for (const [key, value] of Object.entries(overlay)) {
38
+ if (UNSAFE_KEYS.has(key)) continue;
39
+ const has = Object.prototype.hasOwnProperty.call(base, key);
40
+ if (typeof value === "string") {
41
+ if (!has) out[key] = value;
42
+ } else if (isCatalog(value)) {
43
+ if (!has) out[key] = mergeAddOnly({}, value);
44
+ else if (isCatalog(base[key])) out[key] = mergeAddOnly(base[key], value);
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ async function loadSiteOverlay(locale: string): Promise<Catalog> {
51
+ const mod =
52
+ (await import(`../messages/site/${locale}.json`).catch(() => null)) ??
53
+ (await import(`../messages/site/${DEFAULT_LOCALE}.json`).catch(() => null));
54
+ const overlay: unknown = mod ? (mod.default ?? mod) : null;
55
+ return isCatalog(overlay) ? overlay : {};
56
+ }
57
+
14
58
  export default getRequestConfig(async ({ requestLocale }) => {
15
59
  // requestLocale is the modern API (next-intl >= 3.22). It resolves the
16
60
  // active locale from the request, validated by middleware.
@@ -32,5 +76,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
32
76
  messages = (fallback.default ?? fallback) as AbstractIntlMessages;
33
77
  }
34
78
 
35
- return { locale, messages };
79
+ const overlay = await loadSiteOverlay(locale);
80
+ return {
81
+ locale,
82
+ messages: mergeAddOnly(messages, overlay) as AbstractIntlMessages,
83
+ };
36
84
  });
@@ -26,6 +26,8 @@
26
26
  * 401/403) is `false`, any other failure rejects. The popups are bound
27
27
  * in the route (`account/new-popups.tsx`, tested beside it): this file
28
28
  * imports nothing under `(authed)`.
29
+ * 8. #9401 AC-11 — the template's `site-contributions.ts` stub ships no
30
+ * Billing section either; a site fills it.
29
31
  */
30
32
 
31
33
  const mockSignupMe = jest.fn();
@@ -68,6 +70,7 @@ import {
68
70
  accountSettingsSections,
69
71
  type AccountNewRead,
70
72
  } from "../account-config";
73
+ import { siteBillingSections } from "../site-contributions";
71
74
 
72
75
  const INIT = { headers: { cookie: "session=abc" } };
73
76
 
@@ -166,6 +169,10 @@ describe("account-config exports (AC-6)", () => {
166
169
  expect(accountBillingSections).toEqual([]);
167
170
  });
168
171
 
172
+ it("ships no site Billing section either: the stub is the site's to fill (#9401 AC-11, D7)", () => {
173
+ expect(siteBillingSections).toEqual([]);
174
+ });
175
+
169
176
  it("ships the base kinds and the member kinds in their sections (#8231 D4–D6)", () => {
170
177
  expect(accountInboxKinds.map((k) => [k.kind, k.section])).toEqual([
171
178
  ["result", "updates"],