create-ailk 0.12.1 → 0.14.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 (163) hide show
  1. package/README.md +12 -4
  2. package/component-catalog.json +247 -33
  3. package/component-catalog.md +10 -6
  4. package/dist/apply-design.d.ts +21 -2
  5. package/dist/apply-design.js +120 -19
  6. package/dist/cli.js +1 -0
  7. package/dist/design-compile.d.ts +70 -2
  8. package/dist/design-compile.js +97 -1
  9. package/dist/design-home.d.ts +74 -0
  10. package/dist/design-home.js +256 -0
  11. package/dist/design-package.d.ts +39 -2
  12. package/dist/design-package.js +223 -9
  13. package/dist/design-presets.d.ts +17 -1
  14. package/dist/design-presets.js +17 -5
  15. package/dist/design-scaffold.d.ts +6 -25
  16. package/dist/design-scaffold.js +26 -74
  17. package/dist/index.d.ts +1 -0
  18. package/dist/lib/design-checks.d.ts +12 -1
  19. package/dist/lib/design-checks.js +38 -2
  20. package/dist/lib/module-license-gate.d.ts +9 -0
  21. package/dist/lib/module-license-gate.js +18 -0
  22. package/dist/module-architecture.js +27 -0
  23. package/dist/programmatic.js +70 -26
  24. package/dist/site-chrome.d.ts +38 -0
  25. package/dist/site-chrome.js +109 -2
  26. package/dist/site-fonts.d.ts +21 -0
  27. package/dist/site-fonts.js +54 -0
  28. package/dist/site-roster.d.ts +16 -0
  29. package/dist/site-roster.js +81 -0
  30. package/dist/starter-grid.js +15 -0
  31. package/dist/surfaces.js +12 -0
  32. package/dist/team-profiles.d.ts +29 -0
  33. package/dist/team-profiles.js +43 -0
  34. package/package.json +3 -3
  35. package/release-scope.json +1 -1
  36. package/site-templates/_base/content/en/TeamList/team.mdx +68 -0
  37. package/site-templates/_base/content/en/TeamMember/first-member.mdx +22 -0
  38. package/site-templates/_base/content/en/TeamMember/second-member.mdx +22 -0
  39. package/site-templates/_base/content/en/TeamMember/third-member.mdx +22 -0
  40. package/site-templates/designs/minimalism/DESIGN.md +164 -34
  41. package/site-templates/designs/minimalism/home/local-business-service.mdx +119 -0
  42. package/site-templates/designs/minimalism/home/organization-product.mdx +132 -0
  43. package/site-templates/designs/minimalism/home/organization-service.mdx +131 -0
  44. package/site-templates/designs/minimalism/home/person-service.mdx +101 -0
  45. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +34 -1
  46. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +20 -0
  47. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +34 -1
  48. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +22 -1
  49. package/site-templates/marketing-site-free/ailk-template.json +1 -1
  50. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +34 -1
  51. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -7
  52. package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
  53. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +34 -1
  54. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -7
  55. package/site-templates/shapes/organization-service/ailk-template.json +1 -1
  56. package/templates/.env.example +23 -9
  57. package/templates/README.md +2 -0
  58. package/templates/apps/api/CLAUDE.md +24 -17
  59. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +98 -0
  60. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +321 -0
  61. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +117 -0
  62. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +39 -14
  63. package/templates/apps/api/src/config/index.ts +35 -20
  64. package/templates/apps/api/src/lib/__mocks__/prisma-client.js +3 -0
  65. package/templates/apps/api/src/lib/__mocks__/prisma.ts +14 -0
  66. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +468 -0
  67. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +32 -2
  68. package/templates/apps/api/src/lib/agent-store.ts +521 -0
  69. package/templates/apps/api/src/lib/spoke-store.ts +13 -2
  70. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +384 -1
  71. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +61 -0
  72. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +176 -0
  73. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +76 -0
  74. package/templates/apps/api/src/middleware/auth.ts +219 -5
  75. package/templates/apps/api/src/middleware/default-tenant.ts +29 -8
  76. package/templates/apps/api/src/middleware/rate-limit.ts +35 -13
  77. package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +76 -15
  78. package/templates/apps/api/src/routes/leads/index.ts +14 -6
  79. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -6
  80. package/templates/apps/api/src/routes/project-listings/index.ts +14 -9
  81. package/templates/apps/api/src/routes/project-listings/me.ts +4 -3
  82. package/templates/apps/api/src/routes/project-listings/patch-copy.ts +7 -5
  83. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +198 -0
  84. package/templates/apps/api/src/server.ts +27 -0
  85. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +374 -57
  86. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +25 -2
  87. package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +303 -17
  88. package/templates/apps/api/src/services/audit-log.ts +278 -47
  89. package/templates/apps/api/src/services/default-tenant.ts +55 -5
  90. package/templates/apps/api/src/services/leads-inbox.ts +12 -21
  91. package/templates/apps/api/src/services/project-listings.ts +1 -21
  92. package/templates/apps/api/src/services/public-site-resolver.ts +103 -13
  93. package/templates/apps/mcp/CLAUDE.md +44 -5
  94. package/templates/apps/mcp/__tests__/parity.test.ts +53 -1
  95. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +364 -0
  96. package/templates/apps/mcp/package.json +1 -1
  97. package/templates/apps/mcp/src/http-transport.ts +107 -1
  98. package/templates/apps/mcp/src/workspace-server.ts +193 -0
  99. package/templates/apps/web/.env.example +64 -37
  100. package/templates/apps/web/CLAUDE.md +11 -0
  101. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +10 -0
  102. package/templates/apps/web/app/[locale]/(authed)/account/settings/__tests__/theme-modes.test.ts +42 -0
  103. package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +8 -0
  104. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
  105. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  106. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +14 -1
  107. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +69 -0
  108. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +64 -0
  109. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +4 -4
  110. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +15 -3
  111. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +17 -6
  112. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +14 -1
  113. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +2 -2
  114. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +4 -6
  115. package/templates/apps/web/app/[locale]/layout.tsx +156 -53
  116. package/templates/apps/web/app/[locale]/page.tsx +37 -0
  117. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  118. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +4 -6
  119. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +15 -2
  120. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +43 -6
  121. package/templates/apps/web/app/[locale]/services/page.tsx +14 -2
  122. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +2 -2
  123. package/templates/apps/web/app/[locale]/team/[slug]/page.tsx +45 -0
  124. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +155 -0
  125. package/templates/apps/web/app/[locale]/team/page.tsx +49 -0
  126. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +2 -2
  127. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +14 -1
  128. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +2 -2
  129. package/templates/apps/web/app/not-found.tsx +47 -0
  130. package/templates/apps/web/app/v1/[...path]/route.ts +16 -0
  131. package/templates/apps/web/lib/__tests__/profile-back-link.test.ts +95 -0
  132. package/templates/apps/web/lib/__tests__/relay.test.ts +515 -0
  133. package/templates/apps/web/lib/__tests__/site-theme-head.test.ts +70 -0
  134. package/templates/apps/web/lib/__tests__/site-theme.test.ts +64 -1
  135. package/templates/apps/web/lib/profile-back-link.ts +51 -0
  136. package/templates/apps/web/lib/relay.ts +279 -0
  137. package/templates/apps/web/lib/site-chrome.ts +12 -0
  138. package/templates/apps/web/lib/site-fonts.ts +15 -0
  139. package/templates/apps/web/lib/site-logo.tsx +39 -1
  140. package/templates/apps/web/lib/site-theme-head.ts +33 -0
  141. package/templates/apps/web/lib/site-theme.ts +13 -1
  142. package/templates/apps/web/messages/en.json +7 -1
  143. package/templates/apps/web/middleware.ts +5 -4
  144. package/templates/apps/web/next.config.mjs +6 -0
  145. package/templates/database/CHANGELOG.md +22 -0
  146. package/templates/database/__tests__/agents.db.test.ts +235 -0
  147. package/templates/database/auth/schema.prisma +61 -0
  148. package/templates/database/migrations/20261005120000_agents/migration.sql +84 -0
  149. package/templates/database/package.json +1 -1
  150. package/versions.json +35 -35
  151. package/site-templates/designs/minimalism/sample/README.md +0 -48
  152. package/site-templates/designs/minimalism/sample/content/en/HomePage/home.mdx +0 -244
  153. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-1.svg +0 -7
  154. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-2.svg +0 -7
  155. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-3.svg +0 -7
  156. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-4.svg +0 -7
  157. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-5.svg +0 -7
  158. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-6.svg +0 -7
  159. package/site-templates/designs/minimalism/sample/public/hero-backdrop.svg +0 -17
  160. package/site-templates/designs/minimalism/sample/public/logo-placeholder-1.svg +0 -6
  161. package/site-templates/designs/minimalism/sample/public/logo-placeholder-2.svg +0 -6
  162. package/site-templates/designs/minimalism/sample/public/logo-placeholder-3.svg +0 -6
  163. package/site-templates/designs/minimalism/sample/public/wordmark-placeholder.svg +0 -28
