create-ailk 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/README.md +124 -0
  2. package/component-catalog.json +3 -3
  3. package/dist/add-module.d.ts +7 -0
  4. package/dist/add-module.js +80 -3
  5. package/dist/add-route.js +26 -2
  6. package/dist/adopt.d.ts +74 -0
  7. package/dist/adopt.js +314 -0
  8. package/dist/apply-design.d.ts +12 -1
  9. package/dist/apply-design.js +36 -4
  10. package/dist/cli.js +103 -3
  11. package/dist/derive-architecture.d.ts +37 -2
  12. package/dist/derive-architecture.js +92 -7
  13. package/dist/design-package.js +3 -2
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/lib/chrome-readers.d.ts +21 -0
  17. package/dist/lib/chrome-readers.js +100 -0
  18. package/dist/lib/declared-env.d.ts +142 -0
  19. package/dist/lib/declared-env.js +985 -0
  20. package/dist/lib/env-catalog.d.ts +17 -0
  21. package/dist/lib/env-catalog.js +234 -0
  22. package/dist/lib/extract-module-bundle.d.ts +20 -0
  23. package/dist/lib/extract-module-bundle.js +53 -6
  24. package/dist/lib/license-key-files.d.ts +6 -1
  25. package/dist/lib/license-key-files.js +9 -4
  26. package/dist/lib/module-tier.d.ts +2 -0
  27. package/dist/lib/module-tier.js +4 -0
  28. package/dist/module-architecture.d.ts +9 -0
  29. package/dist/module-architecture.js +82 -0
  30. package/dist/parse-args.d.ts +27 -0
  31. package/dist/parse-args.js +89 -0
  32. package/dist/programmatic.d.ts +28 -0
  33. package/dist/programmatic.js +181 -11
  34. package/dist/seed-flows.d.ts +65 -0
  35. package/dist/seed-flows.js +350 -0
  36. package/dist/site-chrome.d.ts +4 -0
  37. package/dist/site-chrome.js +34 -3
  38. package/dist/site-manifest.d.ts +55 -5
  39. package/dist/site-manifest.js +121 -12
  40. package/dist/site-roster.d.ts +13 -0
  41. package/dist/site-roster.js +22 -8
  42. package/dist/spoke-checkout.d.ts +42 -0
  43. package/dist/spoke-checkout.js +359 -0
  44. package/dist/surfaces.js +7 -0
  45. package/dist/sync-routes.js +29 -5
  46. package/dist/topology.d.ts +246 -0
  47. package/dist/topology.js +942 -0
  48. package/package.json +3 -3
  49. package/site-templates/designs/minimalism/DESIGN.md +3 -3
  50. package/templates/.env.example +10 -0
  51. package/templates/apps/api/CLAUDE.md +21 -4
  52. package/templates/apps/api/package.json +1 -0
  53. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  54. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
  55. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
  56. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
  57. package/templates/apps/api/src/bin/spokes.ts +62 -1
  58. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
  59. package/templates/apps/api/src/config/index.ts +35 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
  61. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  62. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
  63. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
  64. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  65. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
  66. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  67. package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
  68. package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
  69. package/templates/apps/api/src/lib/site.ts +37 -0
  70. package/templates/apps/api/src/lib/spoke-store.ts +41 -3
  71. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  72. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
  73. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  74. package/templates/apps/api/src/middleware/auth.ts +129 -0
  75. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  76. package/templates/apps/api/src/openapi/spec.ts +38 -2
  77. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
  78. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
  79. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  80. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  81. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
  82. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  83. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  84. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
  85. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  86. package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
  87. package/templates/apps/api/src/server.ts +105 -0
  88. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
  89. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
  90. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
  91. package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
  92. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  93. package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
  94. package/templates/apps/api/src/services/lead-routing.ts +278 -30
  95. package/templates/apps/api/src/services/person.ts +541 -0
  96. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  97. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  98. package/templates/apps/mcp/src/index.ts +6 -4
  99. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  100. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  101. package/templates/apps/web/.env.example +58 -5
  102. package/templates/apps/web/CLAUDE.md +1 -0
  103. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
  106. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
  107. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
  110. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
  111. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
  112. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
  113. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  114. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  115. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  116. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
  117. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
  118. package/templates/apps/web/app/[locale]/layout.tsx +83 -18
  119. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  120. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  121. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  122. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
  123. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
  124. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
  125. package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
  126. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
  127. package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
  128. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
  129. package/templates/apps/web/app/openapi.json/route.ts +72 -7
  130. package/templates/apps/web/auth.ts +29 -2
  131. package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
  132. package/templates/apps/web/components/signed-in-nav.tsx +17 -3
  133. package/templates/apps/web/instrumentation.ts +7 -4
  134. package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
  135. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  136. package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
  137. package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
  138. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
  139. package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
  140. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  141. package/templates/apps/web/lib/account-config.ts +130 -1
  142. package/templates/apps/web/lib/api-origin.generated.ts +11 -0
  143. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  144. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
  145. package/templates/apps/web/lib/hub-session/config.ts +104 -0
  146. package/templates/apps/web/lib/hub-session/index.ts +23 -0
  147. package/templates/apps/web/lib/hub-session/seal.ts +82 -0
  148. package/templates/apps/web/lib/hub-session/session.ts +180 -0
  149. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
  150. package/templates/apps/web/lib/listings-config.ts +31 -0
  151. package/templates/apps/web/lib/listings.tsx +7 -3
  152. package/templates/apps/web/lib/meeting-slugs.ts +11 -1
  153. package/templates/apps/web/lib/next-path.ts +35 -0
  154. package/templates/apps/web/lib/relay.ts +209 -3
  155. package/templates/apps/web/lib/server-api.ts +11 -1
  156. package/templates/apps/web/lib/site-brand.tsx +22 -1
  157. package/templates/apps/web/lib/site-chrome.ts +3 -0
  158. package/templates/apps/web/lib/topology.generated.ts +33 -0
  159. package/templates/apps/web/messages/en.json +32 -3
  160. package/templates/apps/web/package.json +1 -1
  161. package/templates/database/CHANGELOG.md +51 -0
  162. package/templates/database/__tests__/agents.db.test.ts +85 -0
  163. package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
  164. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  165. package/templates/database/__tests__/member-area.db.test.ts +245 -0
  166. package/templates/database/__tests__/person.db.test.ts +584 -0
  167. package/templates/database/auth/schema.prisma +24 -0
  168. package/templates/database/config/schema.prisma +13 -0
  169. package/templates/database/inbox/schema.prisma +111 -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/migrations/20261010120000_person/migration.sql +106 -0
  175. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
  176. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
  177. package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
  178. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
  179. package/templates/database/package.json +1 -1
  180. package/templates/database/scripts/person-backfill.sql +132 -0
  181. package/templates/database/tenancy.ts +53 -0
  182. package/templates/tools/check-declared-env.ts +37 -0
  183. package/templates/tools/declared-env.ts +1163 -0
  184. package/versions.json +35 -34
