create-ailk 0.14.0 → 0.16.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 (184) hide show
  1. package/README.md +124 -0
  2. package/component-catalog.json +3 -3
  3. package/dist/add-module.d.ts +7 -0
  4. package/dist/add-module.js +80 -3
  5. package/dist/add-route.js +26 -2
  6. package/dist/adopt.d.ts +74 -0
  7. package/dist/adopt.js +314 -0
  8. package/dist/apply-design.d.ts +12 -1
  9. package/dist/apply-design.js +36 -4
  10. package/dist/cli.js +103 -3
  11. package/dist/derive-architecture.d.ts +37 -2
  12. package/dist/derive-architecture.js +92 -7
  13. package/dist/design-package.js +3 -2
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/lib/chrome-readers.d.ts +21 -0
  17. package/dist/lib/chrome-readers.js +100 -0
  18. package/dist/lib/declared-env.d.ts +142 -0
  19. package/dist/lib/declared-env.js +985 -0
  20. package/dist/lib/env-catalog.d.ts +17 -0
  21. package/dist/lib/env-catalog.js +234 -0
  22. package/dist/lib/extract-module-bundle.d.ts +20 -0
  23. package/dist/lib/extract-module-bundle.js +53 -6
  24. package/dist/lib/license-key-files.d.ts +6 -1
  25. package/dist/lib/license-key-files.js +9 -4
  26. package/dist/lib/module-tier.d.ts +2 -0
  27. package/dist/lib/module-tier.js +4 -0
  28. package/dist/module-architecture.d.ts +9 -0
  29. package/dist/module-architecture.js +82 -0
  30. package/dist/parse-args.d.ts +27 -0
  31. package/dist/parse-args.js +89 -0
  32. package/dist/programmatic.d.ts +28 -0
  33. package/dist/programmatic.js +181 -11
  34. package/dist/seed-flows.d.ts +65 -0
  35. package/dist/seed-flows.js +350 -0
  36. package/dist/site-chrome.d.ts +4 -0
  37. package/dist/site-chrome.js +34 -3
  38. package/dist/site-manifest.d.ts +55 -5
  39. package/dist/site-manifest.js +121 -12
  40. package/dist/site-roster.d.ts +13 -0
  41. package/dist/site-roster.js +22 -8
  42. package/dist/spoke-checkout.d.ts +42 -0
  43. package/dist/spoke-checkout.js +359 -0
  44. package/dist/surfaces.js +7 -0
  45. package/dist/sync-routes.js +29 -5
  46. package/dist/topology.d.ts +246 -0
  47. package/dist/topology.js +942 -0
  48. package/package.json +3 -3
  49. package/site-templates/designs/minimalism/DESIGN.md +3 -3
  50. package/templates/.env.example +10 -0
  51. package/templates/apps/api/CLAUDE.md +21 -4
  52. package/templates/apps/api/package.json +1 -0
  53. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  54. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
  55. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
  56. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
  57. package/templates/apps/api/src/bin/spokes.ts +62 -1
  58. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
  59. package/templates/apps/api/src/config/index.ts +35 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
  61. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  62. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
  63. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
  64. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  65. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
  66. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  67. package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
  68. package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
  69. package/templates/apps/api/src/lib/site.ts +37 -0
  70. package/templates/apps/api/src/lib/spoke-store.ts +41 -3
  71. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  72. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
  73. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  74. package/templates/apps/api/src/middleware/auth.ts +129 -0
  75. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  76. package/templates/apps/api/src/openapi/spec.ts +38 -2
  77. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
  78. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
  79. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  80. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  81. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
  82. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  83. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  84. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
  85. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  86. package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
  87. package/templates/apps/api/src/server.ts +105 -0
  88. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
  89. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
  90. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
  91. package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
  92. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  93. package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
  94. package/templates/apps/api/src/services/lead-routing.ts +278 -30
  95. package/templates/apps/api/src/services/person.ts +541 -0
  96. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  97. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  98. package/templates/apps/mcp/src/index.ts +6 -4
  99. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  100. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  101. package/templates/apps/web/.env.example +58 -5
  102. package/templates/apps/web/CLAUDE.md +1 -0
  103. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
  106. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
  107. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
  110. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
  111. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
  112. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
  113. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  114. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  115. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  116. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
  117. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
  118. package/templates/apps/web/app/[locale]/layout.tsx +83 -18
  119. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  120. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  121. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  122. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
  123. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
  124. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
  125. package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
  126. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
  127. package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
  128. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
  129. package/templates/apps/web/app/openapi.json/route.ts +72 -7
  130. package/templates/apps/web/auth.ts +29 -2
  131. package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
  132. package/templates/apps/web/components/signed-in-nav.tsx +17 -3
  133. package/templates/apps/web/instrumentation.ts +7 -4
  134. package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
  135. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  136. package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
  137. package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
  138. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
  139. package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
  140. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  141. package/templates/apps/web/lib/account-config.ts +130 -1
  142. package/templates/apps/web/lib/api-origin.generated.ts +11 -0
  143. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  144. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
  145. package/templates/apps/web/lib/hub-session/config.ts +104 -0
  146. package/templates/apps/web/lib/hub-session/index.ts +23 -0
  147. package/templates/apps/web/lib/hub-session/seal.ts +82 -0
  148. package/templates/apps/web/lib/hub-session/session.ts +180 -0
  149. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
  150. package/templates/apps/web/lib/listings-config.ts +31 -0
  151. package/templates/apps/web/lib/listings.tsx +7 -3
  152. package/templates/apps/web/lib/meeting-slugs.ts +11 -1
  153. package/templates/apps/web/lib/next-path.ts +35 -0
  154. package/templates/apps/web/lib/relay.ts +209 -3
  155. package/templates/apps/web/lib/server-api.ts +11 -1
  156. package/templates/apps/web/lib/site-brand.tsx +22 -1
  157. package/templates/apps/web/lib/site-chrome.ts +3 -0
  158. package/templates/apps/web/lib/topology.generated.ts +33 -0
  159. package/templates/apps/web/messages/en.json +32 -3
  160. package/templates/apps/web/package.json +1 -1
  161. package/templates/database/CHANGELOG.md +51 -0
  162. package/templates/database/__tests__/agents.db.test.ts +85 -0
  163. package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
  164. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  165. package/templates/database/__tests__/member-area.db.test.ts +245 -0
  166. package/templates/database/__tests__/person.db.test.ts +584 -0
  167. package/templates/database/auth/schema.prisma +24 -0
  168. package/templates/database/config/schema.prisma +13 -0
  169. package/templates/database/inbox/schema.prisma +111 -0
  170. package/templates/database/jest.config.cjs +7 -11
  171. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
  172. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
  173. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
  174. package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
  175. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
  176. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
  177. package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
  178. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
  179. package/templates/database/package.json +1 -1
  180. package/templates/database/scripts/person-backfill.sql +132 -0
  181. package/templates/database/tenancy.ts +53 -0
  182. package/templates/tools/check-declared-env.ts +37 -0
  183. package/templates/tools/declared-env.ts +1163 -0
  184. package/versions.json +35 -34