@@ -29,9 +29,11 @@ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
29
29
  import { PartialColorPaletteSchema, SiteThemeSchema } from "@working-theory/site-config/schema";
30
30
  import {
31
31
  siteColorTokenMap,
32
+ siteThemeScopedDarkCss,
32
33
  siteThemeShapeCss,
33
34
  siteThemeToBrandTokens,
34
35
  themeToCssVarsBothModes,
36
+ themeToScopedDarkCss,
35
37
  } from "@working-theory/theme";
36
38
 
37
39
  import { loadSiteBrandTokens, loadSiteShapeCss } from "../site-theme";
@@ -268,8 +270,11 @@ describe("loadSiteBrandTokens — the brand triad", () => {
268
270
 
269
271
  const theme = SiteThemeSchema.parse(parsed);
270
272
  const before = siteThemeToBrandTokens(theme);
273
+ // Mirrors loadSiteShapeCss: a `darkBands: site` theme (minimalism's, since #7739) also scopes
274
+ // its dark ink; without it `scoped` is "" and the golden is the pre-triad CSS unchanged.
275
+ const scoped = theme.darkBands === "site" && before ? siteThemeScopedDarkCss(theme, before) : "";
271
276
  expect(layoutCss()).toBe(
272
- themeToCssVarsBothModes(before ? { brand: before } : {}) + siteThemeShapeCss(theme),
277
+ themeToCssVarsBothModes(before ? { brand: before } : {}) + siteThemeShapeCss(theme) + scoped,
273
278
  );
274
279
  });
275
280
 