@@ -28,11 +28,22 @@
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
 
45
+ import { browserApiOrigin } from "./api-origin.generated";
46
+
36
47
  type RelayMethod = "GET" | "POST";
37
48
 
38
49
  /** One relayed (method, path) pair. */
@@ -78,13 +89,197 @@ const BASE_RESPONSE_HEADERS = {
78
89
  "x-content-type-options": "nosniff",
79
90
  } as const;
80
91
 
81
- function refusal(status: number, code: string, message: string): Response {
92
+ function refusal(
93
+ status: number,
94
+ code: string,
95
+ message: string,
96
+ extraHeaders?: Record<string, string>,
97
+ ): Response {
82
98
  return Response.json(
83
99
  { ok: false, error: { code, message } },
84
- { status, headers: BASE_RESPONSE_HEADERS },
100
+ { status, headers: { ...BASE_RESPONSE_HEADERS, ...extraHeaders } },
85
101
  );
86
102
  }
87
103
 
104
+ /**
105
+ * The per-visitor budget (#7707, D1-D2): 8 calls per 10 minutes shared across
106
+ * the five visitor routes, 30 per 10 minutes on the session route alone, an
107
+ * in-process store capped at 10,000 entries. No env var tunes these (AC-8).
108
+ */
109
+ export const RELAY_RATE_LIMIT = Object.freeze({
110
+ limit: 8,
111
+ sessionLimit: 30,
112
+ windowMs: 600_000,
113
+ maxBuckets: 10_000,
114
+ });
115
+
116
+ type RateLimitBucket = {
117
+ count: number;
118
+ windowStartMs: number;
119
+ };
120
+
121
+ // Module-scoped store, shared by the visitor (`visitor|<identity>`) and
122
+ // session (`session|<identity>`) budgets (D7: same identity, window and
123
+ // store). Ported from the hub's own limiter
124
+ // (`apps/api/src/middleware/rate-limit.ts`), not imported — `apps/web`
125
+ // cannot import `apps/api`.
126
+ const rateLimitBuckets = new Map<string, RateLimitBucket>();
127
+ let requestsSinceSweep = 0;
128
+ const SWEEP_INTERVAL_REQUESTS = 100;
129
+
130
+ function evictExpiredBuckets(now: number): void {
131
+ for (const [key, bucket] of rateLimitBuckets) {
132
+ if (now - bucket.windowStartMs >= RELAY_RATE_LIMIT.windowMs) {
133
+ rateLimitBuckets.delete(key);
134
+ }
135
+ }
136
+ }
137
+
138
+ /** Test-only reset (D8). Not reachable over HTTP. */
139
+ export function __resetRelayRateLimit(): void {
140
+ rateLimitBuckets.clear();
141
+ requestsSinceSweep = 0;
142
+ }
143
+
144
+ /** Test-only accessor for the current distinct-bucket count (D8). */
145
+ export function __relayRateLimitBucketCount(): number {
146
+ return rateLimitBuckets.size;
147
+ }
148
+
149
+ /**
150
+ * Count one request against `key`, opening a new window when needed (D6). A
151
+ * genuinely new identity at capacity evicts the single oldest-inserted entry
152
+ * first (`Map` iteration order is insertion order) — the hub's own `hit()`.
153
+ */
154
+ function hit(key: string, now: number): RateLimitBucket {
155
+ let bucket = rateLimitBuckets.get(key);
156
+ if (!bucket || now - bucket.windowStartMs >= RELAY_RATE_LIMIT.windowMs) {
157
+ if (
158
+ !rateLimitBuckets.has(key) &&
159
+ rateLimitBuckets.size >= RELAY_RATE_LIMIT.maxBuckets
160
+ ) {
161
+ const oldestKey = rateLimitBuckets.keys().next().value;
162
+ if (oldestKey !== undefined) rateLimitBuckets.delete(oldestKey);
163
+ }
164
+ bucket = { count: 0, windowStartMs: now };
165
+ rateLimitBuckets.set(key, bucket);
166
+ }
167
+ bucket.count += 1;
168
+ return bucket;
169
+ }
170
+
171
+ /**
172
+ * The eight hextets of a valid IPv6 address, or `null`. Ported from the
173
+ * hub's own `expandIpv6` (`apps/api/src/middleware/rate-limit.ts`).
174
+ */
175
+ function expandIpv6(address: string): number[] | null {
176
+ let text = address;
177
+ const tail: number[] = [];
178
+ const lastColon = text.lastIndexOf(":");
179
+ const last = text.slice(lastColon + 1);
180
+ if (last.includes(".")) {
181
+ const octets = last.split(".").map(Number);
182
+ tail.push(((octets[0] ?? 0) << 8) | (octets[1] ?? 0));
183
+ tail.push(((octets[2] ?? 0) << 8) | (octets[3] ?? 0));
184
+ text = `${text.slice(0, lastColon + 1)}0`;
185
+ }
186
+ const halves = text.split("::");
187
+ const parse = (part: string | undefined): number[] =>
188
+ part ? part.split(":").map((hextet) => parseInt(hextet, 16)) : [];
189
+ const head = parse(halves[0]);
190
+ const rest = parse(halves[1]);
191
+ if (tail.length > 0) {
192
+ (halves.length > 1 ? rest : head).pop();
193
+ }
194
+ const fill = 8 - head.length - rest.length - tail.length;
195
+ if (fill < 0 || (halves.length === 1 && fill !== 0)) return null;
196
+ const hextets = [
197
+ ...head,
198
+ ...new Array<number>(fill).fill(0),
199
+ ...rest,
200
+ ...tail,
201
+ ];
202
+ return hextets.every((h) => Number.isInteger(h) && h >= 0 && h <= 0xffff)
203
+ ? hextets
204
+ : null;
205
+ }
206
+
207
+ const MAX_VISITOR_IP_LENGTH = 45;
208
+
209
+ /**
210
+ * Validate and normalise a visitor address (D3): IPv4 as is, an IPv4-mapped
211
+ * IPv6 address as its IPv4 form, any other IPv6 address as its /64 prefix.
212
+ * `null` for anything else. Ported from the hub's own `normaliseVisitorIp`.
213
+ */
214
+ function normaliseVisitorIp(raw: string): string | null {
215
+ const value = raw.trim().toLowerCase();
216
+ if (value.length > MAX_VISITOR_IP_LENGTH || value.includes("%")) return null;
217
+ const family = isIP(value);
218
+ if (family === 4) return value;
219
+ if (family !== 6) return null;
220
+ const hextets = expandIpv6(value);
221
+ if (!hextets) return null;
222
+ const isV4Mapped =
223
+ hextets.slice(0, 5).every((h) => h === 0) && hextets[5] === 0xffff;
224
+ if (isV4Mapped) {
225
+ const high = hextets[6] ?? 0;
226
+ const low = hextets[7] ?? 0;
227
+ return [high >> 8, high & 0xff, low >> 8, low & 0xff].join(".");
228
+ }
229
+ const prefix = hextets
230
+ .slice(0, 4)
231
+ .map((h) => h.toString(16).padStart(4, "0"))
232
+ .join(":");
233
+ return `${prefix}::/64`;
234
+ }
235
+
236
+ /**
237
+ * The visitor's identity (D3): the first comma-separated value of
238
+ * `x-forwarded-for` when present (a present-but-empty value does not fall
239
+ * back to `x-real-ip`), else `x-real-ip`. Anything that does not normalise —
240
+ * no header, an empty value, or one `normaliseVisitorIp` rejects — shares the
241
+ * single identity `"unknown"`, never a fresh bucket of its own.
242
+ */
243
+ function visitorIdentity(request: Request): string {
244
+ const raw =
245
+ request.headers.get("x-forwarded-for") ?? request.headers.get("x-real-ip");
246
+ const first = raw?.split(",")[0]?.trim().toLowerCase();
247
+ if (!first) return "unknown";
248
+ return normaliseVisitorIp(first) ?? "unknown";
249
+ }
250
+
251
+ /**
252
+ * Count one request against its budget (D1-D7): the visitor budget for every
253
+ * entry but the session route, which gets its own, wider, independent
254
+ * budget. Returns whether the call is admitted and the whole seconds (at
255
+ * least 1) until the tripped bucket's window ends.
256
+ */
257
+ function checkRateLimit(
258
+ request: Request,
259
+ entry: RelayRoute,
260
+ now: number,
261
+ ): { allowed: boolean; retryAfterSeconds: number } {
262
+ requestsSinceSweep += 1;
263
+ if (requestsSinceSweep >= SWEEP_INTERVAL_REQUESTS) {
264
+ evictExpiredBuckets(now);
265
+ requestsSinceSweep = 0;
266
+ }
267
+
268
+ const identity = visitorIdentity(request);
269
+ const isSession = entry === SESSION_ROUTE;
270
+ const key = `${isSession ? "session" : "visitor"}|${identity}`;
271
+ const limit = isSession
272
+ ? RELAY_RATE_LIMIT.sessionLimit
273
+ : RELAY_RATE_LIMIT.limit;
274
+
275
+ const bucket = hit(key, now);
276
+ const retryAfterSeconds = Math.max(
277
+ 1,
278
+ Math.ceil((bucket.windowStartMs + RELAY_RATE_LIMIT.windowMs - now) / 1000),
279
+ );
280
+ return { allowed: bucket.count <= limit, retryAfterSeconds };
281
+ }
282
+
88
283
  /**
89
284
  * True when the site has an API address to forward to. `getApiBase()` falls
90
285
  * back to `http://localhost:3001` for local development; a production site
@@ -94,7 +289,7 @@ function hubConfigured(): boolean {
94
289
  if (process.env.NODE_ENV !== "production") return true;
95
290
  return Boolean(
96
291
  process.env.AILK_API_URL ||
97
- process.env.NEXT_PUBLIC_AILK_API_URL ||
292
+ browserApiOrigin() ||
98
293
  process.env.NEXT_PUBLIC_API_URL,
99
294
  );
100
295
  }
@@ -220,6 +415,17 @@ export async function handleRelay(request: Request): Promise<Response> {
220
415
  return refusal(403, "cross_site", "Cross-site request refused");
221
416
  }
222
417
 
418
+ const { allowed, retryAfterSeconds } = checkRateLimit(
419
+ request,
420
+ entry,
421
+ Date.now(),
422
+ );
423
+ if (!allowed) {
424
+ return refusal(429, "rate_limited", "Too many requests", {
425
+ "retry-after": String(retryAfterSeconds),
426
+ });
427
+ }
428
+
223
429
  let body: string | undefined;
224
430
  if (entry.method === "POST") {
225
431
  if (!isJson(request.headers.get("content-type"))) {
@@ -24,6 +24,9 @@
24
24
 
25
25
  import { cookies, headers } from "next/headers";
26
26
 
27
+ import { hubSessionInit } from "./hub-session/session";
28
+ import { SIGN_IN_PROVIDER } from "./topology.generated";
29
+
27
30
  /**
28
31
  * Structural mirror of `@working-theory/api-client`'s `ApiResult<T>`
29
32
  * (`packages/api-client/src/http.ts`) — not imported, because that type is
@@ -75,10 +78,17 @@ interface ErrorBody {
75
78
  * pass the check exactly as a browser-originated one would. RSC `GET`s are
76
79
  * unaffected: `isOriginAllowed` short-circuits `true` for safe methods before
77
80
  * it ever inspects the header.
81
+ *
82
+ * On a spoke that signs in through its hub (`SIGN_IN_PROVIDER === "hub"`,
83
+ * #7894 D12) this is the ONE request-init re-target: it returns the hub
84
+ * credential as `Authorization: Bearer` (`hubSessionInit`), and no header at
85
+ * all signed out — the hub has no cookie session for this site's visitor.
86
+ * Every caller passes the result straight to the api-client unchanged.
78
87
  */
79
88
  export async function forwardedCookieInit(): Promise<{
80
- headers: { cookie: string; origin?: string };
89
+ headers: Record<string, string>;
81
90
  }> {
91
+ if (SIGN_IN_PROVIDER === "hub") return hubSessionInit();
82
92
  const cookieHeader = (await cookies()).toString();
83
93
  const origin = (await headers()).get("origin");
84
94
  return { headers: { cookie: cookieHeader, ...(origin ? { origin } : {}) } };
@@ -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,33 @@
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 database of its own: by default it
6
+ // has no sign-in, so its render reads no session and imports nothing; with
7
+ // `--hub-sign-in` (#7894) it reads the sealed hub-session cookie.
8
+
9
+ export const TOPOLOGY: "hub" | "spoke" = "hub";
10
+ export const SIGN_IN_ENABLED: boolean = true;
11
+ export const SIGN_IN_PROVIDER: "site" | "hub" | "none" = "site";
12
+ export const SIGN_IN_PATH: string | null = "/sign-in";
13
+ export const SIGN_UP_PATH: string | null = "/sign-up";
14
+ export const SIGN_OUT_ENDPOINT: string | null = null;
15
+
16
+ export type SiteSession = {
17
+ user: {
18
+ name?: string | null;
19
+ email: string;
20
+ emailVerified: boolean;
21
+ createdAt?: Date | string | null;
22
+ };
23
+ } | null;
24
+
25
+ export async function readSession(headers: Headers): Promise<SiteSession> {
26
+ const { auth } = await import("../auth");
27
+ return auth.api.getSession({ headers });
28
+ }
29
+
30
+ export async function registerSiteStores(): Promise<void> {
31
+ const { registerLicenseKeyStore } = await import("./license-key-store");
32
+ await registerLicenseKeyStore();
33
+ }
@@ -303,6 +303,16 @@
303
303
  "ctaLabel": "Back to services"
304
304
  }
305
305
  },