@@ -47,18 +47,18 @@ import { NextIntlClientProvider } from "next-intl";
47
47
  import { getMessages, getTranslations } from "next-intl/server";
48
48
  import type { ReactElement, ReactNode } from "react";
49
49
 
50
- import { auth } from "../../auth";
51
50
  import { GoogleAnalytics } from "../../components/google-analytics";
52
51
  import {
53
52
  SignedInNav,
54
53
  type SignedInNavProps,
55
54
  } from "../../components/signed-in-nav";
56
55
  import { isAccountPath, isAuthPath } from "../../lib/auth-routes";
56
+ import { isClosingFooterPath } from "../../lib/closing-footer-path";
57
57
  import { assertEnabledLocale } from "../../lib/locale-guard";
58
58
  import { footerColumns, navItems } from "../../lib/nav-config";
59
59
  import { getOgImageUrl } from "../../lib/og";
60
60
  import { backLinkHref } from "../../lib/profile-back-link";
61
- import { SITE_NAME } from "../../lib/site-brand";
61
+ import { COPYRIGHT_HOLDER, SITE_NAME } from "../../lib/site-brand";
62
62
  import { siteChrome, type SiteChrome } from "../../lib/site-chrome";
63
63
  import { siteLogo } from "../../lib/site-logo";
64
64
  // Registers the `_site.mdx` identity with page-renderer at module load, so
@@ -69,6 +69,12 @@ import "../../lib/site-identity";
69
69
  // (#7521). Inert without AILK_LISTING_PUBLIC_KEY or the paid package.
70
70
  import "../../lib/listings";
71
71
  import { siteThemeHead } from "../../lib/site-theme-head";
