create-ailk 0.14.0 → 0.15.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 (95) hide show
  1. package/component-catalog.json +3 -3
  2. package/dist/add-module.d.ts +7 -0
  3. package/dist/add-module.js +34 -2
  4. package/dist/add-route.js +18 -2
  5. package/dist/apply-design.d.ts +12 -1
  6. package/dist/apply-design.js +36 -4
  7. package/dist/cli.js +23 -3
  8. package/dist/derive-architecture.d.ts +15 -2
  9. package/dist/derive-architecture.js +34 -6
  10. package/dist/design-package.js +3 -2
  11. package/dist/index.d.ts +1 -1
  12. package/dist/index.js +1 -1
  13. package/dist/lib/chrome-readers.d.ts +21 -0
  14. package/dist/lib/chrome-readers.js +100 -0
  15. package/dist/lib/extract-module-bundle.d.ts +11 -0
  16. package/dist/lib/extract-module-bundle.js +36 -6
  17. package/dist/lib/license-key-files.d.ts +6 -1
  18. package/dist/lib/license-key-files.js +9 -4
  19. package/dist/module-architecture.d.ts +9 -0
  20. package/dist/module-architecture.js +6 -0
  21. package/dist/parse-args.d.ts +22 -0
  22. package/dist/parse-args.js +73 -0
  23. package/dist/programmatic.d.ts +20 -0
  24. package/dist/programmatic.js +110 -9
  25. package/dist/site-chrome.d.ts +4 -0
  26. package/dist/site-chrome.js +34 -3
  27. package/dist/site-manifest.d.ts +12 -5
  28. package/dist/site-manifest.js +25 -11
  29. package/dist/sync-routes.js +11 -2
  30. package/dist/topology.d.ts +131 -0
  31. package/dist/topology.js +533 -0
  32. package/package.json +3 -3
  33. package/site-templates/designs/minimalism/DESIGN.md +3 -3
  34. package/templates/apps/api/CLAUDE.md +4 -2
  35. package/templates/apps/api/package.json +1 -0
  36. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  37. package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
  38. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  39. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  40. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  41. package/templates/apps/api/src/lib/site.ts +37 -0
  42. package/templates/apps/api/src/lib/spoke-store.ts +18 -2
  43. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  44. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  45. package/templates/apps/api/src/middleware/auth.ts +3 -0
  46. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  47. package/templates/apps/api/src/openapi/spec.ts +38 -2
  48. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  49. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  50. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +275 -0
  51. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  52. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  53. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  54. package/templates/apps/api/src/routes/webhooks/stripe.ts +61 -1
  55. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +199 -0
  56. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  57. package/templates/apps/api/src/services/lead-routing.ts +148 -29
  58. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  59. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  60. package/templates/apps/mcp/src/index.ts +6 -4
  61. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  62. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  63. package/templates/apps/web/CLAUDE.md +1 -0
  64. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  65. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  66. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  67. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
  68. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
  69. package/templates/apps/web/app/[locale]/layout.tsx +71 -19
  70. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  71. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  72. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  73. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +89 -1
  74. package/templates/apps/web/app/openapi.json/route.ts +69 -1
  75. package/templates/apps/web/instrumentation.ts +7 -4
  76. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  77. package/templates/apps/web/lib/__tests__/relay.test.ts +290 -0
  78. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  79. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  80. package/templates/apps/web/lib/relay.ts +206 -2
  81. package/templates/apps/web/lib/site-brand.tsx +22 -1
  82. package/templates/apps/web/lib/site-chrome.ts +3 -0
  83. package/templates/apps/web/lib/topology.generated.ts +23 -0
  84. package/templates/apps/web/package.json +1 -1
  85. package/templates/database/CHANGELOG.md +25 -0
  86. package/templates/database/__tests__/agents.db.test.ts +65 -0
  87. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  88. package/templates/database/auth/schema.prisma +23 -0
  89. package/templates/database/inbox/schema.prisma +13 -0
  90. package/templates/database/jest.config.cjs +7 -11
  91. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
  92. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
  93. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
  94. package/templates/database/package.json +1 -1
  95. package/versions.json +34 -33
@@ -118,6 +118,58 @@ describe("SITE_NAME / SITE_BASE_URL (#4157)", () => {
118
118
  });
119
119
  });
120
120
 