306
+ "checkoutThankYou": {
307
+ "title": "Thank you",
308
+ "body": "Your payment went through. We'll email you a receipt and the next steps."
309
+ },
310
+ "checkoutUnavailable": {
311
+ "title": "Checkout isn't available right now",
312
+ "body": "Nothing was charged. Please try again in a few minutes.",
313
+ "ctaHeadline": "Need a hand?",
314
+ "ctaLabel": "Contact us"
315
+ },
306
316
  "resources": {
307
317
  "heading": "Resources",
308
318
  "emptyState": "Resources listing coming soon."
@@ -649,7 +659,8 @@
649
659
  "unsaved": "Unsaved changes",
650
660
  "clean": "No unsaved changes",
651
661
  "nameRequired": "Enter your name.",
652
- "saveError": "We couldn't save your name. Try again."
662
+ "saveError": "We couldn't save your name. Try again.",
663
+ "manageOnHub": "Manage your account on our main site"
653
664
  },
654
665
  "security": {
655
666
  "title": "Security",
@@ -660,7 +671,8 @@
660
671
  "magicLink": "Email sign-in link",
661
672
  "password": "Password",
662
673
  "available": "Available",
663
- "none": "No sign-in method is configured."
674
+ "none": "No sign-in method is configured.",
675
+ "viaHub": "You sign in through our main site. Your sign-in methods are managed there."
664
676
  },
