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.
- package/README.md +124 -0
- package/component-catalog.json +3 -3
- package/dist/add-module.d.ts +7 -0
- package/dist/add-module.js +80 -3
- package/dist/add-route.js +26 -2
- package/dist/adopt.d.ts +74 -0
- package/dist/adopt.js +314 -0
- package/dist/apply-design.d.ts +12 -1
- package/dist/apply-design.js +36 -4
- package/dist/cli.js +103 -3
- package/dist/derive-architecture.d.ts +37 -2
- package/dist/derive-architecture.js +92 -7
- package/dist/design-package.js +3 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/chrome-readers.d.ts +21 -0
- package/dist/lib/chrome-readers.js +100 -0
- package/dist/lib/declared-env.d.ts +142 -0
- package/dist/lib/declared-env.js +985 -0
- package/dist/lib/env-catalog.d.ts +17 -0
- package/dist/lib/env-catalog.js +234 -0
- package/dist/lib/extract-module-bundle.d.ts +20 -0
- package/dist/lib/extract-module-bundle.js +53 -6
- package/dist/lib/license-key-files.d.ts +6 -1
- package/dist/lib/license-key-files.js +9 -4
- package/dist/lib/module-tier.d.ts +2 -0
- package/dist/lib/module-tier.js +4 -0
- package/dist/module-architecture.d.ts +9 -0
- package/dist/module-architecture.js +82 -0
- package/dist/parse-args.d.ts +27 -0
- package/dist/parse-args.js +89 -0
- package/dist/programmatic.d.ts +28 -0
- package/dist/programmatic.js +181 -11
- package/dist/seed-flows.d.ts +65 -0
- package/dist/seed-flows.js +350 -0
- package/dist/site-chrome.d.ts +4 -0
- package/dist/site-chrome.js +34 -3
- package/dist/site-manifest.d.ts +55 -5
- package/dist/site-manifest.js +121 -12
- package/dist/site-roster.d.ts +13 -0
- package/dist/site-roster.js +22 -8
- package/dist/spoke-checkout.d.ts +42 -0
- package/dist/spoke-checkout.js +359 -0
- package/dist/surfaces.js +7 -0
- package/dist/sync-routes.js +29 -5
- package/dist/topology.d.ts +246 -0
- package/dist/topology.js +942 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +3 -3
- package/templates/.env.example +10 -0
- package/templates/apps/api/CLAUDE.md +21 -4
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
- package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
- package/templates/apps/api/src/bin/spokes.ts +62 -1
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
- package/templates/apps/api/src/config/index.ts +35 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
- package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
- package/templates/apps/api/src/lib/agent-store.ts +95 -3
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
- package/templates/apps/api/src/lib/site.ts +37 -0
- package/templates/apps/api/src/lib/spoke-store.ts +41 -3
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
- package/templates/apps/api/src/middleware/auth.ts +129 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
- package/templates/apps/api/src/openapi/spec.ts +38 -2
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
- package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
- package/templates/apps/api/src/routes/leads/index.ts +69 -6
- package/templates/apps/api/src/routes/webhooks/README.md +39 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
- package/templates/apps/api/src/server.ts +105 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
- package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
- package/templates/apps/api/src/services/default-tenant.ts +41 -7
- package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
- package/templates/apps/api/src/services/lead-routing.ts +278 -30
- package/templates/apps/api/src/services/person.ts +541 -0
- package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
- package/templates/apps/mcp/__tests__/index.test.ts +5 -5
- package/templates/apps/mcp/src/index.ts +6 -4
- package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
- package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
- package/templates/apps/web/.env.example +58 -5
- package/templates/apps/web/CLAUDE.md +1 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
- package/templates/apps/web/app/[locale]/layout.tsx +83 -18
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
- package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
- package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
- package/templates/apps/web/app/openapi.json/route.ts +72 -7
- package/templates/apps/web/auth.ts +29 -2
- package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
- package/templates/apps/web/components/signed-in-nav.tsx +17 -3
- package/templates/apps/web/instrumentation.ts +7 -4
- package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
- package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
- package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
- package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
- package/templates/apps/web/lib/account-config.ts +130 -1
- package/templates/apps/web/lib/api-origin.generated.ts +11 -0
- package/templates/apps/web/lib/closing-footer-path.ts +50 -0
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
- package/templates/apps/web/lib/hub-session/config.ts +104 -0
- package/templates/apps/web/lib/hub-session/index.ts +23 -0
- package/templates/apps/web/lib/hub-session/seal.ts +82 -0
- package/templates/apps/web/lib/hub-session/session.ts +180 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
- package/templates/apps/web/lib/listings-config.ts +31 -0
- package/templates/apps/web/lib/listings.tsx +7 -3
- package/templates/apps/web/lib/meeting-slugs.ts +11 -1
- package/templates/apps/web/lib/next-path.ts +35 -0
- package/templates/apps/web/lib/relay.ts +209 -3
- package/templates/apps/web/lib/server-api.ts +11 -1
- package/templates/apps/web/lib/site-brand.tsx +22 -1
- package/templates/apps/web/lib/site-chrome.ts +3 -0
- package/templates/apps/web/lib/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +32 -3
- package/templates/apps/web/package.json +1 -1
- package/templates/database/CHANGELOG.md +51 -0
- package/templates/database/__tests__/agents.db.test.ts +85 -0
- package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
- package/templates/database/__tests__/member-area.db.test.ts +245 -0
- package/templates/database/__tests__/person.db.test.ts +584 -0
- package/templates/database/auth/schema.prisma +24 -0
- package/templates/database/config/schema.prisma +13 -0
- package/templates/database/inbox/schema.prisma +111 -0
- package/templates/database/jest.config.cjs +7 -11
- package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
- package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
- package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
- package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
- package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
- package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
- package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
- package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/person-backfill.sql +132 -0
- package/templates/database/tenancy.ts +53 -0
- package/templates/tools/check-declared-env.ts +37 -0
- package/templates/tools/declared-env.ts +1163 -0
- 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
|
|
254
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
403
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
746
|
-
? {
|
|
747
|
-
|
|
748
|
-
|
|
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
|
+
});
|