create-ailk 0.11.0 → 0.11.2

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 (69) hide show
  1. package/dist/content-payloads.js +2 -2
  2. package/dist/lib/scaffold-render-sweep.d.ts +16 -0
  3. package/dist/lib/scaffold-render-sweep.js +52 -3
  4. package/dist/module-architecture.js +11 -0
  5. package/package.json +1 -1
  6. package/site-templates/shapes/local-business-service/content/en/Service/first-job.mdx +8 -8
  7. package/site-templates/shapes/local-business-service/content/en/Service/second-job.mdx +8 -8
  8. package/site-templates/shapes/local-business-service/content/en/Service/third-job.mdx +8 -8
  9. package/site-templates/shapes/organization-service/content/en/Service/first-engagement.mdx +8 -8
  10. package/site-templates/shapes/organization-service/content/en/Service/second-engagement.mdx +8 -8
  11. package/site-templates/shapes/organization-service/content/en/Service/third-engagement.mdx +8 -8
  12. package/site-templates/shapes/person-service/content/en/Service/first-engagement.mdx +8 -8
  13. package/site-templates/shapes/person-service/content/en/Service/second-engagement.mdx +8 -8
  14. package/site-templates/shapes/person-service/content/en/Service/third-engagement.mdx +8 -8
  15. package/templates/.env.example +13 -7
  16. package/templates/apps/api/.env.example +5 -0
  17. package/templates/apps/api/package.json +1 -1
  18. package/templates/apps/api/src/__tests__/cors.test.ts +91 -10
  19. package/templates/apps/api/src/__tests__/server.test.ts +5 -1
  20. package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +135 -0
  21. package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +238 -0
  22. package/templates/apps/api/src/config/index.ts +136 -0
  23. package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +1 -1
  24. package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +1 -1
  25. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +1 -1
  26. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +2 -2
  27. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +142 -0
  28. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +1 -1
  29. package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +1 -1
  30. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +1 -1
  31. package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +1 -1
  32. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +1 -1
  33. package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +1 -1
  34. package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +1 -1
  35. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +1 -1
  36. package/templates/apps/api/src/middleware/auth.ts +19 -21
  37. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +1 -1
  38. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +1 -1
  39. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +168 -39
  40. package/templates/apps/api/src/routes/checkout/sessions.ts +20 -34
  41. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +1 -1
  42. package/templates/apps/api/src/server.ts +21 -16
  43. package/templates/apps/mcp/package.json +1 -1
  44. package/templates/apps/web/CLAUDE.md +1 -1
  45. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +0 -10
  46. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +0 -10
  47. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +1 -14
  48. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +0 -10
  49. package/templates/apps/web/app/[locale]/features/[slug]/page.tsx +0 -10
  50. package/templates/apps/web/app/[locale]/for/[slug]/page.tsx +0 -10
  51. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +1 -14
  52. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +0 -10
  53. package/templates/apps/web/app/[locale]/layout.tsx +57 -56
  54. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +0 -10
  55. package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +0 -10
  56. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +0 -10
  57. package/templates/apps/web/app/[locale]/projects/[slug]/page.tsx +1 -15
  58. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +0 -10
  59. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +0 -10
  60. package/templates/apps/web/app/[locale]/software/[slug]/page.tsx +0 -10
  61. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +1 -14
  62. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +4 -16
  63. package/templates/apps/web/app/[locale]/vs/[slug]/__tests__/page.test.tsx +3 -15
  64. package/templates/apps/web/app/[locale]/vs/[slug]/page.tsx +0 -10
  65. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +1 -14
  66. package/templates/apps/web/auth.ts +73 -13
  67. package/templates/apps/web/components/sign-out-trigger.tsx +3 -2
  68. package/templates/apps/web/components/signed-in-nav.tsx +58 -0
  69. package/versions.json +0 -1
@@ -28,13 +28,14 @@
28
28
  * (backward-compat for OSS standalone deployments). See #935.