121
+ /**
122
+ * `COPYRIGHT_HOLDER` — the footer's holder (#7796): `siteIdentity.legalName`
123
+ * when declared, else the site name, exactly as before.
124
+ */
125
+ describe("COPYRIGHT_HOLDER (#7796)", () => {
126
+ function writeIdentity(identityLines: string): void {
127
+ writeFileSync(
128
+ join(tmpRoot, "_site.mdx"),
129
+ `---\nsiteName: Wendi Henry\nsiteIdentity:\n kind: Person\n name: Wendi Henry\n${identityLines}---\n`,
130
+ );
131
+ process.env["AILK_CONTENT_ROOT"] = tmpRoot;
132
+ delete process.env["NEXT_PUBLIC_SITE_NAME"];
133
+ }
134
+
135
+ it("is siteIdentity.legalName when the site declares one", async () => {
136
+ writeIdentity(" legalName: SalesSmyth LLC\n");
137
+ const { COPYRIGHT_HOLDER, SITE_NAME } = await loadSiteBrand();
138
+
139
+ expect(COPYRIGHT_HOLDER).toBe("SalesSmyth LLC");
140
+ expect(SITE_NAME).toBe("Wendi Henry");
141
+ });
142
+
143
+ it("falls back to SITE_NAME when no legalName is declared", async () => {
144
+ writeIdentity("");
145
+ const { COPYRIGHT_HOLDER, SITE_NAME } = await loadSiteBrand();
146
+
147
+ expect(COPYRIGHT_HOLDER).toBe(SITE_NAME);
148
+ });
149
+
150
+ it("falls back to SITE_NAME when legalName is blank or not a string", async () => {
151
+ writeIdentity(' legalName: " "\n');
152
+ expect((await loadSiteBrand()).COPYRIGHT_HOLDER).toBe("Wendi Henry");
153
+
154
+ writeIdentity(" legalName: 42\n");
155
+ expect((await loadSiteBrand()).COPYRIGHT_HOLDER).toBe("Wendi Henry");
156
+ });
157
+
158
+ it("follows the NEXT_PUBLIC_SITE_NAME override when no legalName is declared", async () => {
159
+ writeIdentity("");
160
+ process.env["NEXT_PUBLIC_SITE_NAME"] = "Env Override Co";
161
+ expect((await loadSiteBrand()).COPYRIGHT_HOLDER).toBe("Env Override Co");
162
+ });
163
+
164
+ it("falls back to the neutral default when _site.mdx is absent", async () => {
165
+ process.env["AILK_CONTENT_ROOT"] = tmpRoot;
166
+ delete process.env["NEXT_PUBLIC_SITE_NAME"];
167
+ const { COPYRIGHT_HOLDER, FALLBACK_SITE_NAME } = await loadSiteBrand();
168
+
169
+ expect(COPYRIGHT_HOLDER).toBe(FALLBACK_SITE_NAME);
170
+ });
171
+ });
172
+
121
173
  /**
122
174
  * `siteLogo()` — the brand triad's logomark (#6820 D5). No `brand:` renders
123
175
  * exactly today's element; a declared mark is the one rendered, with its dark
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Whether a page ends on the closing footer (#7797).
3
+ *
4
+ * The `closingFooterPaths` chrome knob lists route paths as the site writes
5
+ * them ("/about"). The visitor's pathname arrives as the middleware forwards it, so
6
+ * it may carry a locale prefix ("/es/about") or a trailing slash ("/about/").
7
+ * Both sides are reduced to the same form before they are compared, so the
8
+ * list names a page once however a request spells it.
9
+ *
10
+ * No Next.js import — pure functions, asserted directly by their own suite,
11
+ * in the `profile-back-link.ts` idiom.
12
+ */
13
+
14
+ /** `path` without its query or fragment, any enabled-locale prefix, or trailing slashes; "/" for the root. */
15
+ export function normalizeRoutePath(
16
+ path: string,
17
+ locales: readonly string[],
18
+ ): string {
19
+ const bare = path.split(/[?#]/)[0] ?? "";
20
+ // Compare the first segment to the locale list rather than building a
21
+ // pattern from it, so a locale code is never read as regex syntax.
22
+ const segments = bare.split("/");
23
+ const first = segments[1];
24
+ const unprefixed =
25
+ first !== undefined && locales.includes(first)
26
+ ? ["", ...segments.slice(2)].join("/")
27
+ : bare;
28
+ const trimmed = unprefixed.replace(/\/+$/, "");
29
+ return trimmed === "" ? "/" : trimmed;
30
+ }
31
+
32
+ /**
33
+ * True when `pathname` is one of `paths` and is not the home.
34
+ *
35
+ * Fail-safe direction: an absent or empty list, or an absent pathname, answers
36
+ * `false`, so a site that sets no list renders exactly as before. The home is
37
+ * never an inner page, so listing "/" has no effect — the home's footer is its
38
+ * own knobs' (`home`, `chromeStyle`).
39
+ */
40
+ export function isClosingFooterPath(input: {
41
+ pathname: string | null | undefined;
42
+ paths: readonly string[] | undefined;
43
+ locales: readonly string[];
44
+ }): boolean {
45
+ const { pathname, paths, locales } = input;
46
+ if (!pathname || !paths || paths.length === 0) return false;
47
+ const current = normalizeRoutePath(pathname, locales);
48
+ if (current === "/") return false;
49
+ return paths.some((path) => normalizeRoutePath(path, locales) === current);
50
+ }
@@ -28,9 +28,18 @@
28
28
  * - AN ALLOW-LIST OF HEADERS (D3). Nothing else from the incoming request is
29
29
  * forwarded: no `authorization`, no client-sent `x-ailk-*`, no `host`, and
30
30
  * a `cookie` only on the one session route.
31
+ * - A PER-VISITOR BUDGET (#7707, D1-D7). Each visitor gets 8 calls per 10
32
+ * minutes across the five visitor routes, and 30 per 10 minutes on the
33
+ * session route, counted in this instance's own in-memory store; the
34
+ * ninth (31st) gets `429 rate_limited` before any hub call. Per instance,
35
+ * and only as strong as the platform: it holds only where the site's edge
36
+ * overwrites `x-forwarded-for` and `x-real-ip` (Vercel does; a bare
37
+ * self-hosted Next server does not).
31
38
  *
32
39
  * NO SECRET IS LOGGED. A failure logs `{ path, outcome }` and nothing else.
33
40
  */
41
+ import { isIP } from "node:net";
42
+
34
43
  import { relayToApi, visitorForwardHeaders } from "@working-theory/api-client";
35
44
 
36
45
  type RelayMethod = "GET" | "POST";
@@ -78,11 +87,195 @@ const BASE_RESPONSE_HEADERS = {
78
87
  "x-content-type-options": "nosniff",
79
88
  } as const;
80
89
 
81
- function refusal(status: number, code: string, message: string): Response {
90
+ function refusal(
91
+ status: number,
92
+ code: string,
93
+ message: string,
94
+ extraHeaders?: Record<string, string>,
95
+ ): Response {
82
96
  return Response.json(
83
97
  { ok: false, error: { code, message } },
84
- { status, headers: BASE_RESPONSE_HEADERS },
98
+ { status, headers: { ...BASE_RESPONSE_HEADERS, ...extraHeaders } },
99
+ );
100
+ }
101
+
102
+ /**
103
+ * The per-visitor budget (#7707, D1-D2): 8 calls per 10 minutes shared across
104
+ * the five visitor routes, 30 per 10 minutes on the session route alone, an
105
+ * in-process store capped at 10,000 entries. No env var tunes these (AC-8).
106
+ */
107
+ export const RELAY_RATE_LIMIT = Object.freeze({
108
+ limit: 8,
109
+ sessionLimit: 30,
110
+ windowMs: 600_000,
111
+ maxBuckets: 10_000,
112
+ });
113
+
114
+ type RateLimitBucket = {
115
+ count: number;
116
+ windowStartMs: number;
117
+ };
118
+
119
+ // Module-scoped store, shared by the visitor (`visitor|<identity>`) and
120
+ // session (`session|<identity>`) budgets (D7: same identity, window and
121
+ // store). Ported from the hub's own limiter
122
+ // (`apps/api/src/middleware/rate-limit.ts`), not imported — `apps/web`
123
+ // cannot import `apps/api`.
124
+ const rateLimitBuckets = new Map<string, RateLimitBucket>();
125
+ let requestsSinceSweep = 0;
126
+ const SWEEP_INTERVAL_REQUESTS = 100;
127
+
128
+ function evictExpiredBuckets(now: number): void {
129
+ for (const [key, bucket] of rateLimitBuckets) {
130
+ if (now - bucket.windowStartMs >= RELAY_RATE_LIMIT.windowMs) {
131
+ rateLimitBuckets.delete(key);
132
+ }
133
+ }
134
+ }
135
+
136
+ /** Test-only reset (D8). Not reachable over HTTP. */
137
+ export function __resetRelayRateLimit(): void {
138
+ rateLimitBuckets.clear();
139
+ requestsSinceSweep = 0;
140
+ }
141
+
142
+ /** Test-only accessor for the current distinct-bucket count (D8). */
143
+ export function __relayRateLimitBucketCount(): number {
144
+ return rateLimitBuckets.size;
145
+ }
146
+
147
+ /**
148
+ * Count one request against `key`, opening a new window when needed (D6). A
149
+ * genuinely new identity at capacity evicts the single oldest-inserted entry
150
+ * first (`Map` iteration order is insertion order) — the hub's own `hit()`.
151
+ */
152
+ function hit(key: string, now: number): RateLimitBucket {
153
+ let bucket = rateLimitBuckets.get(key);
154
+ if (!bucket || now - bucket.windowStartMs >= RELAY_RATE_LIMIT.windowMs) {
155
+ if (
156
+ !rateLimitBuckets.has(key) &&
157
+ rateLimitBuckets.size >= RELAY_RATE_LIMIT.maxBuckets
158
+ ) {
159
+ const oldestKey = rateLimitBuckets.keys().next().value;
160
+ if (oldestKey !== undefined) rateLimitBuckets.delete(oldestKey);
161
+ }
162
+ bucket = { count: 0, windowStartMs: now };
163
+ rateLimitBuckets.set(key, bucket);
164
+ }
165
+ bucket.count += 1;
166
+ return bucket;
167
+ }
168
+
169
+ /**
170
+ * The eight hextets of a valid IPv6 address, or `null`. Ported from the
171
+ * hub's own `expandIpv6` (`apps/api/src/middleware/rate-limit.ts`).
172
+ */
173
+ function expandIpv6(address: string): number[] | null {
174
+ let text = address;
175
+ const tail: number[] = [];
176
+ const lastColon = text.lastIndexOf(":");
177
+ const last = text.slice(lastColon + 1);
178
+ if (last.includes(".")) {
179
+ const octets = last.split(".").map(Number);
180
+ tail.push(((octets[0] ?? 0) << 8) | (octets[1] ?? 0));
181
+ tail.push(((octets[2] ?? 0) << 8) | (octets[3] ?? 0));
182
+ text = `${text.slice(0, lastColon + 1)}0`;
183
+ }
184
+ const halves = text.split("::");
185
+ const parse = (part: string | undefined): number[] =>
186
+ part ? part.split(":").map((hextet) => parseInt(hextet, 16)) : [];
187
+ const head = parse(halves[0]);
188
+ const rest = parse(halves[1]);
189
+ if (tail.length > 0) {
190
+ (halves.length > 1 ? rest : head).pop();
191
+ }
192
+ const fill = 8 - head.length - rest.length - tail.length;
193
+ if (fill < 0 || (halves.length === 1 && fill !== 0)) return null;
194
+ const hextets = [
195
+ ...head,
196
+ ...new Array<number>(fill).fill(0),
197
+ ...rest,
198
+ ...tail,
199
+ ];
200
+ return hextets.every((h) => Number.isInteger(h) && h >= 0 && h <= 0xffff)
201
+ ? hextets
202
+ : null;
203
+ }
204
+
205
+ const MAX_VISITOR_IP_LENGTH = 45;
206
+
207
+ /**
208
+ * Validate and normalise a visitor address (D3): IPv4 as is, an IPv4-mapped
209
+ * IPv6 address as its IPv4 form, any other IPv6 address as its /64 prefix.
210
+ * `null` for anything else. Ported from the hub's own `normaliseVisitorIp`.
211
+ */
212
+ function normaliseVisitorIp(raw: string): string | null {
213
+ const value = raw.trim().toLowerCase();
214
+ if (value.length > MAX_VISITOR_IP_LENGTH || value.includes("%")) return null;
215
+ const family = isIP(value);
216
+ if (family === 4) return value;
217
+ if (family !== 6) return null;
218
+ const hextets = expandIpv6(value);
219
+ if (!hextets) return null;
220
+ const isV4Mapped =
221
+ hextets.slice(0, 5).every((h) => h === 0) && hextets[5] === 0xffff;
222
+ if (isV4Mapped) {
223
+ const high = hextets[6] ?? 0;
224
+ const low = hextets[7] ?? 0;
225
+ return [high >> 8, high & 0xff, low >> 8, low & 0xff].join(".");
226
+ }
227
+ const prefix = hextets
228
+ .slice(0, 4)
229
+ .map((h) => h.toString(16).padStart(4, "0"))
230
+ .join(":");
231
+ return `${prefix}::/64`;
232
+ }
233
+
234
+ /**
235
+ * The visitor's identity (D3): the first comma-separated value of
236
+ * `x-forwarded-for` when present (a present-but-empty value does not fall
237
+ * back to `x-real-ip`), else `x-real-ip`. Anything that does not normalise —
238
+ * no header, an empty value, or one `normaliseVisitorIp` rejects — shares the
239
+ * single identity `"unknown"`, never a fresh bucket of its own.
240
+ */
241
+ function visitorIdentity(request: Request): string {
242
+ const raw =
243
+ request.headers.get("x-forwarded-for") ?? request.headers.get("x-real-ip");
244
+ const first = raw?.split(",")[0]?.trim().toLowerCase();
245
+ if (!first) return "unknown";
246
+ return normaliseVisitorIp(first) ?? "unknown";
247
+ }
248
+
249
+ /**
250
+ * Count one request against its budget (D1-D7): the visitor budget for every
251
+ * entry but the session route, which gets its own, wider, independent
252
+ * budget. Returns whether the call is admitted and the whole seconds (at
253
+ * least 1) until the tripped bucket's window ends.
254
+ */
255
+ function checkRateLimit(
256
+ request: Request,
257
+ entry: RelayRoute,
258
+ now: number,
259
+ ): { allowed: boolean; retryAfterSeconds: number } {
260
+ requestsSinceSweep += 1;
261
+ if (requestsSinceSweep >= SWEEP_INTERVAL_REQUESTS) {
262
+ evictExpiredBuckets(now);
263
+ requestsSinceSweep = 0;
264
+ }
265
+
266
+ const identity = visitorIdentity(request);
267
+ const isSession = entry === SESSION_ROUTE;
268
+ const key = `${isSession ? "session" : "visitor"}|${identity}`;
269
+ const limit = isSession
270
+ ? RELAY_RATE_LIMIT.sessionLimit
271
+ : RELAY_RATE_LIMIT.limit;
272
+
273
+ const bucket = hit(key, now);
274
+ const retryAfterSeconds = Math.max(
275
+ 1,
276
+ Math.ceil((bucket.windowStartMs + RELAY_RATE_LIMIT.windowMs - now) / 1000),
85
277
  );
278
+ return { allowed: bucket.count <= limit, retryAfterSeconds };
86
279
  }
87
280
 
88
281
  /**
@@ -220,6 +413,17 @@ export async function handleRelay(request: Request): Promise<Response> {
220
413
  return refusal(403, "cross_site", "Cross-site request refused");
221
414
  }
222
415
 
416
+ const { allowed, retryAfterSeconds } = checkRateLimit(
417
+ request,
418
+ entry,
419
+ Date.now(),
420
+ );
421
+ if (!allowed) {
422
+ return refusal(429, "rate_limited", "Too many requests", {
423
+ "retry-after": String(retryAfterSeconds),
424
+ });
425
+ }
426
+
223
427
  let body: string | undefined;
224
428
  if (entry.method === "POST") {
225
429
  if (!isJson(request.headers.get("content-type"))) {
@@ -47,6 +47,8 @@ interface SiteFrontmatter {
47
47
  baseUrl?: string | undefined;
48
48
  /** `brand.logomark`, raw — validated by `siteLogo()` when it renders. */
49
49
  logomark?: unknown;
50
+ /** `siteIdentity.legalName`, when declared as a non-empty string. */
51
+ legalName?: string | undefined;
50
52
  }
51
53
 
52
54
  /**
@@ -84,8 +86,19 @@ function readSiteFrontmatter(): SiteFrontmatter | null {
84
86
  const parsed: unknown = parseYaml(match[1] ?? "");
85
87
  if (!parsed || typeof parsed !== "object") return null;
86
88
 
87
- const { siteName, baseUrl, brand } = parsed as Record<string, unknown>;
89
+ const { siteName, baseUrl, brand, siteIdentity } = parsed as Record<
90
+ string,
91
+ unknown
92
+ >;
93
+ const legalName =
94
+ siteIdentity && typeof siteIdentity === "object"
95
+ ? (siteIdentity as Record<string, unknown>).legalName
96
+ : undefined;
88
97
  return {
98
+ legalName:
99
+ typeof legalName === "string" && legalName.trim().length > 0
100
+ ? legalName.trim()
101
+ : undefined,
89
102
  siteName: typeof siteName === "string" ? siteName : undefined,
90
103
  baseUrl: typeof baseUrl === "string" ? baseUrl : undefined,
91
104
  logomark:
@@ -112,6 +125,14 @@ export const SITE_NAME =
112
125
  export const SITE_BASE_URL =
113
126
  process.env.NEXT_PUBLIC_BASE_URL ?? siteFrontmatter?.baseUrl ?? FALLBACK_BASE_URL;
114
127
 
128
+ /**
129
+ * The footer copyright holder (#7796): `siteIdentity.legalName` when the site
130
+ * declares one (schema.org's `Organization.legalName` — the owning entity,
131
+ * which often differs from the display name), else `SITE_NAME`. A site that
132
+ * declares no `legalName` resolves exactly as it did before this existed.
133
+ */
134
+ export const COPYRIGHT_HOLDER = siteFrontmatter?.legalName ?? SITE_NAME;
135
+
115
136
  /**
116
137
  * The raw `brand.logomark` from `_site.mdx`, or `undefined` when none is
117
138
  * declared. Unvalidated: `siteLogo()` (`site-logo.tsx`) parses it on the
@@ -21,6 +21,8 @@
21
21
  // - chromeStyle: "minimal" → the deck's closing slide is the wordmark,
22
22
  // contact lines and one legal line; the consent bar is a hairline bar on
23
23
  // the page ground; the 404 renders in the site's theme.
24
+ // - closingFooterPaths → each listed inner page ends on the closing footer
25
+ // (the wordmark, contact lines and legal line) instead of its usual footer.
24
26
 
25
27
  export interface SiteChrome {
26
28
  nav: "full" | "mark-only";
@@ -31,6 +33,7 @@ export interface SiteChrome {
31
33
  deckPresentation?: true;
32
34
  colorMode?: "light";
33
35
  chromeStyle?: "minimal";
36
+ closingFooterPaths?: readonly string[];
34
37
  }
35
38
 
36
39
  export const siteChrome: SiteChrome = {
@@ -0,0 +1,23 @@
1
+ // topology.generated.ts — how this site reads a session and registers its stores.
2
+ //
3
+ // GENERATED by create-ailk for this site's topology (#7426). Do not edit:
4
+ // every scaffold rewrites it. A hub reads the session through Better Auth;
5
+ // a spoke (`--topology spoke`) has no sign-in and no database of its own,
6
+ // so its render reads no session and imports nothing.
7
+ import { auth } from "../auth";
8
+
9
+ export const TOPOLOGY: "hub" | "spoke" = "hub";
10
+ export const SIGN_IN_ENABLED: boolean = true;
11
+
12
+ export type SiteSession = {
13
+ user: { name?: string | null; email: string };
14
+ } | null;
15
+
16
+ export async function readSession(headers: Headers): Promise<SiteSession> {
17
+ return auth.api.getSession({ headers });
18
+ }
19
+
20
+ export async function registerSiteStores(): Promise<void> {
21
+ const { registerLicenseKeyStore } = await import("./license-key-store");
22
+ await registerLicenseKeyStore();
23
+ }
@@ -37,7 +37,7 @@
37
37
  "@working-theory/web-i18n": "workspace:*",
38
38
  "better-auth": "1.6.22",
39
39
  "lucide-react": "^0.577.0",
40
- "next": "^16.3.3",
40
+ "next": "^16.3.8",
41
41
  "next-intl": "^3.26.3",
42
42
  "next-mdx-remote": "^6.0.0",
43
43
  "pg": "^8.22.0",
@@ -1,5 +1,30 @@
1
1
  # @working-theory/database
2
2
 
3
+ ## 0.37.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#7821](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/7821) [`878ffc6`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/878ffc61e3c0baad68889e77d4686fdbc88a85c2) Thanks [@samuelhenry](https://github.com/samuelhenry)! - Sign-in lands in the user's last workspace ([#7803](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/7803)), the schema half.
8
+
9
+ - `@working-theory/database` — `User` gains `lastWorkspaceId` (`users.last_workspace_id`), a nullable reference to `Workspace` with `ON DELETE SET NULL`, an index and the ADR-0031 `wksp_` format CHECK; `Workspace` gains the back-relation `lastActiveUsers`. The migration `20261008120000_user_last_workspace` is purely additive: one nullable column with no default, so no backfill runs and no existing row changes. Deploy it before the API that reads the column.
10
+
11
+ - [#7901](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/7901) [`6efec95`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/6efec954914e40acbf42db1e0132569e983c3915) Thanks [@samuelhenry](https://github.com/samuelhenry)! - Agent key grants ([#7864](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/7864)): an owner or admin can grant an agent any of five
12
+ permissions — content-edit, content-publish, assets-write, magnets-manage and
13
+ meeting-types-manage. `@working-theory/validation` adds
14
+ `AGENT_GRANTABLE_PERMISSIONS`, `isAgentGrantable`, `allows` and
15
+ `setAgentGrantsBodySchema`, and the agent wire row gains `grants`;
16
+ `@working-theory/database` adds the `agents.grants` column, held to those five
17
+ by a CHECK. An agent with no grants behaves exactly as before.
18
+
19
+ - [#7895](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/7895) [`90451af`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/90451afcc7c264a0c5de7d239edcfbdefcf28f64) Thanks [@samuelhenry](https://github.com/samuelhenry)! - A lead write can be retried safely ([#7869](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/7869)), the schema half.
20
+
21
+ - `@working-theory/database` — `Lead` gains `idempotencyKey` (`leads.idempotency_key`), a nullable text column with a unique index over `(workspace_id, idempotency_key)`. The migration `20261008130000_lead_idempotency_key` is purely additive: one nullable column with no default and one unique index, so no backfill runs and no existing row changes (NULLs are distinct under the index). Deploy it before the API that writes the column.
22
+
23
+ ### Patch Changes
24
+
25
+ - Updated dependencies [[`d3f8ac6`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/d3f8ac6bd41959f23a1f5f591e3cccce42895cd0), [`c979e93`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/c979e93a593606bfe47e815f29eef9e17be7e329), [`6efec95`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/6efec954914e40acbf42db1e0132569e983c3915)]:
26
+ - @working-theory/validation@0.37.0
27
+
3
28
  ## 0.36.0
4
29
 
5
30
  ### Patch Changes
@@ -18,11 +18,41 @@
18
18
  * 4. Deleting the membership of the person last shown the key sets
19
19
  * `key_issued_to_membership_id` null and keeps the agent.
20
20
  * 5. Deleting the creator's user row leaves the agent (no relation).
21
+ * 6. `grants` (#7864 D2, AC-2) defaults to empty, takes any of the five
22
+ * grantable permissions, and the `agents_grants_allowed` CHECK refuses
23
+ * anything else.
24
+ *
25
+ * One case needs no database and always runs: the migration's CHECK names
26
+ * exactly `@working-theory/validation`'s `AGENT_GRANTABLE_PERMISSIONS`, so the
27
+ * SQL and the TypeScript spelling of the list cannot drift (SEC-7864-G).
21
28
  */
29
+ import { readFileSync } from "node:fs";
30
+ import { join } from "node:path";
31
+
32
+ import { AGENT_GRANTABLE_PERMISSIONS } from "@working-theory/validation";
22
33
  import { Client } from "pg";
23
34
 
24
35
  const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
25
36
 
37
+ const GRANTS_MIGRATION = join(
38
+ __dirname,
39
+ "..",
40
+ "migrations",
41
+ "20261009120000_agent_grants",
42
+ "migration.sql",
43
+ );
44
+
45
+ describe("the agents_grants_allowed CHECK (#7864 D2, AC-2)", () => {
46
+ it("names exactly AGENT_GRANTABLE_PERMISSIONS", () => {
47
+ const sql = readFileSync(GRANTS_MIGRATION, "utf8");
48
+ const check = /CHECK \("grants" <@ ARRAY\[([^\]]*)\]::TEXT\[\]\)/.exec(sql);
49
+ expect(check).not.toBeNull();
50
+ const listed = [...check![1]!.matchAll(/'([^']*)'/g)].map((m) => m[1]);
51
+ expect(new Set(listed)).toEqual(new Set(AGENT_GRANTABLE_PERMISSIONS));
52
+ expect(listed).toHaveLength(AGENT_GRANTABLE_PERMISSIONS.length);
53
+ });
54
+ });
55
+
26
56
  const AGENT_ID = /^agent_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
27
57
 
28
58
  describeDb("the agents table (#3591, live DB)", () => {
@@ -172,6 +202,41 @@ describeDb("the agents table (#3591, live DB)", () => {
172
202
  });
173
203
  });
174
204
 
205
+ describe("grants (#7864 D2, AC-2)", () => {
206
+ async function grantsOf(id: string): Promise<string[]> {
207
+ return (
208
+ await db.query<{ grants: string[] }>(
209
+ `SELECT "grants" FROM "agents" WHERE "id" = $1`,
210
+ [id],
211
+ )
212
+ ).rows[0]!.grants;
213
+ }
214
+
215
+ it("a new agent has no grants", async () => {
216
+ expect(await grantsOf(await insertAgent())).toEqual([]);
217
+ });
218
+
219
+ it("takes grantable permissions", async () => {
220
+ const id = await insertAgent();
221
+ await db.query(
222
+ `UPDATE "agents" SET "grants" = ARRAY['content-edit','magnets-manage']::TEXT[] WHERE "id" = $1`,
223
+ [id],
224
+ );
225
+ expect(await grantsOf(id)).toEqual(["content-edit", "magnets-manage"]);
226
+ });
227
+
228
+ it("refuses any other permission", async () => {
229
+ const id = await insertAgent();
230
+ await expect(
231
+ db.query(
232
+ `UPDATE "agents" SET "grants" = ARRAY['content-edit','agents-manage']::TEXT[] WHERE "id" = $1`,
233
+ [id],
234
+ ),
235
+ ).rejects.toThrow(/agents_grants_allowed/);
236
+ expect(await grantsOf(id)).toEqual([]);
237
+ });
238
+ });
239
+
175
240
  it("a credential digest is unique", async () => {
176
241
  const hash = nextHash();
177
242
  await insertAgent({ credential_hash: hash });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @file lead-idempotency-key.db.test.ts
3
+ *
4
+ * LIVE-DATABASE proof of the lead idempotency key (#7869, spec
5
+ * docs/specs/7869-lead-idempotency-key-spec.md D8, D10 cases 15–17; AC-4).
6
+ * Requires DATABASE_URL pointing at a Postgres with the migrations applied —
7
+ * the whole suite SKIPS when DATABASE_URL is unset, keeping the workspace
8
+ * Jest run DB-free (the repo convention; the `apps/api Route Tests
9
+ * (Postgres)` workflow exercises it).
10
+ *
11
+ * Uses the `pg` driver directly, as `spoke-registry.db.test.ts` does. What it
12
+ * proves that a mock cannot — the unique index
13
+ * `leads_workspace_id_idempotency_key_key` itself:
14
+ * 15. One key twice in one workspace: the second insert fails (23505).
15
+ * 16. One key in two workspaces: both inserts succeed.
16
+ * 17. Two keyless (NULL) leads in one workspace: both succeed.
17
+ */
18
+ import { Client } from "pg";
19
+
20
+ const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
21
+
22
+ describeDb("the lead idempotency key (#7869, live DB)", () => {
23
+ let db: Client;
24
+ let workspaceA: string;
25
+ let workspaceB: string;
26
+
27
+ beforeAll(async () => {
28
+ db = new Client({ connectionString: process.env.DATABASE_URL });
29
+ await db.connect();
30
+
31
+ const org = (
32
+ await db.query<{ id: string }>(
33
+ `INSERT INTO "organizations" ("name", "slug", "updated_at")
34
+ VALUES ('Lead Idempotency Org', 'idem7869-org', now()) RETURNING "id"`,
35
+ )
36
+ ).rows[0]!;
37
+ const workspace = async (slug: string) =>
38
+ (
39
+ await db.query<{ id: string }>(
40
+ `INSERT INTO "workspaces" ("organization_id", "name", "slug", "updated_at")
41
+ VALUES ($1, $2, $2, now()) RETURNING "id"`,
42
+ [org.id, slug],
43
+ )
44
+ ).rows[0]!.id;
45
+ workspaceA = await workspace("idem7869-ws-a");
46
+ workspaceB = await workspace("idem7869-ws-b");
47
+ });
48
+
49
+ afterAll(async () => {
50
+ await db.query(`DELETE FROM "leads" WHERE "source" = 'idem7869'`);
51
+ await db.query(`DELETE FROM "organizations" WHERE "slug" = 'idem7869-org'`);
52
+ await db.end();
53
+ });
54
+
55
+ /** Insert one lead; the database mints its id. */
56
+ const insertLead = (workspaceId: string, idempotencyKey: string | null) =>
57
+ db.query(
58
+ `INSERT INTO "leads" ("site_id", "workspace_id", "source", "email", "payload", "idempotency_key")
59
+ VALUES ($1, $1, 'idem7869', 'visitor@example.com', '{}', $2)`,
60
+ [workspaceId, idempotencyKey],
61
+ );
62
+
63
+ it("case 15 — one key twice in one workspace: the second insert fails with 23505", async () => {
64
+ await insertLead(workspaceA, "idem7869-key-same-workspace");
65
+ await expect(
66
+ insertLead(workspaceA, "idem7869-key-same-workspace"),
67
+ ).rejects.toMatchObject({
68
+ code: "23505",
69
+ constraint: "leads_workspace_id_idempotency_key_key",
70
+ });
71
+ });
72
+
73
+ it("case 16 — the same key in two workspaces: both inserts succeed", async () => {
74
+ await insertLead(workspaceA, "idem7869-key-two-workspaces");
75
+ await insertLead(workspaceB, "idem7869-key-two-workspaces");
76
+ const { rows } = await db.query<{ n: string }>(
77
+ `SELECT count(*) AS n FROM "leads" WHERE "idempotency_key" = 'idem7869-key-two-workspaces'`,
78
+ );
79
+ expect(rows[0]!.n).toBe("2");
80
+ });
81
+
82
+ it("case 17 — two keyless leads in one workspace: both succeed (NULLs are distinct)", async () => {
83
+ await insertLead(workspaceA, null);
84
+ await insertLead(workspaceA, null);
85
+ const { rows } = await db.query<{ n: string }>(
86
+ `SELECT count(*) AS n FROM "leads"
87
+ WHERE "source" = 'idem7869' AND "workspace_id" = $1 AND "idempotency_key" IS NULL`,
88
+ [workspaceA],
89
+ );
90
+ expect(rows[0]!.n).toBe("2");
91
+ });
92
+ });