create-ailk 0.13.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 (188) hide show
  1. package/README.md +12 -4
  2. package/component-catalog.json +250 -36
  3. package/component-catalog.md +10 -6
  4. package/dist/add-module.d.ts +7 -0
  5. package/dist/add-module.js +34 -2
  6. package/dist/add-route.js +18 -2
  7. package/dist/apply-design.d.ts +32 -2
  8. package/dist/apply-design.js +155 -22
  9. package/dist/cli.js +24 -3
  10. package/dist/derive-architecture.d.ts +15 -2
  11. package/dist/derive-architecture.js +34 -6
  12. package/dist/design-compile.d.ts +70 -2
  13. package/dist/design-compile.js +97 -1
  14. package/dist/design-home.d.ts +74 -0
  15. package/dist/design-home.js +256 -0
  16. package/dist/design-package.d.ts +39 -2
  17. package/dist/design-package.js +224 -9
  18. package/dist/design-presets.d.ts +17 -1
  19. package/dist/design-presets.js +17 -5
  20. package/dist/design-scaffold.d.ts +6 -25
  21. package/dist/design-scaffold.js +26 -74
  22. package/dist/index.d.ts +2 -1
  23. package/dist/index.js +1 -1
  24. package/dist/lib/chrome-readers.d.ts +21 -0
  25. package/dist/lib/chrome-readers.js +100 -0
  26. package/dist/lib/design-checks.d.ts +12 -1
  27. package/dist/lib/design-checks.js +38 -2
  28. package/dist/lib/extract-module-bundle.d.ts +11 -0
  29. package/dist/lib/extract-module-bundle.js +36 -6
  30. package/dist/lib/license-key-files.d.ts +6 -1
  31. package/dist/lib/license-key-files.js +9 -4
  32. package/dist/lib/module-license-gate.d.ts +9 -0
  33. package/dist/lib/module-license-gate.js +18 -0
  34. package/dist/module-architecture.d.ts +9 -0
  35. package/dist/module-architecture.js +6 -0
  36. package/dist/parse-args.d.ts +22 -0
  37. package/dist/parse-args.js +73 -0
  38. package/dist/programmatic.d.ts +20 -0
  39. package/dist/programmatic.js +180 -35
  40. package/dist/site-chrome.d.ts +42 -0
  41. package/dist/site-chrome.js +140 -2
  42. package/dist/site-fonts.d.ts +21 -0
  43. package/dist/site-fonts.js +54 -0
  44. package/dist/site-manifest.d.ts +12 -5
  45. package/dist/site-manifest.js +25 -11
  46. package/dist/site-roster.d.ts +16 -0
  47. package/dist/site-roster.js +81 -0
  48. package/dist/starter-grid.js +15 -0
  49. package/dist/surfaces.js +12 -0
  50. package/dist/sync-routes.js +11 -2
  51. package/dist/team-profiles.d.ts +29 -0
  52. package/dist/team-profiles.js +43 -0
  53. package/dist/topology.d.ts +131 -0
  54. package/dist/topology.js +533 -0
  55. package/package.json +3 -3
  56. package/release-scope.json +1 -1
  57. package/site-templates/_base/content/en/TeamList/team.mdx +68 -0
  58. package/site-templates/_base/content/en/TeamMember/first-member.mdx +22 -0
  59. package/site-templates/_base/content/en/TeamMember/second-member.mdx +22 -0
  60. package/site-templates/_base/content/en/TeamMember/third-member.mdx +22 -0
  61. package/site-templates/designs/minimalism/DESIGN.md +166 -36
  62. package/site-templates/designs/minimalism/home/local-business-service.mdx +119 -0
  63. package/site-templates/designs/minimalism/home/organization-product.mdx +132 -0
  64. package/site-templates/designs/minimalism/home/organization-service.mdx +131 -0
  65. package/site-templates/designs/minimalism/home/person-service.mdx +101 -0
  66. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +34 -1
  67. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +20 -0
  68. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +34 -1
  69. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +22 -1
  70. package/site-templates/marketing-site-free/ailk-template.json +1 -1
  71. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +34 -1
  72. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -7
  73. package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
  74. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +34 -1
  75. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -7
  76. package/site-templates/shapes/organization-service/ailk-template.json +1 -1
  77. package/templates/README.md +2 -0
  78. package/templates/apps/api/CLAUDE.md +6 -1
  79. package/templates/apps/api/package.json +1 -0
  80. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  81. package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
  82. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  83. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  84. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  85. package/templates/apps/api/src/lib/site.ts +37 -0
  86. package/templates/apps/api/src/lib/spoke-store.ts +18 -2
  87. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  88. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  89. package/templates/apps/api/src/middleware/auth.ts +3 -0
  90. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  91. package/templates/apps/api/src/openapi/spec.ts +38 -2
  92. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  93. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  94. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +275 -0
  95. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  96. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  97. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  98. package/templates/apps/api/src/routes/webhooks/stripe.ts +61 -1
  99. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +199 -0
  100. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  101. package/templates/apps/api/src/services/lead-routing.ts +148 -29
  102. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  103. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  104. package/templates/apps/mcp/package.json +1 -1
  105. package/templates/apps/mcp/src/index.ts +6 -4
  106. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  107. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  108. package/templates/apps/web/CLAUDE.md +12 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +10 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/settings/__tests__/theme-modes.test.ts +42 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +8 -0
  112. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  113. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  114. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
  115. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  116. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +14 -1
  117. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  118. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +163 -0
  119. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +68 -0
  120. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +4 -4
  121. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +15 -3
  122. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +17 -6
  123. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +14 -1
  124. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +2 -2
  125. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +4 -6
  126. package/templates/apps/web/app/[locale]/layout.tsx +212 -57
  127. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  128. package/templates/apps/web/app/[locale]/page.tsx +37 -0
  129. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  130. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +4 -6
  131. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  132. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +15 -2
  133. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +43 -6
  134. package/templates/apps/web/app/[locale]/services/page.tsx +14 -2
  135. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +2 -2
  136. package/templates/apps/web/app/[locale]/team/[slug]/page.tsx +45 -0
  137. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +155 -0
  138. package/templates/apps/web/app/[locale]/team/page.tsx +49 -0
  139. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +2 -2
  140. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +14 -1
  141. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  142. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +2 -2
  143. package/templates/apps/web/app/not-found.tsx +47 -0
  144. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +89 -1
  145. package/templates/apps/web/app/openapi.json/route.ts +69 -1
  146. package/templates/apps/web/instrumentation.ts +7 -4
  147. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  148. package/templates/apps/web/lib/__tests__/profile-back-link.test.ts +95 -0
  149. package/templates/apps/web/lib/__tests__/relay.test.ts +290 -0
  150. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  151. package/templates/apps/web/lib/__tests__/site-theme-head.test.ts +70 -0
  152. package/templates/apps/web/lib/__tests__/site-theme.test.ts +64 -1
  153. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  154. package/templates/apps/web/lib/profile-back-link.ts +51 -0
  155. package/templates/apps/web/lib/relay.ts +206 -2
  156. package/templates/apps/web/lib/site-brand.tsx +22 -1
  157. package/templates/apps/web/lib/site-chrome.ts +15 -0
  158. package/templates/apps/web/lib/site-fonts.ts +15 -0
  159. package/templates/apps/web/lib/site-logo.tsx +39 -1
  160. package/templates/apps/web/lib/site-theme-head.ts +33 -0
  161. package/templates/apps/web/lib/site-theme.ts +13 -1
  162. package/templates/apps/web/lib/topology.generated.ts +23 -0
  163. package/templates/apps/web/messages/en.json +7 -1
  164. package/templates/apps/web/package.json +1 -1
  165. package/templates/database/CHANGELOG.md +32 -0
  166. package/templates/database/__tests__/agents.db.test.ts +65 -0
  167. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  168. package/templates/database/auth/schema.prisma +23 -0
  169. package/templates/database/inbox/schema.prisma +13 -0
  170. package/templates/database/jest.config.cjs +7 -11
  171. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
  172. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
  173. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
  174. package/templates/database/package.json +1 -1
  175. package/versions.json +35 -34
  176. package/site-templates/designs/minimalism/sample/README.md +0 -48
  177. package/site-templates/designs/minimalism/sample/content/en/HomePage/home.mdx +0 -244
  178. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-1.svg +0 -7
  179. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-2.svg +0 -7
  180. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-3.svg +0 -7
  181. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-4.svg +0 -7
  182. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-5.svg +0 -7
  183. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-6.svg +0 -7
  184. package/site-templates/designs/minimalism/sample/public/hero-backdrop.svg +0 -17
  185. package/site-templates/designs/minimalism/sample/public/logo-placeholder-1.svg +0 -6
  186. package/site-templates/designs/minimalism/sample/public/logo-placeholder-2.svg +0 -6
  187. package/site-templates/designs/minimalism/sample/public/logo-placeholder-3.svg +0 -6
  188. package/site-templates/designs/minimalism/sample/public/wordmark-placeholder.svg +0 -28
@@ -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
@@ -13,12 +13,27 @@
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.
24
+ // - closingFooterPaths → each listed inner page ends on the closing footer
25
+ // (the wordmark, contact lines and legal line) instead of its usual footer.
16
26
 
17
27
  export interface SiteChrome {
18
28
  nav: "full" | "mark-only";
19
29
  footer: "columns" | "centered";
20
30
  announcementBanner: boolean;
21
31
  home: "default" | "deck";
32
+ innerPages?: "bare";
33
+ deckPresentation?: true;
34
+ colorMode?: "light";
35
+ chromeStyle?: "minimal";
36
+ closingFooterPaths?: readonly string[];
22
37
  }
23
38
 
24
39
  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
  }
@@ -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
+ }
@@ -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."
@@ -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,37 @@
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
+
28
+ ## 0.36.0
29
+
30
+ ### Patch Changes
31
+
32
+ - 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)]:
33
+ - @working-theory/validation@0.36.0
34
+
3
35
  ## 0.35.0
4
36
 
5
37
  ### Minor 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 });