create-ailk 0.12.1 → 0.13.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 (55) hide show
  1. package/dist/module-architecture.js +27 -0
  2. package/package.json +3 -3
  3. package/templates/.env.example +23 -9
  4. package/templates/apps/api/CLAUDE.md +21 -17
  5. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +98 -0
  6. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +321 -0
  7. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +117 -0
  8. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +39 -14
  9. package/templates/apps/api/src/config/index.ts +35 -20
  10. package/templates/apps/api/src/lib/__mocks__/prisma-client.js +3 -0
  11. package/templates/apps/api/src/lib/__mocks__/prisma.ts +14 -0
  12. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +468 -0
  13. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +32 -2
  14. package/templates/apps/api/src/lib/agent-store.ts +521 -0
  15. package/templates/apps/api/src/lib/spoke-store.ts +13 -2
  16. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +384 -1
  17. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +61 -0
  18. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +176 -0
  19. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +76 -0
  20. package/templates/apps/api/src/middleware/auth.ts +219 -5
  21. package/templates/apps/api/src/middleware/default-tenant.ts +29 -8
  22. package/templates/apps/api/src/middleware/rate-limit.ts +35 -13
  23. package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +76 -15
  24. package/templates/apps/api/src/routes/leads/index.ts +14 -6
  25. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -6
  26. package/templates/apps/api/src/routes/project-listings/index.ts +14 -9
  27. package/templates/apps/api/src/routes/project-listings/me.ts +4 -3
  28. package/templates/apps/api/src/routes/project-listings/patch-copy.ts +7 -5
  29. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +198 -0
  30. package/templates/apps/api/src/server.ts +27 -0
  31. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +374 -57
  32. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +25 -2
  33. package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +303 -17
  34. package/templates/apps/api/src/services/audit-log.ts +278 -47
  35. package/templates/apps/api/src/services/default-tenant.ts +55 -5
  36. package/templates/apps/api/src/services/leads-inbox.ts +12 -21
  37. package/templates/apps/api/src/services/project-listings.ts +1 -21
  38. package/templates/apps/api/src/services/public-site-resolver.ts +103 -13
  39. package/templates/apps/mcp/CLAUDE.md +44 -5
  40. package/templates/apps/mcp/__tests__/parity.test.ts +53 -1
  41. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +364 -0
  42. package/templates/apps/mcp/src/http-transport.ts +107 -1
  43. package/templates/apps/mcp/src/workspace-server.ts +193 -0
  44. package/templates/apps/web/.env.example +64 -37
  45. package/templates/apps/web/app/v1/[...path]/route.ts +16 -0
  46. package/templates/apps/web/lib/__tests__/relay.test.ts +515 -0
  47. package/templates/apps/web/lib/relay.ts +279 -0
  48. package/templates/apps/web/middleware.ts +5 -4
  49. package/templates/apps/web/next.config.mjs +6 -0
  50. package/templates/database/CHANGELOG.md +15 -0
  51. package/templates/database/__tests__/agents.db.test.ts +235 -0
  52. package/templates/database/auth/schema.prisma +61 -0
  53. package/templates/database/migrations/20261005120000_agents/migration.sql +84 -0
  54. package/templates/database/package.json +1 -1
  55. package/versions.json +34 -34