29
29
  * - Same-origin redirects (#6877): a request that did not pass an ENABLED
30
30
  * internal-auth gate (the OSS default) must send successUrl and cancelUrl
31
- * on the site's own origin — `FRONTEND_URL`'s, exact scheme + host + port,
32
- * checked on the raw string as well as on the parse, because the raw
33
- * string is what Stripe receives. Anything else is a 400 before any Stripe
34
- * call or database read. A caller authenticated by X-Internal-Auth keeps
35
- * its redirects; that exemption holds only while every internal caller
36
- * builds its redirects itself — an internal caller MUST NEVER forward a
37
- * redirect taken from its own end user.
31
+ * each on one of the trusted browser origins — `config.TRUSTED_ORIGINS`
32
+ * (#7395: FRONTEND_URL's origin plus AILK_TRUSTED_ORIGINS), exact scheme +
33
+ * host + port, checked on the raw string as well as on the parse, because
34
+ * the raw string is what Stripe receives. Anything else is a 400 before
35
+ * any Stripe call or database read. A caller authenticated by
36
+ * X-Internal-Auth keeps its redirects; that exemption holds only while
37
+ * every internal caller builds its redirects itself — an internal caller
38
+ * MUST NEVER forward a redirect taken from its own end user.
38
39
  *
39
40
  * Error mapping:
40
41
  * 401 internal-auth-required — X-Internal-Auth missing/mismatch (only when env var set)
@@ -44,8 +45,8 @@
44
45
  * 500 stripe-not-configured — STRIPE_SECRET_KEY is missing/empty (no Stripe call)
45
46
  * 500 live-mode-forbidden — STRIPE_SECRET_KEY is a live key
46
47
  * 500 config-error — STRIPE_SECRET_KEY invalid configuration (from the SDK wrapper),
47
- * or FRONTEND_URL is not an absolute http(s) URL / is unset in
48
- * production when the origin lock applies (#6877)
48
+ * or no trusted browser origin is configured when the origin
49
+ * lock applies (#6877, #7395)
49
50
  * 502 checkout-unavailable — Stripe SDK threw an unexpected error
50
51
  */
51
52
  import { timingSafeEqual } from "node:crypto";
@@ -154,26 +155,6 @@ function internalAuthVerdict(
154
155
 
155
156
  // ── Same-origin redirect lock (#6877) ────────────────────────────────────────
156
157
 
157
- /**
158
- * The site's own origin, from `FRONTEND_URL` (#6877 D6). `null` when the
159
- * setting does not parse, parses to a non-http(s) scheme (whose origin would
160
- * be the literal `"null"`), or is unset in production — where the config
161
- * default `http://localhost:3000` would lock a live site to localhost.
162
- */
163
- function siteRedirectOrigin(): string | null {
164
- if (!process.env["FRONTEND_URL"] && config.NODE_ENV === "production") {
165
- return null;
166
- }
167
- let parsed: URL;
168
- try {
169
- parsed = new URL(config.FRONTEND_URL);
170
- } catch {
171
- return null;
172
- }
173
- if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
174
- return parsed.origin;
175
- }
176
-
177
158
  /** C0 controls, space, DEL and backslash — anything a parser may normalize. */
178
159
  // eslint-disable-next-line no-control-regex -- the control-character range is exactly what D2 check 1 must refuse.
179
160
  const NON_CANONICAL_URL_CHAR = /[\u0000-\u0020\u007f\\]/;
@@ -225,7 +206,7 @@ async function resolveBuyer(
225
206
  request: FastifyRequest,
226
207
  ): Promise<{ buyer?: Buyer; identity?: IdentityReason }> {
227
208
  const cookieUser = await resolveOptionalCookieUser(request, {
228
- frontendOrigin: config.FRONTEND_URL,
209
+ trustedOrigins: config.TRUSTED_ORIGINS,
229
210
  });
230
211
  if (cookieUser.kind === "none") {
231
212
  return cookieUser.reason === "no_cookie"
@@ -279,16 +260,21 @@ export async function checkoutSessionsHandler(
279
260
 
280
261
  // ── Same-origin redirect lock (#6877) — before any Stripe or DB call ──────
281
262
  if (authVerdict === "open") {
282
- const siteOrigin = siteRedirectOrigin();
283
- if (siteOrigin === null) {
263
+ // An empty list is FRONTEND_URL unset in production or unusable, with no
264
+ // AILK_TRUSTED_ORIGINS (#7395 D4) — refused before any Stripe or DB call.
265
+ const trustedOrigins = config.TRUSTED_ORIGINS;
266
+ if (trustedOrigins.length === 0) {
284
267
  request.log.error(
285
268
  { method: request.method, path: request.url },
286
- "[checkout] FRONTEND_URL is not a valid absolute URL",
269
+ "[checkout] no trusted browser origin is configured",
287
270
  );
288
271
  return reply.status(500).send({ error: "config-error" });
289
272
  }
290
273
  const refused = (["successUrl", "cancelUrl"] as const).filter(
291
- (field) => !isSameOriginRedirect(parsed.data[field], siteOrigin),
274
+ (field) =>
275
+ !trustedOrigins.some((origin) =>
276
+ isSameOriginRedirect(parsed.data[field], origin),
277
+ ),
292
278
  );
293
279
  if (refused.length > 0) {
294
280
  // Field names only — the refused URL is never logged or echoed.
@@ -267,7 +267,7 @@ function installFixtureMocks(): void {
267
267
  async function buildApp(): Promise<FastifyInstance> {
268
268
  const app = Fastify();
269
269
  await app.register(async (authScope) => {
270
- await authScope.register(authPreHandlerPlugin, { frontendOrigin: FRONTEND });
270
+ await authScope.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
271
271
  await authScope.register(projectListingMeRoute);
272
272
  });
273
273
  await app.ready();
@@ -512,18 +512,19 @@ async function assertCommerceSandboxKey(key: string): Promise<void> {
512
512
  * #6889 (SEC-02 of the #6876 security review) — make the checkout route's
513
513
  * posture observable. POST /v1/checkout/sessions admits a caller without
514
514
  * X-Internal-Auth when OSS_API_INTERNAL_SECRET is unset. With FRONTEND_URL
515
- * unset, the route's same-origin redirect lock (#6877) answers 500
516
- * config-error to that caller, and the CORS and cookie-CSRF origin checks pin
517
- * to the `http://localhost:3000` config default. Both stay optional (OSS
518
- * standalone compat), so this only ever WARNS — once per unset variable, and
519
- * only in production. FRONTEND_URL is read from the raw environment because
520
- * `config.FRONTEND_URL` carries that localhost default.
515
+ * unset in production, the trusted-origin list (#7395) holds only
516
+ * AILK_TRUSTED_ORIGINS — with that unset too, CORS and cookie writes refuse
517
+ * every browser origin and the redirect lock (#6877) answers 500
518
+ * config-error to that caller. Both stay optional (OSS standalone compat), so
519
+ * this only ever WARNS — once per unset variable, and only in production.
520
+ * FRONTEND_URL is read from the raw environment because `config.FRONTEND_URL`
521
+ * carries a localhost default.
521
522
  */
522
523
  function warnOnUnsetProductionProtections(): void {
523
524
  if (config.NODE_ENV !== "production") return;
524
525
  if (!process.env["FRONTEND_URL"]) {
525
526
  logger.warn(
526
- "[startup] FRONTEND_URL not set in production — CORS/cookie origin checks are pinned to http://localhost:3000, and checkout answers 500 config-error to any caller admitted without X-Internal-Auth",
527
+ "[startup] FRONTEND_URL not set in production — CORS, cookie-authenticated writes and checkout redirects trust only AILK_TRUSTED_ORIGINS; with neither set, all three refuse every browser origin and checkout answers 500 config-error to any caller admitted without X-Internal-Auth",
527
528
  );
528
529
  }
529
530
  if (!process.env["OSS_API_INTERNAL_SECRET"]) {
@@ -645,12 +646,16 @@ export async function build() {
645
646
  // never reach the client. Full detail still goes to pino at error level.
646
647
  registerErrorSerializer(server);
647
648
 
648
- // Register CORS — public read API + same-origin auth callers.
649
- // With FRONTEND_URL unset: reflect any origin in dev/test (zero-config
650
- // boot) but fail CLOSED in production — `origin: true` combined with
651
- // `credentials: true` would let any website read credentialed responses
652
- // (security review M1). `false` disables cross-origin access entirely;
653
- // same-origin and non-browser callers are unaffected.
649
+ // Register CORS — public read API + the trusted browser origins (#7395).
650
+ // `origin` is ALWAYS a copy of `config.TRUSTED_ORIGINS`, the frozen list
651
+ // config/index.ts resolves once at startup from FRONTEND_URL and
652
+ // AILK_TRUSTED_ORIGINS (no `*`, no wildcard host). @fastify/cors echoes a
653
+ // request's Origin only on an exact match against that array, and writes no
654
+ // allow-origin header otherwise; an empty list means no cross-origin caller.
655
+ // Never pass `true`, a function, a RegExp or a delegator here, and never set
656
+ // a per-route `cors` option: with `credentials: true`, reflecting any origin
657
+ // would let any website read credentialed responses (security review M1).
658
+ // Same-origin and non-browser callers are unaffected.
654
659
  //
655
660
  // `methods` is EXPLICIT because @fastify/cors defaults it to `GET,HEAD,POST`
656
661
  // (its own `defaultOptions`), which is narrower than the surface this server
@@ -666,7 +671,7 @@ export async function build() {
666
671
  // live route table on every run, so a route added in a NEW method fails there
667
672
  // rather than silently becoming unreachable from a browser.
668
673
  //
669
- // This widens WHICH METHODS the allowed origin may use, never WHO is allowed:
674
+ // This widens WHICH METHODS the allowed origins may use, never WHO is allowed:
670
675
  // `origin` and `credentials` are untouched.
671
676
  //
672
677
  // `allowedHeaders` is deliberately left unset: null means @fastify/cors
@@ -674,7 +679,7 @@ export async function build() {
674
679
  // and `content-type` pass without this file having to maintain a list that
675
680
  // silently drops a header a future route needs.
676
681
  await server.register(cors, {
677
- origin: config.FRONTEND_URL || config.NODE_ENV !== "production",
682
+ origin: [...config.TRUSTED_ORIGINS],
678
683
  credentials: true,
679
684
  methods: ["GET", "HEAD", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
680
685
  });
@@ -726,7 +731,7 @@ export async function build() {
726
731
  // handlers. Cookie-first, Bearer-fallback; see middleware/auth.ts.
727
732
  await server.register(async (authScope) => {
728
733
  await authScope.register(authPreHandlerPlugin, {
729
- frontendOrigin: config.FRONTEND_URL,
734
+ trustedOrigins: config.TRUSTED_ORIGINS,
730
735
  });
731
736
  await authScope.register(tokenRoutes);
732
737
  await authScope.register(userRoutes);
@@ -28,7 +28,7 @@
28
28
  "@working-theory/metadata": "workspace:*",
29
29
  "@working-theory/schema": "workspace:*",
30
30
  "@working-theory/validation": "workspace:*",
31
- "fastify": "^5.12.1",
31
+ "fastify": "^5.12.5",
32
32
  "yaml": "^2.9.0",
33
33
  "zod": "^4.2.1"
34
34
  },
@@ -14,7 +14,7 @@ Inherits root `CLAUDE.md`. This file adds web-specific rules.
14
14
 
15
15
  ## Always do first
16
16
 
17
- - For any UI work: invoke the `frontend-design` skill before writing code.
17
+ - For any UI work: read § Theme & styling and § Iterate visually below before writing code. To add a whole page, run the `/ailk:add-page` skill.
18
18
  - For any page-rendering or schema-emission work: read `packages/schema/README.md` (catalog) and `packages/schema/CLAUDE.md` (operational layer + component map) if you haven't this session.
19
19
  - For content authoring questions: read `content/CLAUDE.md` first.
20
20
  - For a full page build, follow the per-page loop (Content intent → Layout → Author content → Visual iterate). The visual-iterate step uses `screenshots/` as scratch (see § Iterate visually).
@@ -3,7 +3,6 @@
3
3
  // Careers list at /careers links here; each job posting lives at /careers/<slug>.
4
4
  import {
5
5
  getMdxSource as defaultGetMdxSource,
6
- getMdxSlugs,
7
6
  renderPage,
8
7
  renderPageMetadata,
9
8
  } from "@working-theory/page-renderer";
@@ -23,15 +22,6 @@ export async function generateMetadata({
23
22
  return renderPageMetadata({ type: "JobPosting", slug, locale });
24
23
  }
25
24
 
26
- export async function generateStaticParams({
27
- params,
28
- }: {
29
- params: { locale: string };
30
- }): Promise<{ slug: string }[]> {
31
- const slugs = await getMdxSlugs("JobPosting", params.locale);
32
- return slugs.map((slug: string) => ({ slug }));
33
- }
34
-
35
25
  export default async function Page({
36
26
  params,
37
27
  getMdxSource = defaultGetMdxSource,
@@ -2,7 +2,6 @@
2
2
  // next-mdx-remote/rsc, and emits a JSON-LD <script>. Unknown slugs trigger 404.
3
3
  import {
4
4
  getMdxSource as defaultGetMdxSource,
5
- getMdxSlugs,
6
5
  renderPage,
7
6
  renderPageMetadata,
8
7
  } from "@working-theory/page-renderer";
@@ -22,15 +21,6 @@ export async function generateMetadata({
22
21
  return renderPageMetadata({ type: "CaseStudy", slug, locale });
23
22
  }
24
23
 
25
- export async function generateStaticParams({
26
- params,
27
- }: {
28
- params: { locale: string };
29
- }): Promise<{ slug: string }[]> {
30
- const slugs = await getMdxSlugs("CaseStudy", params.locale);
31
- return slugs.map((slug: string) => ({ slug }));
32
- }
33
-
34
24
  export default async function Page({
35
25
  params,
36
26
  getMdxSource = defaultGetMdxSource,
@@ -1,11 +1,7 @@
1
1
  // Course detail page. Reads the Course MDX for the given slug, renders via
2
2
  // @working-theory/page-renderer, and emits a JSON-LD <script>. Unknown slugs trigger 404.
3
3
  import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
4
- import {
5
- getMdxSlugs,
6
- renderPage,
7
- renderPageMetadata,
8
- } from "@working-theory/page-renderer";
4
+ import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
9
5
  import type { Metadata } from "next";
10
6
  import type { ReactElement } from "react";
11
7
 
@@ -21,15 +17,6 @@ export async function generateMetadata({
21
17
  return renderPageMetadata({ type: "Course", slug, locale });
22
18
  }
23
19
 
24
- export async function generateStaticParams({
25
- params,
26
- }: {
27
- params: { locale: string };
28
- }): Promise<{ slug: string }[]> {
29
- const slugs = await getMdxSlugs("Course", params.locale);
30
- return slugs.map((slug) => ({ slug }));
31
- }
32
-
33
20
  export default async function Page({
34
21
  params,
35
22
  getMdxSource,
@@ -4,7 +4,6 @@ import {
4
4
  renderPage,
5
5
  renderPageMetadata,
6
6
  getMdxSource as defaultGetMdxSource,
7
- getMdxSlugs,
8
7
  } from "@working-theory/page-renderer";
9
8
  import type { Metadata } from "next";
10
9
  import { notFound } from "next/navigation";
@@ -22,15 +21,6 @@ export async function generateMetadata({
22
21
  return renderPageMetadata({ type: "Event", slug, locale });
23
22
  }
24
23
 
25
- export async function generateStaticParams({
26
- params,
27
- }: {
28
- params: { locale: string };
29
- }): Promise<{ slug: string }[]> {
30
- const slugs = await getMdxSlugs("Event", params.locale);
31
- return slugs.map((slug) => ({ slug }));
32
- }
33
-
34
24
  export default async function Page({
35
25
  params,
36
26
  getMdxSource = defaultGetMdxSource,
@@ -7,7 +7,6 @@
7
7
  import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
8
8
  import {
9
9
  getMdxSource as loadMdxSource,
10
- getMdxSlugs,
11
10
  renderPage,
12
11
  renderPageMetadata,
13
12
  } from "@working-theory/page-renderer";
@@ -26,15 +25,6 @@ export async function generateMetadata({
26
25
  return renderPageMetadata({ type: "Feature", slug, locale });
27
26
  }
28
27
 
29
- export async function generateStaticParams({
30
- params,
31
- }: {
32
- params: { locale: string };
33
- }): Promise<{ slug: string }[]> {
34
- const slugs = await getMdxSlugs("Feature", params.locale);
35
- return slugs.map((slug) => ({ slug }));
36
- }
37
-
38
28
  export default async function Page({
39
29
  params,
40
30
  getMdxSource,
@@ -7,7 +7,6 @@
7
7
  import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
8
8
  import {
9
9
  getMdxSource as loadMdxSource,
10
- getMdxSlugs,
11
10
  renderPage,
12
11
  renderPageMetadata,
13
12
  } from "@working-theory/page-renderer";
@@ -26,15 +25,6 @@ export async function generateMetadata({
26
25
  return renderPageMetadata({ type: "UseCase", slug, locale });
27
26
  }
28
27
 
29
- export async function generateStaticParams({
30
- params,
31
- }: {
32
- params: { locale: string };
33
- }): Promise<{ slug: string }[]> {
34
- const slugs = await getMdxSlugs("UseCase", params.locale);
35
- return slugs.map((slug) => ({ slug }));
36
- }
37
-
38
28
  export default async function Page({
39
29
  params,
40
30
  getMdxSource,
@@ -1,11 +1,7 @@
1
1
  // HowTo detail page. Reads the HowTo MDX for the given slug, renders via
2
2
  // @working-theory/page-renderer, and emits a JSON-LD <script>. Unknown slugs trigger 404.
3
3
  import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
4
- import {
5
- getMdxSlugs,
6
- renderPage,
7
- renderPageMetadata,
8
- } from "@working-theory/page-renderer";
4
+ import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
9
5
  import type { Metadata } from "next";
10
6
  import type { ReactElement } from "react";
11
7
 
@@ -21,15 +17,6 @@ export async function generateMetadata({
21
17
  return renderPageMetadata({ type: "HowTo", slug, locale });
22
18
  }
23
19
 
24
- export async function generateStaticParams({
25
- params,
26
- }: {
27
- params: { locale: string };
28
- }): Promise<{ slug: string }[]> {
29
- const slugs = await getMdxSlugs("HowTo", params.locale);
30
- return slugs.map((slug) => ({ slug }));
31
- }
32
-
33
20
  export default async function Page({
34
21
  params,
35
22
  getMdxSource,
@@ -4,7 +4,6 @@ import {
4
4
  renderPage,
5
5
  renderPageMetadata,
6
6
  getMdxSource as defaultGetMdxSource,
7
- getMdxSlugs,
8
7
  } from "@working-theory/page-renderer";
9
8
  import type { Metadata } from "next";
10
9
  import { notFound } from "next/navigation";
@@ -22,15 +21,6 @@ export async function generateMetadata({
22
21
  return renderPageMetadata({ type: "Integration", slug, locale });
23
22
  }
24
23
 
25
- export async function generateStaticParams({
26
- params,
27
- }: {
28
- params: { locale: string };
29
- }): Promise<{ slug: string }[]> {
30
- const slugs = await getMdxSlugs("Integration", params.locale);
31
- return slugs.map((slug) => ({ slug }));
32
- }
33
-
34
24
  export default async function Page({
35
25
  params,
36
26
  getMdxSource = defaultGetMdxSource,
@@ -48,7 +48,10 @@ import type { ReactElement, ReactNode } from "react";
48
48
 
49
49
  import { auth } from "../../auth";
50
50
  import { GoogleAnalytics } from "../../components/google-analytics";
51
- import { SignOutTrigger } from "../../components/sign-out-trigger";
51
+ import {
52
+ SignedInNav,
53
+ type SignedInNavProps,
54
+ } from "../../components/signed-in-nav";
52
55
  import { isAuthPath } from "../../lib/auth-routes";
53
56
  import { assertEnabledLocale } from "../../lib/locale-guard";
54
57
  import { footerColumns, navItems } from "../../lib/nav-config";
@@ -83,10 +86,11 @@ type ThemeMode = "light" | "dark";
83
86
  * and `flow` (open a StepFlow lightbox). The `flow` kind + Nav's
84
87
  * `renderFlowTrigger` composer is Nav's ONE caller-composed seam (#4341): the
85
88
  * caller receives the rendered trigger `<Button>` and decides what wraps it.
86
- * The layout uses that seam WITHOUT a lightbox — `renderFlowTrigger` below
87
- * replaces both signed-in triggers (Account → a non-navigating name label,
88
- * Sign out → `SignOutTrigger`), so this flow config is NEVER rendered. It only
89
- * has to satisfy the type: one minimal fields-step and a dead `action`.
89
+ * The layout uses that seam WITHOUT a lightbox — `SignedInNav`'s
90
+ * `renderFlowTrigger` replaces both signed-in triggers (Account → a
91
+ * non-navigating name label, Sign out → `SignOutTrigger`), so this flow config
92
+ * is NEVER rendered. It only has to satisfy the type: one minimal fields-step
93
+ * and a dead `action`.
90
94
  */
91
95
  const NAV_CLIENT_ACTION_FLOW: NavActionFlow["flow"] = {
92
96
  steps: [
@@ -314,7 +318,8 @@ export default async function LocaleLayout({
314
318
  // the service shapes now roster both). Signed in → the user's name (a
315
319
  // non-navigating label) + an outline-strong "Sign out"; both authored as
316
320
  // `kind: "flow"` so Nav hands their triggers back through
317
- // `renderFlowTrigger` (see NAV_CLIENT_ACTION_FLOW above).
321
+ // `renderFlowTrigger`, which `SignedInNav` owns (see NAV_CLIENT_ACTION_FLOW
322
+ // above).
318
323
  const navActions: NavAction[] = session
319
324
  ? [
320
325
  { labelKey: "account", kind: "flow", flow: NAV_CLIENT_ACTION_FLOW },
@@ -331,15 +336,17 @@ export default async function LocaleLayout({
331
336
  { labelKey: "signUp", path: "/sign-up", variant: "primary" },
332
337
  ];
333
338
 
334
- // Only compose the flow seam when there is a flow action to compose. This
335
- // layout is a Server Component and `renderFlowTrigger` is a function, so
336
- // passing it is only legal when Nav actually consumes it here rather than
337
- // forwarding it across the RSC boundary into NavMobileMenu ("use client").
338
- // Passing it unconditionally crashed EVERY signed-out render — the whole of a
339
- // fresh scaffold, which configures no auth provider — with "Functions cannot
340
- // be passed directly to Client Components". Nav itself already guards its own
341
- // forward the same way (chrome/Nav.tsx: `{...(renderFlowTrigger ? … : {})}`);
342
- // this is the caller half of that contract.
339
+ // A flow action needs `renderFlowTrigger`, a function — and this layout is a
340
+ // Server Component. Nav forwards that function into NavMobileMenu ("use
341
+ // client"), and React cannot serialize a function across the RSC boundary:
342
+ // passing it from here crashed EVERY signed-in page with "Functions cannot be
343
+ // passed directly to Client Components" (#7402). Nav's own
344
+ // `{...(renderFlowTrigger ? … : {})}` guard only skips the forward when the
345
+ // function is undefined; it never makes passing one legal. So a flow action
346
+ // routes the whole Nav through `SignedInNav` ("use client"), which defines
347
+ // `renderFlowTrigger` and imports `navItems` (icon components are functions
348
+ // too) on the client side. Without one, Nav stays a Server Component and
349
+ // nothing callable crosses.
343
350
  const hasFlowAction = navActions.some((action) => action.kind === "flow");
344
351
 
345
352
  // Shared brand mark injected into both Nav and Footer chrome slots. The
@@ -388,6 +395,33 @@ export default async function LocaleLayout({
388
395
  // `(marketing)` group — a ~35-directory move for a five-page delta (spec D1).
389
396
  const isAuthRoute = isAuthPath(pathname, enabledLocales);
390
397
 
398
+ // Every Nav prop that is safe to serialize — shared verbatim by the
399
+ // server-rendered Nav (signed out) and `SignedInNav` (signed in), which adds
400
+ // `navItems` and `renderFlowTrigger` on its own side of the RSC boundary.
401
+ const navProps: SignedInNavProps = {
402
+ locale,
403
+ labels: navLabels,
404
+ // Deck home (#5697): the two forwards that join #5426 D4 to a real deck.
405
+ // `sticky` pins the bar — MarketingShell renders {nav} as a plain child of
406
+ // the scroll container, so under `scroll-snap-type: y mandatory` the
407
+ // browser snaps slide 1 to the top on load and the unpinned bar is gone
408
+ // before the reader touches anything. `deckMorph` is what puts BrandLogo's
409
+ // `.ailk-deck-brand-mark` / `.ailk-deck-brand-wordmark` hooks in the
410
+ // document for the D4 CSS to key off; it requires both `logo` and
411
+ // `wordmark` (Nav.tsx:344), which are passed below. Scoped to the SAME
412
+ // `isDeckHome` gate MarketingShell's snap/deckHero use, so a non-deck
413
+ // route's render is byte-identical to before this branch existed.
414
+ ...(isDeckHome ? { sticky: true, deckMorph: true } : {}),
415
+ siteName: SITE_NAME,
416
+ frame: "contained",
417
+ navAlignment: "right",
418
+ showThemeToggle: false,
419
+ logo: brandLogo,
420
+ wordmark: brandWordmark,
421
+ overflowLabel: navOverflowLabel,
422
+ actions: navActions,
423
+ };
424
+
391
425
  // Placeholder social handles (#5469 D5): `{{COMPANY_HANDLE}}` is a
392
426
  // create-ailk marker substituted at scaffold time
393
427
  // (packages/create-ailk/src/programmatic.ts), so a customer's site never
@@ -582,47 +616,14 @@ export default async function LocaleLayout({
582
616
  ? { snap: "mandatory" as const, deckHero: true }
583
617
  : {})}
584
618
  nav={
585
- <Nav
586
- locale={locale}
587
- labels={navLabels}
588
- navItems={siteChrome.nav === "mark-only" ? [] : navItems}
589
- // Deck home (#5697): the two forwards that join #5426 D4 to a
590
- // real deck. `sticky` pins the bar — MarketingShell renders
591
- // {nav} as a plain child of the scroll container, so under
592
- // `scroll-snap-type: y mandatory` the browser snaps slide 1 to
593
- // the top on load and the unpinned bar is gone before the
594
- // reader touches anything. `deckMorph` is what puts BrandLogo's
595
- // `.ailk-deck-brand-mark` / `.ailk-deck-brand-wordmark` hooks in
596
- // the document for the D4 CSS to key off; it requires both
597
- // `logo` and `wordmark` (Nav.tsx:344), which the layout already
598
- // passes above. Scoped to the SAME `isDeckHome` gate
599
- // MarketingShell's snap/deckHero use, so a non-deck route's
600
- // render is byte-identical to before this branch existed.
601
- {...(isDeckHome ? { sticky: true, deckMorph: true } : {})}
602
- siteName={SITE_NAME}
603
- frame="contained"
604
- navAlignment="right"
605
- showThemeToggle={false}
606
- logo={brandLogo}
607
- wordmark={brandWordmark}
608
- overflowLabel={navOverflowLabel}
609
- actions={navActions}
610
- {...(hasFlowAction
611
- ? {
612
- renderFlowTrigger: (action, trigger) =>
613
- action.labelKey === "signOut" ? (
614
- <SignOutTrigger>{trigger}</SignOutTrigger>
615
- ) : (
616
- <span
617
- className="text-body-sm text-(--color-text-muted)"
618
- data-testid="nav-account-name"
619
- >
620
- {navLabels.items.account}
621
- </span>
622
- ),
623
- }
624
- : {})}
625
- />
619
+ hasFlowAction ? (
620
+ <SignedInNav {...navProps} />
621
+ ) : (
622
+ <Nav
623
+ {...navProps}
624
+ navItems={siteChrome.nav === "mark-only" ? [] : navItems}
625
+ />
626
+ )
626
627
  }
627
628
  footer={
628
629
  <Footer
@@ -2,7 +2,6 @@
2
2
  // next-mdx-remote/rsc, and emits a JSON-LD <script>. Unknown slugs trigger 404.
3
3
  import {
4
4
  getMdxSource as defaultGetMdxSource,
5
- getMdxSlugs,
6
5
  renderPage,
7
6
  renderPageMetadata,
8
7
  } from "@working-theory/page-renderer";
@@ -22,15 +21,6 @@ export async function generateMetadata({
22
21
  return renderPageMetadata({ type: "Partner", slug, locale });
23
22
  }
24
23
 
25
- export async function generateStaticParams({
26
- params,
27
- }: {
28
- params: { locale: string };
29
- }): Promise<{ slug: string }[]> {
30
- const slugs = await getMdxSlugs("Partner", params.locale);
31
- return slugs.map((slug: string) => ({ slug }));
32
- }
33
-
34
24
  export default async function Page({
35
25
  params,
36
26
  getMdxSource = defaultGetMdxSource,
@@ -4,7 +4,6 @@
4
4
  // NO_HAND_IMPLEMENT_OK: /build-starter operator-directed live pass (#6687).
5
5
  import {
6
6
  getMdxSource as defaultGetMdxSource,
7
- getMdxSlugs,
8
7
  renderPage,
9
8
  renderPageMetadata,
10
9
  } from "@working-theory/page-renderer";
@@ -24,15 +23,6 @@ export async function generateMetadata({
24
23
  return renderPageMetadata({ type: "PortfolioItem", slug, locale });
25
24
  }
26
25
 
27
- export async function generateStaticParams({
28
- params,
29
- }: {
30
- params: { locale: string };
31
- }): Promise<{ slug: string }[]> {
32
- const slugs = await getMdxSlugs("PortfolioItem", params.locale);
33
- return slugs.map((slug: string) => ({ slug }));
34
- }
35
-
36
26
  export default async function Page({
37
27
  params,
38
28
  getMdxSource = defaultGetMdxSource,
@@ -4,7 +4,6 @@ import {
4
4
  renderPage,
5
5
  renderPageMetadata,
6
6
  getMdxSource as defaultGetMdxSource,
7
- getMdxSlugs,
8
7
  } from "@working-theory/page-renderer";
9
8
  import type { Metadata } from "next";
10
9
  import { notFound } from "next/navigation";
@@ -22,15 +21,6 @@ export async function generateMetadata({
22
21
  return renderPageMetadata({ type: "Product", slug, locale });
23
22
  }
24
23
 
25
- export async function generateStaticParams({
26
- params,
27
- }: {
28
- params: { locale: string };
29
- }): Promise<{ slug: string }[]> {
30
- const slugs = await getMdxSlugs("Product", params.locale);
31
- return slugs.map((slug) => ({ slug }));
32
- }
33
-
34
24
  export default async function Page({
35
25
  params,
36
26
  getMdxSource = defaultGetMdxSource,