665
677
  "notifications": {
666
678
  "title": "Notifications",
@@ -696,7 +708,10 @@
696
708
  "emptyHeadline": "Sign-in isn't available yet",
697
709
  "emptyDescription": "This site hasn't turned on sign-in for members yet. Check back soon, or contact the site owner.",
698
710
  "signInLink": "Go to the sign-in page",
699
- "homeLink": "Back to the site"
711
+ "homeLink": "Back to the site",
712
+ "hubHeadline": "Sign in to your account",
713
+ "hubDescription": "You sign in on our main site, then come straight back here.",
714
+ "hubSignIn": "Sign in"
700
715
  },
701
716
  "profile": {
702
717
  "email": "Email",
@@ -718,6 +733,20 @@
718
733
  "period": "Period",
719
734
  "date": "Date"
720
735
  },
736
+ "pieces": {
737
+ "heading": "My pieces",
738
+ "type": "Piece",
739
+ "stage": "Stage",
740
+ "createdAt": "Started",
741
+ "updatedAt": "Updated",
742
+ "needsClient": "This piece is waiting on you."
743
+ },
744
+ "messages": {
745
+ "heading": "My messages",
746
+ "sentAt": "Sent",
747
+ "file": "File",
748
+ "download": "Download {filename}"
749
+ },
721
750
  "subscription": {
722
751
  "headline": "Your payment didn't go through",
723
752
  "action": "Update payment method"
@@ -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,56 @@
1
1
  # @working-theory/database
2
2
 
3
+ ## 0.38.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#8037](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/8037) [`89eba8c`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/89eba8c2bab16766a68284f52c872aae86f59fde) Thanks [@samuelhenry](https://github.com/samuelhenry)! - The member area contract ([#7877](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/7877)): a client's pieces, plan and messages.
8
+
9
+ - `@working-theory/database` — `Piece` and `Interaction` on Person (a composite `(person_id, workspace_id)` foreign key to `people`) and `WorkspacePieceStages`, a workspace's declared piece stages; migration `20261011120000_member_area`.
10
+ - `@working-theory/ids` — the `piece_`, `intx_` and `pstages_` prefixes; `zPieceId` and `zInteractionId`.
11
+ - `@working-theory/validation` — the member-area wire schemas (`pieces.ts`, `member.ts`), the Person timeline's `message` kind, the `people-write` permission (owner, admin; not agent-grantable) and the attachment helpers (`ATTACHABLE_CONTENT_TYPES`, `attachmentFilename`).
12
+ - `@working-theory/api-client` — `member.pieces`, `member.messages` and `member.messageFile` (with `getRaw`), the reads a spoke's server makes for its signed-in client. A spoke that re-serves a file sends its five response headers unchanged.
13
+ - `create-ailk` — the member-area files are co-owned by the `workspace` and `multi-tenant` slices.
14
+
15
+ - [#8049](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/8049) [`6a2a120`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/6a2a120765c423303716d5e3ebf216c9c10e118b) Thanks [@samuelhenry](https://github.com/samuelhenry)! - An agent key may be granted `member-pieces-manage` ([#7878](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/7878)).
16
+
17
+ `@working-theory/validation`: the `member-pieces-manage` permission (owner, admin), the seventh entry of `AGENT_GRANTABLE_PERMISSIONS`. The member area's operator writes — a client's pieces, plan and outbound messages — check it through `allows`, so an agent granted it keeps them in its one workspace; `people-write` stays never-grantable. `@working-theory/database`: the `agents_grants_allowed` CHECK widened to admit it (additive).
18
+
19
+ - [#8004](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/8004) [`d9ba65f`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/d9ba65f6334681b17bd77b64bb7ca9e069b1980f) Thanks [@claudio-planck](https://github.com/claudio-planck)! - Person — the per-workspace client record ([#852](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/852)).
20
+
21
+ `@working-theory/database`: a `Person` model (one client per normalized email per workspace), `personId` on `Lead` and `Appointment`, the schema and set-based backfill migrations, the post-cutover re-run script `scripts/person-backfill.sql`, the agent-grant CHECK widened to admit `people-read`, and the timeline's email-fallback SQL constants on the `tenancy` subpath. `@working-theory/ids`: the `person_` prefix and `zPersonId`. `@working-theory/validation`: the `people-read` permission (owner, admin; agent-grantable) and the People wire schemas. `@working-theory/api-client`: `people.list` and `people.get` for an agent key granted `people-read`.
22
+
23
+ ### Patch Changes
24
+
25
+ - Updated dependencies [[`89eba8c`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/89eba8c2bab16766a68284f52c872aae86f59fde), [`6a2a120`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/6a2a120765c423303716d5e3ebf216c9c10e118b), [`6d38b1a`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/6d38b1a5362b36e56d6bce79fdf96c878020cb18), [`d9ba65f`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/d9ba65f6334681b17bd77b64bb7ca9e069b1980f)]:
26
+ - @working-theory/validation@0.38.0
27
+ - @working-theory/ids@0.11.0
28
+
29
+ ## 0.37.0
30
+
31
+ ### Minor Changes
32
+
33
+ - [#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.
34
+
35
+ - `@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.
36
+
37
+ - [#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
38
+ permissions — content-edit, content-publish, assets-write, magnets-manage and
39
+ meeting-types-manage. `@working-theory/validation` adds
40
+ `AGENT_GRANTABLE_PERMISSIONS`, `isAgentGrantable`, `allows` and
41
+ `setAgentGrantsBodySchema`, and the agent wire row gains `grants`;
42
+ `@working-theory/database` adds the `agents.grants` column, held to those five
43
+ by a CHECK. An agent with no grants behaves exactly as before.
44
+
45
+ - [#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.
46
+
47
+ - `@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.
48
+
49
+ ### Patch Changes
50
+
51
+ - 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)]:
52
+ - @working-theory/validation@0.37.0
53
+
3
54
  ## 0.36.0
4
55
 
5
56
  ### Patch Changes
@@ -18,11 +18,43 @@
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 seven
22
+ * grantable permissions (`people-read` since #852 D9,
23
+ * `member-pieces-manage` since #7878), and the `agents_grants_allowed`
24
+ * CHECK refuses anything else.
25
+ *
26
+ * One case needs no database and always runs: the latest migration's CHECK names
27
+ * exactly `@working-theory/validation`'s `AGENT_GRANTABLE_PERMISSIONS`, so the
28
+ * SQL and the TypeScript spelling of the list cannot drift (SEC-7864-G).
21
29
  */
30
+ import { readFileSync } from "node:fs";
31
+ import { join } from "node:path";
32
+
33
+ import { AGENT_GRANTABLE_PERMISSIONS } from "@working-theory/validation";
22
34
  import { Client } from "pg";
23
35
 
24
36
  const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
25
37
 
38
+ // #7878 — the CHECK as it stands now: the latest migration that re-adds it.
39
+ const GRANTS_MIGRATION = join(
40
+ __dirname,
41
+ "..",
42
+ "migrations",
43
+ "20261011130000_agent_grants_member_pieces_manage",
44
+ "migration.sql",
45
+ );
46
+
47
+ describe("the agents_grants_allowed CHECK (#7864 D2, AC-2)", () => {
48
+ it("names exactly AGENT_GRANTABLE_PERMISSIONS", () => {
49
+ const sql = readFileSync(GRANTS_MIGRATION, "utf8");
50
+ const check = /CHECK \("grants" <@ ARRAY\[([^\]]*)\]::TEXT\[\]\)/.exec(sql);
51
+ expect(check).not.toBeNull();
52
+ const listed = [...check![1]!.matchAll(/'([^']*)'/g)].map((m) => m[1]);
53
+ expect(new Set(listed)).toEqual(new Set(AGENT_GRANTABLE_PERMISSIONS));
54
+ expect(listed).toHaveLength(AGENT_GRANTABLE_PERMISSIONS.length);
55
+ });
56
+ });
57
+
26
58
  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
59
 
28
60
  describeDb("the agents table (#3591, live DB)", () => {
@@ -172,6 +204,59 @@ describeDb("the agents table (#3591, live DB)", () => {
172
204
  });
173
205
  });
174
206
 
207
+ describe("grants (#7864 D2, AC-2)", () => {
208
+ async function grantsOf(id: string): Promise<string[]> {
209
+ return (
210
+ await db.query<{ grants: string[] }>(
211
+ `SELECT "grants" FROM "agents" WHERE "id" = $1`,
212
+ [id],
213
+ )
214
+ ).rows[0]!.grants;
215
+ }
216
+
217
+ it("a new agent has no grants", async () => {
218
+ expect(await grantsOf(await insertAgent())).toEqual([]);
219
+ });
220
+
221
+ it("takes grantable permissions", async () => {
222
+ const id = await insertAgent();
223
+ await db.query(
224
+ `UPDATE "agents" SET "grants" = ARRAY['content-edit','magnets-manage']::TEXT[] WHERE "id" = $1`,
225
+ [id],
226
+ );
227
+ expect(await grantsOf(id)).toEqual(["content-edit", "magnets-manage"]);
228
+ });
229
+
230
+ it("takes people-read (#852 D9)", async () => {
231
+ const id = await insertAgent();
232
+ await db.query(
233
+ `UPDATE "agents" SET "grants" = ARRAY['people-read']::TEXT[] WHERE "id" = $1`,
234
+ [id],
235
+ );
236
+ expect(await grantsOf(id)).toEqual(["people-read"]);
237
+ });
238
+
239
+ it("takes member-pieces-manage (#7878)", async () => {
240
+ const id = await insertAgent();
241
+ await db.query(
242
+ `UPDATE "agents" SET "grants" = ARRAY['member-pieces-manage','people-read']::TEXT[] WHERE "id" = $1`,
243
+ [id],
244
+ );
245
+ expect(await grantsOf(id)).toEqual(["member-pieces-manage", "people-read"]);
246
+ });
247
+
248
+ it("refuses any other permission", async () => {
249
+ const id = await insertAgent();
250
+ await expect(
251
+ db.query(
252
+ `UPDATE "agents" SET "grants" = ARRAY['content-edit','agents-manage']::TEXT[] WHERE "id" = $1`,
253
+ [id],
254
+ ),
255
+ ).rejects.toThrow(/agents_grants_allowed/);
256
+ expect(await grantsOf(id)).toEqual([]);
257
+ });
258
+ });
259
+
175
260
  it("a credential digest is unique", async () => {
176
261
  const hash = nextHash();
177
262
  await insertAgent({ credential_hash: hash });