@@ -0,0 +1,279 @@
1
+ /**
2
+ * The same-origin relay (#7644, spec docs/specs/7644-the-browser-talks-only-to-spec.md).
3
+ *
4
+ * A visitor's browser posts lead capture, newsletter signup, flow submits and
5
+ * booking calls to THIS site's own address — `resolveSubmitUrl` leaves a
6
+ * root-relative action alone in a build of this app (next.config.mjs sets
7
+ * `NEXT_PUBLIC_AILK_SAME_ORIGIN_RELAY`). `app/v1/[...path]/route.ts` hands
8
+ * every `/v1/...` request here, and this module forwards it server-side to
9
+ * the hub through `@working-theory/api-client`'s `relayToApi`, which attaches
10
+ * the site's own `AILK_SPOKE_KEY` when it has one. A browser never holds the
11
+ * key and never calls the hub, so a hub can refuse callers it does not know
12
+ * (#7633) without taking any site's forms down. A single-site install takes
13
+ * the same path with no key.
14
+ *
15
+ * What it guarantees:
16
+ *
17
+ * - ONLY THE LIST (D2). `RELAY_ROUTES` is the fixed set of (method, path)
18
+ * pairs a browser form calls; an exact, case-sensitive match on the
19
+ * pathname or a 404 with no hub call. Adding a route is a reviewed change
20
+ * to this list, its test and apps/web/.env.example.
21
+ * - NO CROSS-SITE POST (D4). `Sec-Fetch-Site` other than `same-origin`, or
22
+ * an `Origin` whose host is not this site's own, is refused with 403
23
+ * before the hub is called. A POST must be JSON, which no cross-origin
24
+ * browser can send without a preflight this route never answers.
25
+ * - THE VISITOR, UNCHANGED (D3). `x-forwarded-for` as received (else
26
+ * `x-real-ip`) and `user-agent` reach the hub, so per-visitor rate limits
27
+ * still count each visitor (#7615's rule; `flow.actions.ts`).
28
+ * - AN ALLOW-LIST OF HEADERS (D3). Nothing else from the incoming request is
29
+ * forwarded: no `authorization`, no client-sent `x-ailk-*`, no `host`, and
30
+ * a `cookie` only on the one session route.
31
+ *
32
+ * NO SECRET IS LOGGED. A failure logs `{ path, outcome }` and nothing else.
33
+ */
34
+ import { relayToApi, visitorForwardHeaders } from "@working-theory/api-client";
35
+
36
+ type RelayMethod = "GET" | "POST";
37
+
38
+ /** One relayed (method, path) pair. */
39
+ export interface RelayRoute {
40
+ readonly method: RelayMethod;
41
+ readonly path: string;
42
+ }
43
+
44
+ function route(method: RelayMethod, path: string): RelayRoute {
45
+ return Object.freeze({ method, path });
46
+ }
47
+
48
+ /**
49
+ * The fixed list (D2). The scheduling and commerce paths are plain strings:
50
+ * on a hub without those modules the hub's own 404 passes through.
51
+ */
52
+ /**
53
+ * The one session route: a site-admin, cookie-authenticated write. It is
54
+ * forwarded with the visitor's `cookie` and `Origin` (the hub still checks
55
+ * that `Origin` against its trusted list), mirroring `forwardedCookieInit` in
56
+ * lib/server-api.ts. Every other entry is a visitor route.
57
+ */
58
+ const SESSION_ROUTE = route("POST", "/v1/commerce/invoices");
59
+
60
+ export const RELAY_ROUTES: readonly RelayRoute[] = Object.freeze([
61
+ route("POST", "/v1/leads/submit"),
62
+ route("POST", "/v1/leads/subscribe"),
63
+ route("POST", "/v1/leads/callback"),
64
+ route("GET", "/v1/schedule/availability"),
65
+ route("POST", "/v1/schedule/book"),
66
+ SESSION_ROUTE,
67
+ ]);
68
+
69
+ /** Fastify's default `bodyLimit`; the hub sets none (NFR4). */
70
+ const RELAY_BODY_LIMIT = 1_048_576;
71
+
72
+ /** Set on every forwarded call; an incoming request carrying it is a loop. */
73
+ const RELAYED_HEADER = "x-ailk-relayed";
74
+
75
+ /** Set on every answer: never cached, never content-sniffed by the browser. */
76
+ const BASE_RESPONSE_HEADERS = {
77
+ "cache-control": "no-store",
78
+ "x-content-type-options": "nosniff",
79
+ } as const;
80
+
81
+ function refusal(status: number, code: string, message: string): Response {
82
+ return Response.json(
83
+ { ok: false, error: { code, message } },
84
+ { status, headers: BASE_RESPONSE_HEADERS },
85
+ );
86
+ }
87
+
88
+ /**
89
+ * True when the site has an API address to forward to. `getApiBase()` falls
90
+ * back to `http://localhost:3001` for local development; a production site
91
+ * with no address set must not send its visitors' calls (and its key) there.
92
+ */
93
+ function hubConfigured(): boolean {
94
+ if (process.env.NODE_ENV !== "production") return true;
95
+ return Boolean(
96
+ process.env.AILK_API_URL ||
97
+ process.env.NEXT_PUBLIC_AILK_API_URL ||
98
+ process.env.NEXT_PUBLIC_API_URL,
99
+ );
100
+ }
101
+
102
+ /**
103
+ * The host the browser addressed: the first `x-forwarded-host`, else `host`.
104
+ * The site's edge must overwrite `x-forwarded-host`, never pass a client's on.
105
+ */
106
+ function ownHost(request: Request): string {
107
+ const forwarded = request.headers.get("x-forwarded-host")?.split(",")[0];
108
+ if (forwarded) return forwarded.trim();
109
+ return request.headers.get("host") ?? new URL(request.url).host;
110
+ }
111
+
112
+ /**
113
+ * True when the request is a cross-site browser call (D4 step 3). A request
114
+ * with neither `Sec-Fetch-Site` nor `Origin` is a non-browser caller and is
115
+ * admitted: it can forge either header anyway.
116
+ */
117
+ function isCrossSite(request: Request): boolean {
118
+ const fetchSite = request.headers.get("sec-fetch-site");
119
+ if (fetchSite !== null && fetchSite !== "same-origin") return true;
120
+
121
+ const origin = request.headers.get("origin");
122
+ if (origin === null) return false;
123
+ try {
124
+ const originUrl = new URL(origin);
125
+ // Parsing the own host under the Origin's scheme normalises a default
126
+ // port and letter case, so `spoke.example:443` equals `spoke.example`.
127
+ const own = new URL(`${originUrl.protocol}//${ownHost(request)}`);
128
+ return originUrl.host !== own.host;
129
+ } catch {
130
+ // `null`, or anything that is not a URL.
131
+ return true;
132
+ }
133
+ }
134
+
135
+ function isJson(contentType: string | null): boolean {
136
+ return (
137
+ contentType?.split(";")[0]?.trim().toLowerCase() === "application/json"
138
+ );
139
+ }
140
+
141
+ /**
142
+ * The body as text, or `null` once it passes `limit` bytes. Read as a stream
143
+ * so an oversized body is refused without buffering all of it.
144
+ */
145
+ async function readCapped(
146
+ request: Request,
147
+ limit: number,
148
+ ): Promise<string | null> {
149
+ const declared = Number(request.headers.get("content-length"));
150
+ if (Number.isFinite(declared) && declared > limit) return null;
151
+ if (!request.body) return "";
152
+
153
+ const reader = request.body.getReader();
154
+ const chunks: Uint8Array[] = [];
155
+ let size = 0;
156
+ for (;;) {
157
+ const { done, value } = await reader.read();
158
+ if (done) break;
159
+ size += value.byteLength;
160
+ if (size > limit) {
161
+ await reader.cancel();
162
+ return null;
163
+ }
164
+ chunks.push(value);
165
+ }
166
+ const bytes = new Uint8Array(size);
167
+ let offset = 0;
168
+ for (const chunk of chunks) {
169
+ bytes.set(chunk, offset);
170
+ offset += chunk.byteLength;
171
+ }
172
+ return new TextDecoder().decode(bytes);
173
+ }
174
+
175
+ /** The outbound headers, built from an allow-list (D3). Never a spread of the incoming set. */
176
+ function relayHeaders(
177
+ request: Request,
178
+ entry: RelayRoute,
179
+ ): Record<string, string> {
180
+ const incoming = request.headers;
181
+ const out: Record<string, string> = { [RELAYED_HEADER]: "1" };
182
+
183
+ const contentType = incoming.get("content-type");
184
+ if (entry.method === "POST" && contentType) out["content-type"] = contentType;
185
+
186
+ const userAgent = incoming.get("user-agent");
187
+ if (userAgent) out["user-agent"] = userAgent;
188
+
189
+ const visitorIp =
190
+ incoming.get("x-forwarded-for") ?? incoming.get("x-real-ip");
191
+ if (visitorIp) out["x-forwarded-for"] = visitorIp;
192
+
193
+ if (entry === SESSION_ROUTE) {
194
+ const cookie = incoming.get("cookie");
195
+ if (cookie) out.cookie = cookie;
196
+ const origin = incoming.get("origin");
197
+ if (origin) out.origin = origin;
198
+ } else {
199
+ Object.assign(
200
+ out,
201
+ visitorForwardHeaders(incoming, process.env.AILK_VISITOR_FORWARD_KEY),
202
+ );
203
+ }
204
+ return out;
205
+ }
206
+
207
+ /** Answer one `/v1/...` request to this site: refuse it, or forward it to the hub. */
208
+ export async function handleRelay(request: Request): Promise<Response> {
209
+ if (request.headers.has(RELAYED_HEADER)) {
210
+ return refusal(508, "relay_loop", "Request already relayed");
211
+ }
212
+
213
+ const url = new URL(request.url);
214
+ const entry = RELAY_ROUTES.find(
215
+ (r) => r.method === request.method && r.path === url.pathname,
216
+ );
217
+ if (!entry) return refusal(404, "not_found", "Not found");
218
+
219
+ if (isCrossSite(request)) {
220
+ return refusal(403, "cross_site", "Cross-site request refused");
221
+ }
222
+
223
+ let body: string | undefined;
224
+ if (entry.method === "POST") {
225
+ if (!isJson(request.headers.get("content-type"))) {
226
+ return refusal(415, "unsupported_media_type", "Expected application/json");
227
+ }
228
+ const text = await readCapped(request, RELAY_BODY_LIMIT);
229
+ if (text === null) {
230
+ return refusal(413, "payload_too_large", "Request body too large");
231
+ }
232
+ body = text;
233
+ }
234
+
235
+ if (!hubConfigured()) {
236
+ console.error("[relay] hub call failed", {
237
+ path: entry.path,
238
+ outcome: "no-api-address",
239
+ });
240
+ return refusal(502, "relay_unreachable", "The site could not reach its API");
241
+ }
242
+
243
+ try {
244
+ const result = await relayToApi(
245
+ entry.method === "GET" ? `${entry.path}${url.search}` : entry.path,
246
+ {
247
+ method: entry.method,
248
+ headers: relayHeaders(request, entry),
249
+ ...(body !== undefined ? { body } : {}),
250
+ },
251
+ );
252
+ // Only a JSON answer keeps its content-type; anything else (an HTML error
253
+ // page from a proxy in front of the hub) is served as plain text, so it
254
+ // can never render as markup on this site's origin.
255
+ const headers: Record<string, string> = {
256
+ ...BASE_RESPONSE_HEADERS,
257
+ "content-type":
258
+ result.contentType && isJson(result.contentType)
259
+ ? result.contentType
260
+ : "text/plain; charset=utf-8",
261
+ };
262
+ if (result.retryAfter) headers["retry-after"] = result.retryAfter;
263
+ // A null-body status (204, 304) cannot carry even an empty string.
264
+ return new Response(result.body === "" ? null : result.body, {
265
+ status: result.status,
266
+ headers,
267
+ });
268
+ } catch (err) {
269
+ // The error's name only (TypeError for a refused redirect or a network
270
+ // failure, TimeoutError for a hub that never answered) — never its
271
+ // message, which can quote a request detail.
272
+ console.error("[relay] hub call failed", {
273
+ path: entry.path,
274
+ outcome: "unreachable",
275
+ error: err instanceof Error ? err.name : "unknown",
276
+ });
277
+ return refusal(502, "relay_unreachable", "The site could not reach its API");
278
+ }
279
+ }
@@ -12,9 +12,10 @@
12
12
  // Locale list: read once at module load from project.yaml via readLocales() (S1).