@@ -318,3 +323,61 @@ describe("the two vocabularies agree on which slots exist", () => {
318
323
  );
319
324
  });
320
325
  });
326
+
327
+ // #7719 D6 — `darkBands: site` paints dark-scoped bands with the site's dark colours.
328
+ describe("loadSiteShapeCss — darkBands", () => {
329
+ const originalRoot = process.env.AILK_CONTENT_ROOT;
330
+ const tempRoots: string[] = [];
331
+
332
+ afterEach(() => {
333
+ if (originalRoot === undefined) delete process.env.AILK_CONTENT_ROOT;
334
+ else process.env.AILK_CONTENT_ROOT = originalRoot;
335
+ for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true });
336
+ });
337
+
338
+ function useSiteMdx(frontmatter: string): void {
339
+ const root = mkdtempSync(join(tmpdir(), "ailk-site-dark-bands-"));
340
+ tempRoots.push(root);
341
+ writeFileSync(join(root, "_site.mdx"), `---\nsiteName: Test\n${frontmatter}---\n`, "utf8");
342
+ process.env.AILK_CONTENT_ROOT = root;
343
+ }
344
+
345
+ const THEME = [
346
+ "theme:",
347
+ " corners: square",
348
+ " colors:",
349
+ " dark:",
350
+ ' bg: "#1c1917"',
351
+ ' text: "#fafaf9"',
352
+ "",
353
+ ].join("\n");
354
+
355
+ it("without darkBands, the shape CSS is today's", () => {
356
+ useSiteMdx(THEME);
357
+ const theme = SiteThemeSchema.parse({ corners: "square", colors: { dark: { bg: "#1c1917", text: "#fafaf9" } } });
358
+ expect(loadSiteShapeCss()).toBe(siteThemeShapeCss(theme));
359
+ });
360
+
361
+ it("with darkBands: site, ends with the scoped dark rule carrying the site's dark --color-bg", () => {
362
+ useSiteMdx(`${THEME} darkBands: site\n`);
363
+ const css = loadSiteShapeCss();
364
+ const brand = loadSiteBrandTokens();
365
+ expect(brand).toBeDefined();
366
+ const scoped = themeToScopedDarkCss({ brand: brand! });
367
+ expect(css.endsWith(scoped)).toBe(true);
368
+ expect(scoped).toContain("--color-bg: #1c1917;");
369
+ expect(css.startsWith(siteThemeShapeCss(SiteThemeSchema.parse({ corners: "square" })))).toBe(true);
370
+ });
371
+
372
+ it("with darkBands: site, the scoped rule leaves square corners to inherit from :root", () => {
373
+ useSiteMdx(`${THEME} darkBands: site\n`);
374
+ const scoped = loadSiteShapeCss().slice(loadSiteShapeCss().indexOf(":not(:root)"));
375
+ expect(scoped).not.toMatch(/--radius-/);
376
+ expect(scoped).not.toMatch(/--font-/);
377
+ });
378
+
379
+ it("darkBands: platform adds nothing", () => {
380
+ useSiteMdx(`${THEME} darkBands: platform\n`);
381
+ expect(loadSiteShapeCss()).not.toContain(':not(:root)');
382
+ });
383
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The back arrow's href on a team profile page (#7744 D7).
3
+ *
4
+ * On a deck home, a profile at `/team/<slug>` returns the visitor to the
5
+ * home's people slide — `#slide-people`, the anchor #7740 gives the home
6
+ * section whose id is `people` — rather than to the top of the home
7
+ * (checklist D3). Every other inner page, the `/team` index included, and
8
+ * every page on a non-deck home goes home.
9
+ *
10
+ * No Next.js import — pure functions, asserted directly by their own suite,
11
+ * in the `auth-routes.ts` idiom.
12
+ */
13
+
14
+ /** The people slide's anchor: #7740's `slide-` prefix plus the section id `people`. */
15
+ export const PEOPLE_SLIDE_ANCHOR = "slide-people";
16
+
17
+ /**
18
+ * Whether `pathname` is a team profile: `/team/<slug>`, bare or under an
19
+ * enabled-locale prefix. Not `/team` (the index) and not a deeper path.
20
+ *
21
+ * Fail-safe direction: an absent or empty pathname answers `false`, so a
22
+ * missing `x-pathname` header leaves the back arrow pointing home.
23
+ */
24
+ export function isTeamProfilePath(
25
+ pathname: string | null | undefined,
26
+ locales: readonly string[],
27
+ ): boolean {
28
+ if (!pathname) return false;
29
+ const path = pathname.split(/[?#]/)[0] ?? "";
30
+ const bare =
31
+ locales.length > 0
32
+ ? path.replace(new RegExp(`^/(${locales.join("|")})(?=/|$)`), "")
33
+ : path;
34
+ const segments = bare.split("/").slice(1);
35
+ return (
36
+ segments.length === 2 && segments[0] === "team" && segments[1] !== ""
37
+ );
38
+ }
39
+
40
+ /** `${homePath}#slide-people` when the home is a deck and the path is a profile; otherwise `homePath`. */
41
+ export function backLinkHref(input: {
42
+ pathname: string | null | undefined;
43
+ homePath: string;
44
+ home: string;
45
+ locales: readonly string[];
46
+ }): string {
47
+ const { pathname, homePath, home, locales } = input;
48
+ return home === "deck" && isTeamProfilePath(pathname, locales)
49
+ ? `${homePath}#${PEOPLE_SLIDE_ANCHOR}`
50
+ : homePath;
51
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * The same-origin relay (#7644, spec docs/specs/7644-the-browser-talks-only-to-spec.md).
3
+ *
4
+ * A visitor's browser posts lead capture, newsletter signup, flow submits and
5
+ * booking calls to THIS site's own address — `resolveSubmitUrl` leaves a
6
+ * root-relative action alone in a build of this app (next.config.mjs sets
7
+ * `NEXT_PUBLIC_AILK_SAME_ORIGIN_RELAY`). `app/v1/[...path]/route.ts` hands
8
+ * every `/v1/...` request here, and this module forwards it server-side to
9
+ * the hub through `@working-theory/api-client`'s `relayToApi`, which attaches
10
+ * the site's own `AILK_SPOKE_KEY` when it has one. A browser never holds the
11
+ * key and never calls the hub, so a hub can refuse callers it does not know
12
+ * (#7633) without taking any site's forms down. A single-site install takes
13
+ * the same path with no key.
14
+ *
15
+ * What it guarantees:
16
+ *
17
+ * - ONLY THE LIST (D2). `RELAY_ROUTES` is the fixed set of (method, path)
18
+ * pairs a browser form calls; an exact, case-sensitive match on the
19
+ * pathname or a 404 with no hub call. Adding a route is a reviewed change
20
+ * to this list, its test and apps/web/.env.example.
21
+ * - NO CROSS-SITE POST (D4). `Sec-Fetch-Site` other than `same-origin`, or
22
+ * an `Origin` whose host is not this site's own, is refused with 403
23
+ * before the hub is called. A POST must be JSON, which no cross-origin
24
+ * browser can send without a preflight this route never answers.
25
+ * - THE VISITOR, UNCHANGED (D3). `x-forwarded-for` as received (else
26
+ * `x-real-ip`) and `user-agent` reach the hub, so per-visitor rate limits
27
+ * still count each visitor (#7615's rule; `flow.actions.ts`).
28
+ * - AN ALLOW-LIST OF HEADERS (D3). Nothing else from the incoming request is
29
+ * forwarded: no `authorization`, no client-sent `x-ailk-*`, no `host`, and
30
+ * a `cookie` only on the one session route.
31
+ *
32
+ * NO SECRET IS LOGGED. A failure logs `{ path, outcome }` and nothing else.
33
+ */
34
+ import { relayToApi, visitorForwardHeaders } from "@working-theory/api-client";
35
+
36
+ type RelayMethod = "GET" | "POST";
37
+
38
+ /** One relayed (method, path) pair. */
39
+ export interface RelayRoute {
40
+ readonly method: RelayMethod;
41
+ readonly path: string;
42
+ }
43
+
44
+ function route(method: RelayMethod, path: string): RelayRoute {
45
+ return Object.freeze({ method, path });
46
+ }
47
+
48
+ /**
49
+ * The fixed list (D2). The scheduling and commerce paths are plain strings:
50
+ * on a hub without those modules the hub's own 404 passes through.
51
+ */
52
+ /**
53
+ * The one session route: a site-admin, cookie-authenticated write. It is
54
+ * forwarded with the visitor's `cookie` and `Origin` (the hub still checks
55
+ * that `Origin` against its trusted list), mirroring `forwardedCookieInit` in
56
+ * lib/server-api.ts. Every other entry is a visitor route.
57
+ */
58
+ const SESSION_ROUTE = route("POST", "/v1/commerce/invoices");
59
+
60
+ export const RELAY_ROUTES: readonly RelayRoute[] = Object.freeze([
61
+ route("POST", "/v1/leads/submit"),
62
+ route("POST", "/v1/leads/subscribe"),
63
+ route("POST", "/v1/leads/callback"),
64
+ route("GET", "/v1/schedule/availability"),
65
+ route("POST", "/v1/schedule/book"),
66
+ SESSION_ROUTE,
67
+ ]);
68
+
69
+ /** Fastify's default `bodyLimit`; the hub sets none (NFR4). */
70
+ const RELAY_BODY_LIMIT = 1_048_576;
71
+
72
+ /** Set on every forwarded call; an incoming request carrying it is a loop. */
73
+ const RELAYED_HEADER = "x-ailk-relayed";
74
+
75
+ /** Set on every answer: never cached, never content-sniffed by the browser. */
76
+ const BASE_RESPONSE_HEADERS = {
77
+ "cache-control": "no-store",
78
+ "x-content-type-options": "nosniff",
79
+ } as const;
80
+
81
+ function refusal(status: number, code: string, message: string): Response {
82
+ return Response.json(
83
+ { ok: false, error: { code, message } },
84
+ { status, headers: BASE_RESPONSE_HEADERS },
85
+ );
86
+ }
87
+
88
+ /**
89
+ * True when the site has an API address to forward to. `getApiBase()` falls
90
+ * back to `http://localhost:3001` for local development; a production site
91
+ * with no address set must not send its visitors' calls (and its key) there.
92
+ */
93
+ function hubConfigured(): boolean {
94
+ if (process.env.NODE_ENV !== "production") return true;
95
+ return Boolean(
96
+ process.env.AILK_API_URL ||
97
+ process.env.NEXT_PUBLIC_AILK_API_URL ||
98
+ process.env.NEXT_PUBLIC_API_URL,
99
+ );
100
+ }
101
+
102
+ /**
103
+ * The host the browser addressed: the first `x-forwarded-host`, else `host`.
104
+ * The site's edge must overwrite `x-forwarded-host`, never pass a client's on.
105
+ */
106
+ function ownHost(request: Request): string {
107
+ const forwarded = request.headers.get("x-forwarded-host")?.split(",")[0];
108
+ if (forwarded) return forwarded.trim();
109
+ return request.headers.get("host") ?? new URL(request.url).host;
110
+ }
111
+
112
+ /**
113
+ * True when the request is a cross-site browser call (D4 step 3). A request
114
+ * with neither `Sec-Fetch-Site` nor `Origin` is a non-browser caller and is
115
+ * admitted: it can forge either header anyway.
116
+ */
117
+ function isCrossSite(request: Request): boolean {
118
+ const fetchSite = request.headers.get("sec-fetch-site");
119
+ if (fetchSite !== null && fetchSite !== "same-origin") return true;
120
+
121
+ const origin = request.headers.get("origin");
122
+ if (origin === null) return false;
123
+ try {
124
+ const originUrl = new URL(origin);
125
+ // Parsing the own host under the Origin's scheme normalises a default
126
+ // port and letter case, so `spoke.example:443` equals `spoke.example`.
127
+ const own = new URL(`${originUrl.protocol}//${ownHost(request)}`);
128
+ return originUrl.host !== own.host;
129
+ } catch {
130
+ // `null`, or anything that is not a URL.
131
+ return true;
132
+ }
133
+ }
134
+
135
+ function isJson(contentType: string | null): boolean {
136
+ return (
137
+ contentType?.split(";")[0]?.trim().toLowerCase() === "application/json"
138
+ );
139
+ }
140
+
141
+ /**
142
+ * The body as text, or `null` once it passes `limit` bytes. Read as a stream
143
+ * so an oversized body is refused without buffering all of it.
144
+ */
145
+ async function readCapped(
146
+ request: Request,
147
+ limit: number,
148
+ ): Promise<string | null> {
149
+ const declared = Number(request.headers.get("content-length"));
150
+ if (Number.isFinite(declared) && declared > limit) return null;
151
+ if (!request.body) return "";
152
+
153
+ const reader = request.body.getReader();
154
+ const chunks: Uint8Array[] = [];
155
+ let size = 0;
156
+ for (;;) {
157
+ const { done, value } = await reader.read();
158
+ if (done) break;
159
+ size += value.byteLength;
160
+ if (size > limit) {
161
+ await reader.cancel();
162
+ return null;
163
+ }
164
+ chunks.push(value);
165
+ }
166
+ const bytes = new Uint8Array(size);
167
+ let offset = 0;
168
+ for (const chunk of chunks) {
169
+ bytes.set(chunk, offset);
170
+ offset += chunk.byteLength;
171
+ }
172
+ return new TextDecoder().decode(bytes);
173
+ }
174
+
175
+ /** The outbound headers, built from an allow-list (D3). Never a spread of the incoming set. */
176
+ function relayHeaders(
177
+ request: Request,
178
+ entry: RelayRoute,
179
+ ): Record<string, string> {
180
+ const incoming = request.headers;
181
+ const out: Record<string, string> = { [RELAYED_HEADER]: "1" };
182
+
183
+ const contentType = incoming.get("content-type");
184
+ if (entry.method === "POST" && contentType) out["content-type"] = contentType;
185
+
186
+ const userAgent = incoming.get("user-agent");
187
+ if (userAgent) out["user-agent"] = userAgent;
188
+
189
+ const visitorIp =
190
+ incoming.get("x-forwarded-for") ?? incoming.get("x-real-ip");
191
+ if (visitorIp) out["x-forwarded-for"] = visitorIp;
192
+
193
+ if (entry === SESSION_ROUTE) {
194
+ const cookie = incoming.get("cookie");
195
+ if (cookie) out.cookie = cookie;
196
+ const origin = incoming.get("origin");
197
+ if (origin) out.origin = origin;
198
+ } else {
199
+ Object.assign(
200
+ out,
201
+ visitorForwardHeaders(incoming, process.env.AILK_VISITOR_FORWARD_KEY),
202
+ );
203
+ }
204
+ return out;
205
+ }
206
+
207
+ /** Answer one `/v1/...` request to this site: refuse it, or forward it to the hub. */
208
+ export async function handleRelay(request: Request): Promise<Response> {
209
+ if (request.headers.has(RELAYED_HEADER)) {
210
+ return refusal(508, "relay_loop", "Request already relayed");
211
+ }
212
+
213
+ const url = new URL(request.url);
214
+ const entry = RELAY_ROUTES.find(
215
+ (r) => r.method === request.method && r.path === url.pathname,
216
+ );
217
+ if (!entry) return refusal(404, "not_found", "Not found");
218
+
219
+ if (isCrossSite(request)) {
220
+ return refusal(403, "cross_site", "Cross-site request refused");
221
+ }
222
+
223
+ let body: string | undefined;
224
+ if (entry.method === "POST") {
225
+ if (!isJson(request.headers.get("content-type"))) {
226
+ return refusal(415, "unsupported_media_type", "Expected application/json");
227
+ }
228
+ const text = await readCapped(request, RELAY_BODY_LIMIT);
229
+ if (text === null) {
230
+ return refusal(413, "payload_too_large", "Request body too large");
231
+ }
232
+ body = text;
233
+ }
234
+
235
+ if (!hubConfigured()) {
236
+ console.error("[relay] hub call failed", {
237
+ path: entry.path,
238
+ outcome: "no-api-address",
239
+ });
240
+ return refusal(502, "relay_unreachable", "The site could not reach its API");
241
+ }
242
+
243
+ try {
244
+ const result = await relayToApi(
245
+ entry.method === "GET" ? `${entry.path}${url.search}` : entry.path,
246
+ {
247
+ method: entry.method,
248
+ headers: relayHeaders(request, entry),
249
+ ...(body !== undefined ? { body } : {}),
250
+ },
251
+ );
252
+ // Only a JSON answer keeps its content-type; anything else (an HTML error
253
+ // page from a proxy in front of the hub) is served as plain text, so it
254
+ // can never render as markup on this site's origin.
255
+ const headers: Record<string, string> = {
256
+ ...BASE_RESPONSE_HEADERS,
257
+ "content-type":
258
+ result.contentType && isJson(result.contentType)
259
+ ? result.contentType
260
+ : "text/plain; charset=utf-8",
261
+ };
262
+ if (result.retryAfter) headers["retry-after"] = result.retryAfter;
263
+ // A null-body status (204, 304) cannot carry even an empty string.
264
+ return new Response(result.body === "" ? null : result.body, {
265
+ status: result.status,
266
+ headers,
267
+ });
268
+ } catch (err) {
269
+ // The error's name only (TypeError for a refused redirect or a network
270
+ // failure, TimeoutError for a hub that never answered) — never its
271
+ // message, which can quote a request detail.
272
+ console.error("[relay] hub call failed", {
273
+ path: entry.path,
274
+ outcome: "unreachable",
275
+ error: err instanceof Error ? err.name : "unknown",
276
+ });
277
+ return refusal(502, "relay_unreachable", "The site could not reach its API");
278
+ }
279
+ }
@@ -13,12 +13,24 @@
13
13
  // - home: "deck" → the locale-root route mounts MarketingShell with
14
14
  // snap="mandatory" + deckHero (spec 5426 D7); every other route is
15
15
  // unaffected.
16
+ // - innerPages: "bare" → every non-home page renders the bare header and
17
+ // the one-bar footer.
18
+ // - deckPresentation → the deck home presents slide by slide: fitted slides, slide anchors, the mark follows the ground
19
+ // - colorMode: "light" → the site is pinned to light and shows no theme
20
+ // switch; dark appears only as a deck ground.
21
+ // - chromeStyle: "minimal" → the deck's closing slide is the wordmark,
22
+ // contact lines and one legal line; the consent bar is a hairline bar on
23
+ // the page ground; the 404 renders in the site's theme.
16
24
 
17
25
  export interface SiteChrome {
18
26
  nav: "full" | "mark-only";
19
27
  footer: "columns" | "centered";
20
28
  announcementBanner: boolean;
21
29
  home: "default" | "deck";
30
+ innerPages?: "bare";
31
+ deckPresentation?: true;
32
+ colorMode?: "light";
33
+ chromeStyle?: "minimal";
22
34
  }
23
35
 
24
36
  export const siteChrome: SiteChrome = {
@@ -0,0 +1,15 @@
1
+ // site-fonts.ts — the web faces the site's design sources.
2
+ //
3
+ // GENERATED by create-ailk from the design's `typography.<role>.fontSource`
4
+ // (#7720). A design that sources no face gets the empty default.
5
+ // `apps/web/app/[locale]/layout.tsx` passes this to `applyDesignFonts`
6
+ // (@working-theory/theme): `families` fills --font-display / --font-body
7
+ // unless the site's own `brand.fonts` sets them, and `className` (the
8
+ // next/font variable classes) goes on <html>.
9
+
10
+ export interface SiteFonts {
11
+ className: string;
12
+ families: { display?: string | undefined; body?: string | undefined };
13
+ }
14
+
15
+ export const siteFonts: SiteFonts = { className: "", families: {} };
@@ -5,7 +5,7 @@
5
5
  * Split from `site-brand.tsx`, which ten route and lib modules import for
6
6
  * `SITE_NAME` / `SITE_BASE_URL`: the mark needs `@working-theory/ui`'s
7
7
  * `Image` and site-config's schema, and those stay out of that wider graph
8
- * (spec #6820 § 5.4). The two layouts are the only callers.
8
+ * (spec #6820 § 5.4). The two layouts and the home page are the only callers.
9
9
  */
10
10
 
11
11
  import { BrandLogomarkSchema } from "@working-theory/site-config/schema";
@@ -14,6 +14,44 @@ import type { ReactElement } from "react";
14
14
 
15
15
  import { SITE_LOGOMARK, SITE_NAME } from "./site-brand";
16
16
 
17
+ /** The light-mode mark as a hero `lockup`: square, so the Hero shows it as a brand mark. */
18
+ export interface SiteLogomarkAsset {
19
+ src: string;
20
+ alt: string;
21
+ width: number;
22
+ height: number;
23
+ }
24
+
25
+ /**
26
+ * The same mark `siteLogo()` renders, as data (#7711): the deck home passes it
27
+ * to its landing hero, which carries the brand while the header is bare
28
+ * (#5697). It passes the light mark only, with no `darkSrc`, as #7711 shipped it.
29
+ */
30
+ export function siteLogomarkAsset(): SiteLogomarkAsset {
31
+ if (SITE_LOGOMARK === undefined) {
32
+ return { src: "/logomark.svg", alt: SITE_NAME, width: 28, height: 28 };
33
+ }
34
+ const { light, alt } = BrandLogomarkSchema.parse(SITE_LOGOMARK);
35
+ return { src: light, alt, width: 28, height: 28 };
36
+ }
37
+
38
+ /**
39
+ * The header's wordmark pair as a hero `lockup` (#7733): the deck home's
40
+ * landing puts it in the headline's place. The same files and size the
41
+ * layout's header wordmark uses; wide, so the Hero reads it as a wordmark,
42
+ * and `alt` is the site name, so the landing's one `h1` is named for the site.
43
+ * `darkSrc` is the light-ink wordmark the Hero swaps in on a dark ground.
44
+ */
45
+ export function siteWordmarkAsset(): SiteLogomarkAsset & { darkSrc: string } {
46
+ return {
47
+ src: "/wordmark.svg",
48
+ darkSrc: "/wordmark-light.svg",
49
+ alt: SITE_NAME,
50
+ width: 140,
51
+ height: 16,
52
+ };
53
+ }
54
+
17
55
  /**
18
56
  * Shared brand mark. The Nav pairs it with `/wordmark.svg`. RSC-safe (no
19
57
  * hooks, no client state).
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The site's theme head (#7742 D6): the inline CSS the locale layout puts in
3
+ * its `<style data-ailk="theme-vars">`, and the class its `<html>` carries
4
+ * for the design's web faces. One helper so the locale layout and the
5
+ * `chromeStyle: minimal` 404 (`app/not-found.tsx`), which renders its own
6
+ * `<html>`, can never drift apart.
7
+ *
8
+ * The site's own declared colours (`_site.mdx` `theme:` + `brand:`) become
9
+ * the token overlay; the design's faces (#7720, the generated
10
+ * `site-fonts.ts`) lie beneath `brand.fonts`; both theme modes are emitted,
11
+ * the dark one gated by `[data-theme="dark"]` (#1532); the site's shape
12
+ * settings (`theme.corners`, `theme.eyebrowCase`) follow so they win.
13
+ */
14
+
15
+ import { applyDesignFonts, themeToCssVarsBothModes } from "@working-theory/theme";
16
+
17
+ import { siteFonts } from "./site-fonts";
18
+ import { loadSiteBrandTokens, loadSiteShapeCss } from "./site-theme";
19
+
20
+ export interface SiteThemeHead {
21
+ /** The `<style data-ailk="theme-vars">` text. */
22
+ css: string;
23
+ /** The `<html>` class for the design's faces; absent when there are none. */
24
+ fontClassName?: string;
25
+ }
26
+
27
+ export function siteThemeHead(): SiteThemeHead {
28
+ const designFonts = applyDesignFonts(loadSiteBrandTokens(), siteFonts);
29
+ const css =
30
+ themeToCssVarsBothModes(designFonts.tokens ? { brand: designFonts.tokens } : {}) +
31
+ loadSiteShapeCss();
32
+ return designFonts.className ? { css, fontClassName: designFonts.className } : { css };
33
+ }
@@ -30,6 +30,7 @@ import { join } from "node:path";
30
30
  import { BrandSchema, SiteThemeSchema } from "@working-theory/site-config/schema";
31
31
  import {
32
32
  siteBrandToBrandTokens,
33
+ siteThemeScopedDarkCss,
33
34
  siteThemeShapeCss,
34
35
  type BrandTokenOverrides,
35
36
  } from "@working-theory/theme";
@@ -97,9 +98,20 @@ export function loadSiteBrandTokens(): BrandTokenOverrides | undefined {
97
98
  /**
98
99
  * The site's shape settings (`theme.corners`, `theme.eyebrowCase`) as a CSS
99
100
  * override block for `:root`. "" when the site sets neither.
101
+ *
102
+ * #7719 D6 — a theme with `darkBands: "site"` also gets its own dark palette
103
+ * on every dark-scoped band below the root (`siteThemeScopedDarkCss`), built
104
+ * from the overlay the root rule carries (the `brand:` triad included). Only
105
+ * mode-variant tokens are re-declared, so the shape settings above still
106
+ * reach inside the band.
107
+ * Without it the string is today's.
100
108
  */
101
109
  export function loadSiteShapeCss(): string {
102
110
  const { theme } = readSiteBlocks();
103
111
  if (theme === undefined) return "";
104
- return siteThemeShapeCss(SiteThemeSchema.parse(theme));
112
+ const parsed = SiteThemeSchema.parse(theme);
113
+ const shape = siteThemeShapeCss(parsed);
114
+ if (parsed.darkBands !== "site") return shape;
115
+ const brand = loadSiteBrandTokens();
116
+ return brand ? shape + siteThemeScopedDarkCss(parsed, brand) : shape;
105
117
  }
@@ -51,7 +51,9 @@
51
51
  "customer3": "'{Customer 3}'",
52
52
  "service1": "'{Service 1}'",
53
53
  "service2": "'{Service 2}'",
54
- "service3": "'{Service 3}'"
54
+ "service3": "'{Service 3}'",
55
+ "nextSlide": "Next slide",
56
+ "backToTop": "Back to top"
55
57
  },
56
58
  "consent": {
57
59
  "title": "Cookie Preferences",
@@ -96,6 +98,10 @@
96
98
  "heading": "Portfolio",
97
99
  "emptyState": "Portfolio coming soon."
98
100
  },
101
+ "team": {
102
+ "heading": "Team",
103
+ "emptyState": "Team listing coming soon."
104
+ },
99
105
  "courses": {
100
106
  "heading": "Courses",
101
107
  "emptyState": "Courses listing coming soon."
@@ -12,9 +12,10 @@
12
12
  // Locale list: read once at module load from project.yaml via readLocales() (S1).
13
13
  // Static assets and internal Next.js paths are excluded via the matcher below.
14
14
  //
15
- // /api/* and /og are route handlers outside app/[locale] (#6864). They skip
16
- // next-intl — whose as-needed rewrite would send them to /en/api/… and /en/og,
17
- // which do not exist — but still get the security headers. They are exempted
15
+ // /api/*, /og and /v1/* are route handlers outside app/[locale] (#6864; /v1
16
+ // is the same-origin relay, #7644). They skip next-intl — whose as-needed
17
+ // rewrite would send them to /en/api/…, /en/og and /en/v1/…, which do not
18
+ // exist — but still get the security headers. They are exempted
18
19
  // here rather than in the matcher, because this middleware is the only place
19
20
  // the security headers are stamped.
20
21
  //
@@ -57,7 +58,7 @@ const intlMiddleware = createMiddleware({
57
58
 
58
59
  // Top-level app/ route directories that live outside [locale]. Kept in step
59
60
  // with app/ by the drift test in __tests__/i18n/middleware.test.ts.
60
- export const NON_LOCALE_ROUTE_PREFIXES = ["/api", "/og"] as const;
61
+ export const NON_LOCALE_ROUTE_PREFIXES = ["/api", "/og", "/v1"] as const;
61
62
 
62
63
  // Segment-bound: /og and /og/x are exempt; /ogden and /og-image are not.
63
64
  export function isNonLocaleRoute(pathname: string): boolean {
@@ -148,6 +148,12 @@ const nextConfig = {
148
148
  // Default locale baked at build time — consumed by the sitemap route for
149
149
  // x-default URL composition and bare-path default-locale serving.
150
150
  NEXT_PUBLIC_DEFAULT_LOCALE: defaultLocaleAtBuild,
151
+ // This site serves the same-origin relay (app/v1/[...path]/route.ts,
152
+ // #7644), so @working-theory/ui's resolveSubmitUrl leaves a form's
153
+ // root-relative action on this site's own address instead of rewriting it
154
+ // onto the hub's. Part of the source, not an operator setting: a build
155
+ // that carries the relay always carries the flag, and no other build does.
156
+ NEXT_PUBLIC_AILK_SAME_ORIGIN_RELAY: "1",
151
157
  },
152
158
  async headers() {
153
159
  if (!STEALTH) return [];
@@ -1,5 +1,27 @@
1
1
  # @working-theory/database
2
2
 
3
+ ## 0.36.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [[`14119fa`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/14119fac22bf0c26973d9286a74bced761ed1218), [`2475b60`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/2475b608d65880d9986d94628494509faedd0cd9), [`7fdf60d`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/7fdf60de18d86d878f5e567d6df5c2e0d64d97eb), [`47eeb95`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/47eeb958390f2eadd7b0c43a47c46fbbd1309872), [`4609e76`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/4609e7640798c53c730b7b9f0e723df1300ca379), [`91c5f56`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/91c5f56121d0efe475f918c493ed4245834d9e7f), [`994094f`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/994094f77619b8b0ff79688b24d7befb4b1b29fa)]:
8
+ - @working-theory/validation@0.36.0
9
+
10
+ ## 0.35.0
11
+
12
+ ### Minor Changes
13
+
14
+ - [#7671](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/7671) [`25d4a69`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/25d4a699cefaac7cf9c73dafa802696ebdc995e8) Thanks [@claudio-planck](https://github.com/claudio-planck)! - The agent seat, step A ([#3591](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/3591), PR-A of docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md).
15
+
16
+ - `@working-theory/database` — a new `auth` model, `Agent`, and its enum `AgentPermissionLevel` (`member`): a stored delegate of one person, bound to one workspace, keeping only the SHA-256 digest of its key. The migration `20261005120000_agents` is purely additive: one table, its indexes, the ADR-0031 `agent_` primary-key CHECK and format CHECKs on every reference column. Deleting the organization, the workspace or the person's membership deletes the agent; deleting the membership of the person last shown its key clears `key_issued_to_membership_id`.
17
+ - `@working-theory/ids` — registers the `agent_` prefix and exports `zAgentId`.
18
+
19
+ ### Patch Changes
20
+
21
+ - Updated dependencies [[`589b167`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/589b167dcd5acb83cb37b46bd5cdb0a57a80df1a), [`25d4a69`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/25d4a699cefaac7cf9c73dafa802696ebdc995e8), [`0fb0f37`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/0fb0f3727db9ad32776b88f39ec8b0943671bcad), [`a6833f9`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/a6833f940da064c36f3150a4af614c9fd8b38628)]:
22
+ - @working-theory/validation@0.35.0
23
+ - @working-theory/ids@0.10.0
24
+
3
25
  ## 0.34.1
4
26
 
5
27
  ### Patch Changes