72
+ import {
73
+ SIGN_IN_PATH,
74
+ SIGN_OUT_ENDPOINT,
75
+ SIGN_UP_PATH,
76
+ readSession,
77
+ } from "../../lib/topology.generated";
72
78
  import { resolveBaseUrl } from "../../src/lib/routes";
73
79
 
74
80
  import { readSiteContactLines } from "./contact/site-contact-lines";
@@ -83,6 +89,7 @@ const chrome: SiteChrome & {
83
89
  deckPresentation?: true;
84
90
  colorMode?: "light";
85
91
  chromeStyle?: "minimal";
92
+ closingFooterPaths?: readonly string[];
86
93
  } = siteChrome;
87
94
 
88
95
  // ── Constants ────────────────────────────────────────────────────────────────
@@ -142,6 +149,15 @@ async function resolveThemeMode(): Promise<ThemeMode> {
142
149
  }
143
150
  }
144
151
 
152
+ /**
153
+ * The deck home's first slide (#7782): the element the consent bar waits on.
154
+ * The same section-slide selector `DeckSlideAdvance` walks (its `SLIDES`, minus
155
+ * the closing footer slide), so the first match is slide 1. The story that pins
156
+ * the geometry (`Shells/MarketingShell` → `Deck Consent Bar`) carries a copy.
157
+ */
158
+ const DECK_FIRST_SLIDE_SELECTOR =
159
+ '.ailk-snap-deck .ailk-section-renderer > :not(.ailk-section-separator):not([data-section="separator"])';
160
+
145
161
  /**
146
162
  * Whether this visitor has already accepted or declined cookies (#5557).
147
163
  *
@@ -250,8 +266,10 @@ export default async function LocaleLayout({
250
266
  const isMinimalChrome = chrome.chromeStyle === "minimal";
251
267
  const consentDecided = await hasConsentChoice();
252
268
  // Server-side session read (#4984) — the same `auth.api.getSession` idiom
253
- // the `(authed)` segment guard uses. Null when signed out or auth is unset.
254
- const session = await auth.api.getSession({ headers: await headers() });
269
+ // the `(authed)` segment guard uses, through the generated topology seam
270
+ // (#7426 D5): a spoke has no sign-in, so its render always answers null.
271
+ // Null when signed out or auth is unset.
272
+ const session = await readSession(await headers());
255
273
 
256
274
  // Resolve nav labels server-side so Nav + Footer components stay
257
275
  // i18n-hook-free and are testable without a NextIntlClientProvider wrapper.
@@ -348,11 +366,22 @@ export default async function LocaleLayout({
348
366
  // above).
349
367
  // The signed-out auth pair, shared by the header (every page but the deck
350
368
  // home) and the deck home's closing footer slide (#7718 D4).
351
- const authActions = [
352
- // NO_HAND_IMPLEMENT_OK: operator-directed live Free-template pass.
353
- { labelKey: "signIn", path: "/sign-in", variant: "outline-strong" },
354
- { labelKey: "signUp", path: "/sign-up", variant: "primary" },
355
- ] as const satisfies readonly NavAction[];
369
+ // The pair comes from the generated seam (#7894 D10): a hub's /sign-in and
370
+ // /sign-up; a spoke that signs in through its hub has Sign in → /account
371
+ // (its gate starts the hub sign-in) and no Sign up; a spoke without sign-in
372
+ // (#7426 D11) has neither, so it offers no auth pair anywhere — not in the
373
+ // header, the deck's closing footer or a bare page's footer.
374
+ const authActions: readonly Extract<NavAction, { path: string }>[] = [
375
+ ...(SIGN_IN_PATH !== null
376
+ ? [
377
+ // NO_HAND_IMPLEMENT_OK: operator-directed live Free-template pass.
378
+ { labelKey: "signIn", path: SIGN_IN_PATH, variant: "outline-strong" as const },
379
+ ]
380
+ : []),
381
+ ...(SIGN_UP_PATH !== null
382
+ ? [{ labelKey: "signUp", path: SIGN_UP_PATH, variant: "primary" as const }]
383
+ : []),
384
+ ];
356
385
  const navActions: NavAction[] = session
357
386
  ? [
358
387
  { labelKey: "account", kind: "flow", flow: NAV_CLIENT_ACTION_FLOW },
@@ -399,8 +428,12 @@ export default async function LocaleLayout({
399
428
  : session
400
429
  ? [{ label: tNav("account"), href: localeHref(locale, "/account") }]
401
430
  : [
402
- { label: tNav("signIn"), href: localeHref(locale, "/sign-in") },
403
- { label: tNav("signUp"), href: localeHref(locale, "/sign-up") },
431
+ ...(SIGN_IN_PATH !== null
432
+ ? [{ label: tNav("signIn"), href: localeHref(locale, SIGN_IN_PATH) }]
433
+ : []),
434
+ ...(SIGN_UP_PATH !== null
435
+ ? [{ label: tNav("signUp"), href: localeHref(locale, SIGN_UP_PATH) }]
436
+ : []),
404
437
  ];
405
438
 
406
439
  // A flow action needs `renderFlowTrigger`, a function — and this layout is a
@@ -434,6 +467,18 @@ export default async function LocaleLayout({
434
467
  // middleware and hreflang alternates read (NEXT_PUBLIC_ENABLED_LOCALES).
435
468
  const enabledLocales = readLocales();
436
469
 
470
+ // #7797 — an inner page the site lists in `closingFooterPaths` ends on the
471
+ // closing footer: the wordmark, the contact lines and the legal line. The
472
+ // home is never an inner page, and an absent or empty list is false for
473
+ // every page, so a site that lists none renders exactly as before.
474
+ const isClosingInner =
475
+ pathname !== homePath &&
476
+ isClosingFooterPath({
477
+ pathname,
478
+ paths: chrome.closingFooterPaths,
479
+ locales: enabledLocales,
480
+ });
481
+
437
482
  // The (auth) route group's chrome branch (#5963).
438
483
  //
439
484
  // A route group's layout nests INSIDE its parent segment's layout, so
@@ -519,7 +564,8 @@ export default async function LocaleLayout({
519
564
 
520
565
  // The site's own name (#6942) — `SITE_NAME` resolves from `_site.mdx`'s
521
566
  // `siteName:`, so a customer's footer never carries the kit vendor's name.
522
- const copyrightText = `© ${new Date().getFullYear()} ${SITE_NAME}. All rights reserved.`;
567
+ // The holder is `siteIdentity.legalName` when declared (#7796), else the name.
568
+ const copyrightText = `© ${new Date().getFullYear()} ${COPYRIGHT_HOLDER}. All rights reserved.`;
523
569
 
524
570
  // Footer legal links (#5612 D3). The centered variant folds in FAQ — the
525
571
  // roster's third always-scaffolded core route (`/faq`, alongside `/privacy`
@@ -716,7 +762,7 @@ export default async function LocaleLayout({
716
762
  }}
717
763
  />
718
764
  ) : hasFlowAction ? (
719
- <SignedInNav {...navProps} />
765
+ <SignedInNav {...navProps} signOutEndpoint={SIGN_OUT_ENDPOINT} />
720
766
  ) : (
721
767
  <Nav
722
768
  {...navProps}
@@ -740,18 +786,32 @@ export default async function LocaleLayout({
740
786
  // Sign up are on no part of the home.
741
787
  // #7721 D4 — a bare inner page's footer is the one bar, its
742
788
  // link group carrying the account links the header drops.
789
+ // #7797 — a listed inner page takes the same closing footer.
790
+ // Under `innerPages: bare` the one-bar footer is where the
791
+ // account links live (the header drops them), so the closing
792
+ // footer carries the deck's auth actions in their place; on
793
+ // any other inner page the header still holds them.
743
794
  {...(isDeckHome && isMinimalChrome
744
795
  ? { layout: "closing" as const, contactLines: readSiteContactLines() }
745
- : isBareInner
746
- ? { layout: "line" as const, accountLinks }
747
- : siteChrome.footer === "centered"
748
- ? { layout: "centered" as const }
749
- : {})}
796
+ : isClosingInner
797
+ ? {
798
+ layout: "closing" as const,
799
+ contactLines: readSiteContactLines(),
800
+ ...(isBareInner ? { actions: deckFooterActions } : {}),
801
+ }
802
+ : isBareInner
803
+ ? { layout: "line" as const, accountLinks }
804
+ : siteChrome.footer === "centered"
805
+ ? { layout: "centered" as const }
806
+ : {})}
750
807
  // #7718 D4 — the deck home's closing slide carries the
751
808
  // centred brand and the auth links its bare header drops.
752
809
  {...(isDeckHome && !isMinimalChrome
753
810
  ? { centeredBrand: true, actions: deckFooterActions }
754
811
  : {})}
812
+ // #7823 — the minimal closing slide's actions are a button,
813
+ // then a text link; Footer reads the style from this.
814
+ {...(isMinimalChrome ? { chromeStyle: "minimal" as const } : {})}
755
815
  // #7742 D2 — no theme switch under `colorMode: light`.
756
816
  showThemeToggle={!pinLight}
757
817
  bottomBarAlign="content"
@@ -816,6 +876,11 @@ export default async function LocaleLayout({
816
876
  // #7742 D5 — the minimal chrome's consent bar: the page ground,
817
877
  // a hairline, no shadow.
818
878
  {...(isMinimalChrome ? { consentAppearance: "hairline" as const } : {})}
879
+ // #7782 — on the minimal deck home the bar first shows once the
880
+ // first slide has left view; every other page shows it on load.
881
+ {...(isDeckHome && isMinimalChrome
882
+ ? { consentRevealAfter: DECK_FIRST_SLIDE_SELECTOR }
883
+ : {})}
819
884
  />
820
885
  )}
821
886
  <Telemetry />
@@ -11,6 +11,9 @@ import React from "react";
11
11
  import { render, screen } from "@testing-library/react";
12
12
 
13
13
  jest.mock("@working-theory/ui", () => ({
14
+ // #7789: renderPage's fallback page head renders inside the section Container.
15
+ Container: ({ children }: { children?: React.ReactNode }) =>
16
+ React.createElement("div", null, children),
14
17
  Heading: ({ children, level }: { children: React.ReactNode; level: number }) =>
15
18
  React.createElement(`h${level}`, null, children),
16
19
  SectionRenderer: ({
@@ -20,6 +20,9 @@ import { render } from "@testing-library/react";
20
20
 
21
21
  // ── @working-theory/ui mock ─────────────────────────────────────────────────────────────
22
22
  jest.mock("@working-theory/ui", () => ({
23
+ // #7789: renderPage's fallback page head renders inside the section Container.
24
+ Container: ({ children }: { children?: React.ReactNode }) =>
25
+ React.createElement("div", null, children),
23
26
  // #4446/#4447: renderPage's own fallback/empty-state title now composes
24
27
  // Heading (mobile type-budget classes, #4417) instead of a bare <h1>.
25
28
  Heading: ({
@@ -106,7 +109,7 @@ describe("ProjectsPage — snapshot regression", () => {
106
109
  });
107
110
  const { container } = render(element);
108
111
  expect(container.innerHTML).toMatchInlineSnapshot(
109
- `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Projects","description":"Open-source projects built with AI Launch Kit.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"CreativeWork"}]}}</script><main id="main"><h1>Projects</h1><p data-testid="projects-description">Open-source projects built with AI Launch Kit.</p><div data-testid="section-renderer" data-section-count="0"></div></main>"`,
112
+ `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Projects","description":"Open-source projects built with AI Launch Kit.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"CreativeWork"}]}}</script><main id="main"><div><h1>Projects</h1><p data-testid="projects-description" class="mt-block-tight text-lead">Open-source projects built with AI Launch Kit.</p></div><div data-testid="section-renderer" data-section-count="0"></div></main>"`,
110
113
  );
111
114
  });
112
115
  });
@@ -130,7 +133,7 @@ describe("ProjectSlugPage — snapshot regression", () => {
130
133
  });
131
134
  const { container } = render(element);
132
135
  expect(container.innerHTML).toMatchInlineSnapshot(
133
- `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AI Launch Kit","description":"The AX-first OSS template.","mainEntity":{"@context":"https://schema.org","@type":"CreativeWork","name":"AI Launch Kit","description":"The AX-first OSS template."}},{"@context":"https://schema.org","@type":"BreadcrumbList","inLanguage":"en","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"http://localhost:3000/en/"},{"@type":"ListItem","position":2,"name":"Projects","item":"http://localhost:3000/en/projects"},{"@type":"ListItem","position":3,"name":"AI Launch Kit","item":"http://localhost:3000/en/projects/ailk"}]}]}</script><main id="main"><h1>AI Launch Kit</h1><p data-testid="ailk-description">The AX-first OSS template.</p><div data-testid="section-renderer" data-section-count="0"></div></main>"`,
136
+ `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AI Launch Kit","description":"The AX-first OSS template.","mainEntity":{"@context":"https://schema.org","@type":"CreativeWork","name":"AI Launch Kit","description":"The AX-first OSS template."}},{"@context":"https://schema.org","@type":"BreadcrumbList","inLanguage":"en","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"http://localhost:3000/en/"},{"@type":"ListItem","position":2,"name":"Projects","item":"http://localhost:3000/en/projects"},{"@type":"ListItem","position":3,"name":"AI Launch Kit","item":"http://localhost:3000/en/projects/ailk"}]}]}</script><main id="main"><div><h1>AI Launch Kit</h1><p data-testid="ailk-description" class="mt-block-tight text-lead">The AX-first OSS template.</p></div><div data-testid="section-renderer" data-section-count="0"></div></main>"`,
134
137
  );
135
138
  });
136
139
 
@@ -17,6 +17,9 @@ jest.mock("next/navigation", () => ({
17
17
  }));
18
18
 
19
19
  jest.mock("@working-theory/ui", () => ({
20
+ // #7789: renderPage's fallback page head renders inside the section Container.
21
+ Container: ({ children }: { children?: React.ReactNode }) =>
22
+ React.createElement("div", null, children),
20
23
  // #4446/#4447: renderPage's own fallback/empty-state title now composes
21
24
  // Heading (mobile type-budget classes, #4417) instead of a bare <h1>.
22
25
  Heading: ({
@@ -0,0 +1,295 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ /**
6
+ * Tests for the hub's authorize step (#7894, spec
7
+ * docs/specs/7894-the-hub-signs-in-its-spec.md D4; AC-4, AC-6).
8
+ *
9
+ * The Better Auth session read and `@working-theory/api-client`'s `hubSignIn`
10
+ * are the two external boundaries; both are mocked. The route's own decisions
11
+ * are what is under test: which query it accepts, who it sends to sign in,
12
+ * where a minted code goes, and what it refuses to redirect to.
13
+ */
14
+
15
+ // --- Mocks set up BEFORE imports ---
16
+
17
+ const mockGetSession = jest.fn();
18
+ // eslint-disable-next-line no-restricted-syntax -- auth.ts is the Better Auth singleton (its adapter needs a live database); this stand-in for the session read is the repo-standard double for that boundary (see the sibling suites under app/[locale]/(authed)/account/__tests__).
19
+ jest.mock("../../../../../../auth", () => ({
20
+ __esModule: true,
21
+ auth: {
22
+ api: { getSession: (...args: unknown[]) => mockGetSession(...args) },
23
+ },
24
+ }));
25
+
26
+ const mockCreateCode = jest.fn();
27
+ jest.mock("@working-theory/api-client", () => ({
28
+ __esModule: true,
29
+ hubSignIn: { createCode: (...args: unknown[]) => mockCreateCode(...args) },
30
+ }));
31
+
32
+ import { resolveNextPath } from "../../../../../[locale]/(auth)/sign-in/next-path";
33
+ import { GET } from "../route";
34
+
35
+ const HUB = "https://hub.example.com";
36
+ const SPOKE_CALLBACK = "https://spoke.example.com/api/hub-session/callback";
37
+ const CHALLENGE = "c".repeat(43);
38
+ const STATE = "s".repeat(43);
39
+ const CODE = "k".repeat(43);
40
+
41
+ const ORIGINAL_ENV = process.env;
42
+
43
+ function query(overrides: Record<string, string | undefined> = {}): string {
44
+ const params = new URLSearchParams({
45
+ response_type: "code",
46
+ client_id: "spoke_abc",
47
+ redirect_uri: SPOKE_CALLBACK,
48
+ state: STATE,
49
+ code_challenge: CHALLENGE,
50
+ code_challenge_method: "S256",
51
+ });
52
+ for (const [key, value] of Object.entries(overrides)) {
53
+ if (value === undefined) params.delete(key);
54
+ else params.set(key, value);
55
+ }
56
+ return params.toString();
57
+ }
58
+
59
+ function request(
60
+ search: string,
61
+ headers: Record<string, string> = {},
62
+ ): Request {
63
+ return new Request(`${HUB}/api/auth/hub/authorize?${search}`, { headers });
64
+ }
65
+
66
+ beforeEach(() => {
67
+ jest.resetAllMocks();
68
+ process.env = { ...ORIGINAL_ENV, BETTER_AUTH_URL: `${HUB}/some/base` };
69
+ });
70
+
71
+ afterAll(() => {
72
+ process.env = ORIGINAL_ENV;
73
+ });
74
+
75
+ describe("a malformed query is a 400 and never a redirect", () => {
76
+ it.each([
77
+ ["no client_id", { client_id: undefined }],
78
+ ["response_type token", { response_type: "token" }],
79
+ ["method plain", { code_challenge_method: "plain" }],
80
+ ["a short code_challenge", { code_challenge: "short" }],
81
+ ["no state", { state: undefined }],
82
+ ["a state with a delimiter", { state: "a&b=c;d e" }],
83
+ ["no redirect_uri", { redirect_uri: undefined }],
84
+ ])("%s", async (_name, overrides) => {
85
+ mockGetSession.mockResolvedValue({ user: { id: "u1" } });
86
+
87
+ const res = await GET(request(query(overrides)));
88
+
89
+ expect(res.status).toBe(400);
90
+ expect(res.headers.get("location")).toBeNull();
91
+ expect(res.headers.get("content-type")).toBe("application/json");
92
+ expect(await res.json()).toEqual({ error: "invalid_request" });
93
+ expect(mockCreateCode).not.toHaveBeenCalled();
94
+ expect(mockGetSession).not.toHaveBeenCalled();
95
+ });
96
+
97
+ it("a repeated key is ambiguous, so 400", async () => {
98
+ const res = await GET(request(`${query()}&client_id=spoke_other`));
99
+ expect(res.status).toBe(400);
100
+ expect(res.headers.get("location")).toBeNull();
101
+ });
102
+ });
103
+
104
+ describe("no Better Auth session", () => {
105
+ it("AC-4: 303 to /sign-in?next= followed by the encoded authorize path and query", async () => {
106
+ mockGetSession.mockResolvedValue(null);
107
+ const search = query();
108
+
109
+ const res = await GET(request(search));
110
+
111
+ expect(res.status).toBe(303);
112
+ const location = res.headers.get("location") ?? "";
113
+ expect(location).toBe(
114
+ `/sign-in?next=${encodeURIComponent(`/api/auth/hub/authorize?${search}`)}`,
115
+ );
116
+ expect(mockCreateCode).not.toHaveBeenCalled();
117
+ });
118
+
119
+ it("AC-6: the next value is a same-origin path the sign-in card keeps (resolveNextPath)", async () => {
120
+ mockGetSession.mockResolvedValue(null);
121
+
122
+ const res = await GET(request(query()));
123
+
124
+ const next =
125
+ new URL(res.headers.get("location") ?? "", HUB).searchParams.get(
126
+ "next",
127
+ ) ?? "";
128
+ expect(next.startsWith("/api/auth/hub/authorize?")).toBe(true);
129
+ // Unchanged — not the fallback — so Better Auth is handed it as the callback
130
+ // for Google and for the emailed link alike.
131
+ expect(resolveNextPath(next, "/account")).toBe(next);
132
+ expect(next).not.toMatch(/^https?:/);
133
+ });
134
+ });
135
+
136
+ describe("a signed-in person", () => {
137
+ beforeEach(() => {
138
+ mockGetSession.mockResolvedValue({ user: { id: "u1" } });
139
+ });
140
+
141
+ it("AC-4: 303 to the REGISTERED redirect URI with the code and the same state", async () => {
142
+ mockCreateCode.mockResolvedValue({
143
+ ok: true,
144
+ body: { code: CODE, redirectUri: SPOKE_CALLBACK },
145
+ });
146
+
147
+ const res = await GET(
148
+ request(query(), { cookie: "better-auth.session_token=tok" }),
149
+ );
150
+
151
+ expect(res.status).toBe(303);
152
+ const target = new URL(res.headers.get("location") ?? "");
153
+ expect(`${target.origin}${target.pathname}`).toBe(SPOKE_CALLBACK);
154
+ expect(target.searchParams.get("code")).toBe(CODE);
155
+ expect(target.searchParams.get("state")).toBe(STATE);
156
+ expect(res.headers.get("cache-control")).toBe("no-store");
157
+ expect(res.headers.get("referrer-policy")).toBe("no-referrer");
158
+ });
159
+
160
+ it("forwards the visitor's cookie and the hub's pinned origin to the API — nothing else", async () => {
161
+ mockCreateCode.mockResolvedValue({
162
+ ok: true,
163
+ body: { code: CODE, redirectUri: SPOKE_CALLBACK },
164
+ });
165
+
166
+ await GET(
167
+ request(query(), {
168
+ cookie: "better-auth.session_token=tok",
169
+ origin: "https://evil.example",
170
+ }),
171
+ );
172
+
173
+ expect(mockCreateCode).toHaveBeenCalledWith(
174
+ {
175
+ clientId: "spoke_abc",
176
+ redirectUri: SPOKE_CALLBACK,
177
+ codeChallenge: CHALLENGE,
178
+ },
179
+ { headers: { cookie: "better-auth.session_token=tok", origin: HUB } },
180
+ );
181
+ });
182
+
183
+ it("AC-4: a redirect_uri the API does not register is a 400 with NO Location header", async () => {
184
+ mockCreateCode.mockResolvedValue({
185
+ ok: false,
186
+ status: 400,
187
+ body: {
188
+ ok: false,
189
+ error: { code: "invalid_redirect_uri", message: "x" },
190
+ },
191
+ });
192
+
193
+ const res = await GET(
194
+ request(
195
+ query({
196
+ redirect_uri: "https://attacker.example.com/api/hub-session/callback",
197
+ }),
198
+ ),
199
+ );
200
+
201
+ expect(res.status).toBe(400);
202
+ expect(res.headers.get("location")).toBeNull();
203
+ expect(await res.json()).toEqual({ error: "invalid_request" });
204
+ });
205
+
206
+ it("redirects to the URI the API echoed, never to the query's own", async () => {
207
+ // A (hypothetical) API answer for a different URI than the query named.
208
+ mockCreateCode.mockResolvedValue({
209
+ ok: true,
210
+ body: { code: CODE, redirectUri: SPOKE_CALLBACK },
211
+ });
212
+
213
+ const res = await GET(
214
+ request(
215
+ query({
216
+ redirect_uri: "https://attacker.example.com/api/hub-session/callback",
217
+ }),
218
+ ),
219
+ );
220
+
221
+ expect(new URL(res.headers.get("location") ?? "").origin).toBe(
222
+ "https://spoke.example.com",
223
+ );
224
+ });
225
+
226
+ it("answers 503, not a redirect, when the API echoes a URI that does not parse", async () => {
227
+ mockCreateCode.mockResolvedValue({
228
+ ok: true,
229
+ body: { code: CODE, redirectUri: "not a url" },
230
+ });
231
+
232
+ const res = await GET(request(query()));
233
+
234
+ expect(res.status).toBe(503);
235
+ expect(res.headers.get("location")).toBeNull();
236
+ });
237
+
238
+ it("AC-6: an unverified email goes to the hub's own /account/settings, never the spoke", async () => {
239
+ mockCreateCode.mockResolvedValue({
240
+ ok: false,
241
+ status: 403,
242
+ body: { ok: false, error: { code: "email_unverified", message: "x" } },
243
+ });
244
+
245
+ const res = await GET(request(query()));
246
+
247
+ expect(res.status).toBe(303);
248
+ expect(res.headers.get("location")).toBe("/account/settings");
249
+ expect(res.headers.get("location")).not.toContain("spoke.example.com");
250
+ });
251
+
252
+ it.each([
253
+ ["an unknown client", 400, "invalid_client"],
254
+ ["a rate limit", 429, "rate_limited"],
255
+ ["a forbidden caller", 403, "forbidden"],
256
+ ["an expired session", 401, "unauthorized"],
257
+ ])("%s is a 400, never a redirect", async (_name, status, code) => {
258
+ mockCreateCode.mockResolvedValue({
259
+ ok: false,
260
+ status,
261
+ body: { ok: false, error: { code, message: "x" } },
262
+ });
263
+
264
+ const res = await GET(request(query()));
265
+
266
+ expect(res.status).toBe(400);
267
+ expect(res.headers.get("location")).toBeNull();
268
+ });
269
+
270
+ it("answers 503 — not a redirect — when the API cannot be reached", async () => {
271
+ mockCreateCode.mockRejectedValue(new Error("network"));
272
+
273
+ const res = await GET(request(query()));
274
+
275
+ expect(res.status).toBe(503);
276
+ expect(res.headers.get("location")).toBeNull();
277
+ expect(await res.json()).toEqual({ error: "temporarily_unavailable" });
278
+ });
279
+
280
+ it.each([
281
+ ["unset", undefined],
282
+ ["not a URL", "not a url"],
283
+ ])(
284
+ "answers 503 when BETTER_AUTH_URL is %s (no pinned origin to send)",
285
+ async (_name, value) => {
286
+ if (value === undefined) delete process.env.BETTER_AUTH_URL;
287
+ else process.env.BETTER_AUTH_URL = value;
288
+
289
+ const res = await GET(request(query()));
290
+
291
+ expect(res.status).toBe(503);
292
+ expect(mockCreateCode).not.toHaveBeenCalled();
293
+ },
294
+ );
295
+ });