13
13
  // Static assets and internal Next.js paths are excluded via the matcher below.
14
14
  //
15
- // /api/* and /og are route handlers outside app/[locale] (#6864). They skip
16
- // next-intl — whose as-needed rewrite would send them to /en/api/… and /en/og,
17
- // which do not exist — but still get the security headers. They are exempted
15
+ // /api/*, /og and /v1/* are route handlers outside app/[locale] (#6864; /v1
16
+ // is the same-origin relay, #7644). They skip next-intl — whose as-needed
17
+ // rewrite would send them to /en/api/…, /en/og and /en/v1/…, which do not
18
+ // exist — but still get the security headers. They are exempted
18
19
  // here rather than in the matcher, because this middleware is the only place
19
20
  // the security headers are stamped.
20
21
  //
@@ -57,7 +58,7 @@ const intlMiddleware = createMiddleware({
57
58
 
58
59
  // Top-level app/ route directories that live outside [locale]. Kept in step
59
60
  // with app/ by the drift test in __tests__/i18n/middleware.test.ts.
60
- export const NON_LOCALE_ROUTE_PREFIXES = ["/api", "/og"] as const;
61
+ export const NON_LOCALE_ROUTE_PREFIXES = ["/api", "/og", "/v1"] as const;
61
62
 
62
63
  // Segment-bound: /og and /og/x are exempt; /ogden and /og-image are not.
63
64
  export function isNonLocaleRoute(pathname: string): boolean {
@@ -148,6 +148,12 @@ const nextConfig = {
148
148
  // Default locale baked at build time — consumed by the sitemap route for
149
149
  // x-default URL composition and bare-path default-locale serving.
150
150
  NEXT_PUBLIC_DEFAULT_LOCALE: defaultLocaleAtBuild,
151
+ // This site serves the same-origin relay (app/v1/[...path]/route.ts,
152
+ // #7644), so @working-theory/ui's resolveSubmitUrl leaves a form's
153
+ // root-relative action on this site's own address instead of rewriting it
154
+ // onto the hub's. Part of the source, not an operator setting: a build
155
+ // that carries the relay always carries the flag, and no other build does.
156
+ NEXT_PUBLIC_AILK_SAME_ORIGIN_RELAY: "1",
151
157
  },
152
158
  async headers() {
153
159
  if (!STEALTH) return [];
@@ -1,5 +1,20 @@
1
1
  # @working-theory/database
2
2
 
3
+ ## 0.35.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#7671](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/7671) [`25d4a69`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/25d4a699cefaac7cf9c73dafa802696ebdc995e8) Thanks [@claudio-planck](https://github.com/claudio-planck)! - The agent seat, step A ([#3591](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/3591), PR-A of docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md).
8
+
9
+ - `@working-theory/database` — a new `auth` model, `Agent`, and its enum `AgentPermissionLevel` (`member`): a stored delegate of one person, bound to one workspace, keeping only the SHA-256 digest of its key. The migration `20261005120000_agents` is purely additive: one table, its indexes, the ADR-0031 `agent_` primary-key CHECK and format CHECKs on every reference column. Deleting the organization, the workspace or the person's membership deletes the agent; deleting the membership of the person last shown its key clears `key_issued_to_membership_id`.
10
+ - `@working-theory/ids` — registers the `agent_` prefix and exports `zAgentId`.
11
+
12
+ ### Patch Changes
13
+
14
+ - Updated dependencies [[`589b167`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/589b167dcd5acb83cb37b46bd5cdb0a57a80df1a), [`25d4a69`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/25d4a699cefaac7cf9c73dafa802696ebdc995e8), [`0fb0f37`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/0fb0f3727db9ad32776b88f39ec8b0943671bcad), [`a6833f9`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/a6833f940da064c36f3150a4af614c9fd8b38628)]:
15
+ - @working-theory/validation@0.35.0
16
+ - @working-theory/ids@0.10.0
17
+
3
18
  ## 0.34.1
4
19
 
5
20
  ### Patch Changes
@@ -0,0 +1,235 @@
1
+ /**
2
+ * @file agents.db.test.ts
3
+ *
4
+ * LIVE-DATABASE proof of the agents table (#3591, spec
5
+ * docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md D1; AC-1 and AC-2's
6
+ * cascade legs). Requires DATABASE_URL pointing at a Postgres with the
7
+ * migrations applied — the whole suite SKIPS when DATABASE_URL is unset,
8
+ * keeping the workspace Jest run DB-free (the repo convention; the
9
+ * `apps/api Route Tests (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:
13
+ * 1. The six format CHECKs reject a malformed id.
14
+ * 2. `credential_hash` is unique.
15
+ * 3. Deleting the responsible person's membership, the workspace or the
16
+ * organization deletes the agent — so a re-invited person's new
17
+ * membership cannot revive it.
18
+ * 4. Deleting the membership of the person last shown the key sets
19
+ * `key_issued_to_membership_id` null and keeps the agent.
20
+ * 5. Deleting the creator's user row leaves the agent (no relation).
21
+ */
22
+ import { Client } from "pg";
23
+
24
+ const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
25
+
26
+ 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
+
28
+ describeDb("the agents table (#3591, live DB)", () => {
29
+ let db: Client;
30
+ let organizationId: string;
31
+ let workspaceId: string;
32
+ let ownerUserId: string;
33
+ let personUserId: string;
34
+ let ownerMembershipId: string;
35
+ let personMembershipId: string;
36
+ let hashSeq = 0;
37
+
38
+ /** A fresh 64-character digest per insert, so the unique key never collides by accident. */
39
+ function nextHash(): string {
40
+ hashSeq += 1;
41
+ return hashSeq.toString(16).padStart(64, "0");
42
+ }
43
+
44
+ async function insertUser(tag: string): Promise<string> {
45
+ return (
46
+ await db.query<{ id: string }>(
47
+ `INSERT INTO "users" ("email", "updated_at") VALUES ($1, now()) RETURNING "id"`,
48
+ [`agents3591-${tag}@example.test`],
49
+ )
50
+ ).rows[0]!.id;
51
+ }
52
+
53
+ async function insertOrganization(slug: string): Promise<string> {
54
+ return (
55
+ await db.query<{ id: string }>(
56
+ `INSERT INTO "organizations" ("name", "slug", "updated_at")
57
+ VALUES ($1, $1, now()) RETURNING "id"`,
58
+ [`agents3591-${slug}`],
59
+ )
60
+ ).rows[0]!.id;
61
+ }
62
+
63
+ async function insertWorkspace(orgId: string, slug: string): Promise<string> {
64
+ return (
65
+ await db.query<{ id: string }>(
66
+ `INSERT INTO "workspaces" ("organization_id", "name", "slug", "updated_at")
67
+ VALUES ($1, $2, $2, now()) RETURNING "id"`,
68
+ [orgId, slug],
69
+ )
70
+ ).rows[0]!.id;
71
+ }
72
+
73
+ async function insertMembership(orgId: string, userId: string, role: string): Promise<string> {
74
+ return (
75
+ await db.query<{ id: string }>(
76
+ `INSERT INTO "memberships" ("organization_id", "user_id", "role")
77
+ VALUES ($1, $2, $3::"Role") RETURNING "id"`,
78
+ [orgId, userId, role],
79
+ )
80
+ ).rows[0]!.id;
81
+ }
82
+
83
+ type AgentColumns = {
84
+ id?: string;
85
+ organization_id: string;
86
+ workspace_id: string;
87
+ responsible_membership_id: string;
88
+ created_by_user_id: string;
89
+ key_issued_to_membership_id: string | null;
90
+ credential_hash: string;
91
+ };
92
+
93
+ function defaults(): AgentColumns {
94
+ return {
95
+ organization_id: organizationId,
96
+ workspace_id: workspaceId,
97
+ responsible_membership_id: personMembershipId,
98
+ created_by_user_id: ownerUserId,
99
+ key_issued_to_membership_id: ownerMembershipId,
100
+ credential_hash: nextHash(),
101
+ };
102
+ }
103
+
104
+ /** Insert one agent; the database mints its id unless one is given. */
105
+ async function insertAgent(overrides: Partial<AgentColumns> = {}): Promise<string> {
106
+ const row = { ...defaults(), ...overrides };
107
+ const columns = Object.keys(row);
108
+ const values = Object.values(row);
109
+ const { rows } = await db.query<{ id: string }>(
110
+ `INSERT INTO "agents" (${columns.map((c) => `"${c}"`).join(", ")}, "name", "credential_prefix", "updated_at")
111
+ VALUES (${columns.map((_, i) => `$${i + 1}`).join(", ")}, 'inbox helper', 'ailk_agt_abcdefgh', now())
112
+ RETURNING "id"`,
113
+ values,
114
+ );
115
+ return rows[0]!.id;
116
+ }
117
+
118
+ async function agentRow(id: string): Promise<{ key_issued_to_membership_id: string | null } | undefined> {
119
+ return (
120
+ await db.query<{ key_issued_to_membership_id: string | null }>(
121
+ `SELECT "key_issued_to_membership_id" FROM "agents" WHERE "id" = $1`,
122
+ [id],
123
+ )
124
+ ).rows[0];
125
+ }
126
+
127
+ beforeAll(async () => {
128
+ db = new Client({ connectionString: process.env.DATABASE_URL });
129
+ await db.connect();
130
+ organizationId = await insertOrganization("org");
131
+ workspaceId = await insertWorkspace(organizationId, "agents3591-ws");
132
+ ownerUserId = await insertUser("owner");
133
+ personUserId = await insertUser("person");
134
+ ownerMembershipId = await insertMembership(organizationId, ownerUserId, "owner");
135
+ personMembershipId = await insertMembership(organizationId, personUserId, "member");
136
+ });
137
+
138
+ afterAll(async () => {
139
+ await db.query(`DELETE FROM "organizations" WHERE "slug" LIKE 'agents3591-%'`);
140
+ await db.query(`DELETE FROM "users" WHERE "email" LIKE 'agents3591-%'`);
141
+ await db.end();
142
+ });
143
+
144
+ describe("format CHECKs (ADR-0031)", () => {
145
+ it("an agent takes a database-minted agent_ id, a member ceiling and is enabled", async () => {
146
+ const id = await insertAgent();
147
+ expect(id).toMatch(AGENT_ID);
148
+ const { rows } = await db.query<{ permission_level: string; enabled: boolean }>(
149
+ `SELECT "permission_level", "enabled" FROM "agents" WHERE "id" = $1`,
150
+ [id],
151
+ );
152
+ expect(rows[0]).toEqual({ permission_level: "member", enabled: true });
153
+ });
154
+
155
+ it.each([
156
+ ["id", "agents_id_format"],
157
+ ["organization_id", "agents_organization_id_format"],
158
+ ["workspace_id", "agents_workspace_id_format"],
159
+ ["responsible_membership_id", "agents_responsible_membership_id_format"],
160
+ ["key_issued_to_membership_id", "agents_key_issued_to_membership_id_format"],
161
+ ["created_by_user_id", "agents_created_by_user_id_format"],
162
+ ] as const)("REJECTS a malformed %s", async (column, constraint) => {
163
+ // The format CHECK fires before any foreign key.
164
+ await expect(insertAgent({ [column]: "bad" })).rejects.toThrow(new RegExp(constraint));
165
+ });
166
+
167
+ it("refuses a ceiling outside the enum", async () => {
168
+ const id = await insertAgent();
169
+ await expect(
170
+ db.query(`UPDATE "agents" SET "permission_level" = 'owner' WHERE "id" = $1`, [id]),
171
+ ).rejects.toThrow(/AgentPermissionLevel/);
172
+ });
173
+ });
174
+
175
+ it("a credential digest is unique", async () => {
176
+ const hash = nextHash();
177
+ await insertAgent({ credential_hash: hash });
178
+ await expect(insertAgent({ credential_hash: hash })).rejects.toThrow(
179
+ /agents_credential_hash_key/,
180
+ );
181
+ });
182
+
183
+ describe("cascades (D1 — leaving stops the agent)", () => {
184
+ it("deleting the person's membership deletes their agent, and a re-invite does not revive it", async () => {
185
+ const user = await insertUser("leaver");
186
+ const membership = await insertMembership(organizationId, user, "member");
187
+ const agent = await insertAgent({ responsible_membership_id: membership });
188
+
189
+ await db.query(`DELETE FROM "memberships" WHERE "id" = $1`, [membership]);
190
+ expect(await agentRow(agent)).toBeUndefined();
191
+
192
+ const reinvited = await insertMembership(organizationId, user, "member");
193
+ expect(reinvited).not.toBe(membership);
194
+ expect(await agentRow(agent)).toBeUndefined();
195
+ });
196
+
197
+ it("deleting the workspace deletes its agents", async () => {
198
+ const doomed = await insertWorkspace(organizationId, "agents3591-doomed");
199
+ const agent = await insertAgent({ workspace_id: doomed });
200
+ await db.query(`DELETE FROM "workspaces" WHERE "id" = $1`, [doomed]);
201
+ expect(await agentRow(agent)).toBeUndefined();
202
+ });
203
+
204
+ it("deleting the organization deletes its agents", async () => {
205
+ const org = await insertOrganization("doomed-org");
206
+ const ws = await insertWorkspace(org, "agents3591-doomed-org-ws");
207
+ const user = await insertUser("doomed-org-person");
208
+ const membership = await insertMembership(org, user, "owner");
209
+ const agent = await insertAgent({
210
+ organization_id: org,
211
+ workspace_id: ws,
212
+ responsible_membership_id: membership,
213
+ key_issued_to_membership_id: membership,
214
+ });
215
+ await db.query(`DELETE FROM "organizations" WHERE "id" = $1`, [org]);
216
+ expect(await agentRow(agent)).toBeUndefined();
217
+ });
218
+
219
+ it("deleting the key-holder's membership keeps the agent and clears key_issued_to_membership_id", async () => {
220
+ const admin = await insertUser("departing-admin");
221
+ const adminMembership = await insertMembership(organizationId, admin, "admin");
222
+ const agent = await insertAgent({ key_issued_to_membership_id: adminMembership });
223
+
224
+ await db.query(`DELETE FROM "memberships" WHERE "id" = $1`, [adminMembership]);
225
+ expect(await agentRow(agent)).toEqual({ key_issued_to_membership_id: null });
226
+ });
227
+
228
+ it("deleting the creator's user row leaves the agent (created_by_user_id has no relation)", async () => {
229
+ const creator = await insertUser("creator");
230
+ const agent = await insertAgent({ created_by_user_id: creator, key_issued_to_membership_id: null });
231
+ await db.query(`DELETE FROM "users" WHERE "id" = $1`, [creator]);
232
+ expect(await agentRow(agent)).toEqual({ key_issued_to_membership_id: null });
233
+ });
234
+ });
235
+ });
@@ -82,6 +82,8 @@ model Organization {
82
82
  memberships Membership[]
83
83
  invitations Invitation[]
84
84
  workspaces Workspace[]
85
+ // #3591 — the agents (delegates) of this organization's members.
86
+ agents Agent[]
85
87
 
86
88
  @@unique([billingProvider, billingCustomerId])
87
89
  @@map("organizations")
@@ -102,6 +104,8 @@ model Workspace {
102
104
  organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
103
105
  // #7532 — the hub's spoke registry rows bound to this workspace.
104
106
  spokes Spoke[]
107
+ // #3591 — the agents bound to this workspace.
108
+ agents Agent[]
105
109
 
106
110
  @@unique([organizationId, slug])
107
111
  @@index([organizationId])
@@ -137,6 +141,59 @@ model Spoke {
137
141
  @@map("spokes")
138
142
  }
139
143
 
144
+ /// AgentPermissionLevel — an agent's ceiling (#3591, spec
145
+ /// docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md D1, D5). The agent
146
+ /// acts with the LOWER of its person's `Role` and this. One value in v1;
147
+ /// raising the ceiling later is a new value here and in
148
+ /// `@working-theory/validation`'s `AGENT_PERMISSION_LEVELS`.
149
+ enum AgentPermissionLevel {
150
+ member
151
+ }
152
+
153
+ /// Agent — a stored delegate of ONE person, bound to ONE workspace (#3591,
154
+ /// spec docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md D1, D2).
155
+ ///
156
+ /// The agent signs in with a key (`ailk_agt_…`, shown once) and passes the
157
+ /// same tenant guard a browser session does, through its person's live
158
+ /// `Membership` (`responsibleMembershipId`). Removing that membership deletes
159
+ /// the agent (the FK below), so a re-invite cannot revive it.
160
+ ///
161
+ /// Only the key's SHA-256 digest is stored (`credentialHash`) plus a short
162
+ /// recognisable `credentialPrefix`; the key itself never is.
163
+ /// `keyIssuedToMembershipId` is the owner or admin who created or last
164
+ /// re-keyed the agent — the only person who has seen its key. Removing that
165
+ /// membership sets the column null and the guard refuses the agent until it
166
+ /// is re-keyed. `createdByUserId` is a plain column with no relation, so
167
+ /// deleting the creator does not delete the agents they made for others.
168
+ /// `apps/api/src/lib/agent-store.ts` is the only file that names
169
+ /// `prisma.agent`.
170
+ model Agent {
171
+ id String @id @default(dbgenerated("('agent_'::text || (uuidv7())::text)")) @db.Text
172
+ organizationId String @map("organization_id")
173
+ workspaceId String @map("workspace_id")
174
+ responsibleMembershipId String @map("responsible_membership_id")
175
+ name String @db.VarChar(80)
176
+ permissionLevel AgentPermissionLevel @default(member) @map("permission_level")
177
+ enabled Boolean @default(true)
178
+ credentialHash String @unique @map("credential_hash") @db.Char(64)
179
+ credentialPrefix String @map("credential_prefix") @db.VarChar(24)
180
+ lastActiveAt DateTime? @map("last_active_at")
181
+ createdByUserId String @map("created_by_user_id")
182
+ keyIssuedToMembershipId String? @map("key_issued_to_membership_id")
183
+ createdAt DateTime @default(now()) @map("created_at")
184
+ updatedAt DateTime @updatedAt @map("updated_at")
185
+
186
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
187
+ workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
188
+ responsible Membership @relation("AgentResponsible", fields: [responsibleMembershipId], references: [id], onDelete: Cascade)
189
+ keyIssuedTo Membership? @relation("AgentKeyIssuedTo", fields: [keyIssuedToMembershipId], references: [id], onDelete: SetNull)
190
+
191
+ @@index([organizationId])
192
+ @@index([workspaceId])
193
+ @@index([responsibleMembershipId])
194
+ @@map("agents")
195
+ }
196
+
140
197
  /// Membership — User ↔ Organization with a Role. Managed by the Better Auth
141
198
  /// organization plugin (modelName "membership"). Org-level: grants visibility
142
199
  /// into every workspace of the organization.
@@ -149,6 +206,10 @@ model Membership {
149
206
 
150
207
  organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
151
208
  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
209
+ // #3591 — the agents this person is responsible for, and the agents whose
210
+ // current key this person was the one shown.
211
+ agents Agent[] @relation("AgentResponsible")
212
+ agentKeysIssued Agent[] @relation("AgentKeyIssuedTo")
152
213
 
153
214
  @@unique([organizationId, userId])
154
215
  @@index([userId])