@hexis-ai/engram-server 0.11.3 → 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 (42) hide show
  1. package/dist/adapters/memory-key-store.d.ts +4 -0
  2. package/dist/adapters/memory-key-store.js +12 -0
  3. package/dist/adapters/memory.js +47 -66
  4. package/dist/adapters/pg-tagged.d.ts +18 -0
  5. package/dist/adapters/pg-tagged.js +29 -0
  6. package/dist/adapters/postgres-key-store.d.ts +4 -0
  7. package/dist/adapters/postgres-key-store.js +14 -3
  8. package/dist/adapters/postgres-org-store.d.ts +42 -0
  9. package/dist/adapters/postgres-org-store.js +120 -0
  10. package/dist/adapters/postgres.js +57 -80
  11. package/dist/adapters/util.d.ts +27 -0
  12. package/dist/adapters/util.js +47 -0
  13. package/dist/admin.d.ts +26 -4
  14. package/dist/admin.js +126 -7
  15. package/dist/auth-resolver.d.ts +32 -0
  16. package/dist/auth-resolver.js +53 -0
  17. package/dist/auth.d.ts +196 -0
  18. package/dist/auth.js +164 -0
  19. package/dist/index.d.ts +4 -0
  20. package/dist/index.js +4 -0
  21. package/dist/key-store.d.ts +5 -0
  22. package/dist/main.js +84 -26
  23. package/dist/migrations/0006-auth.d.ts +2 -0
  24. package/dist/migrations/0006-auth.js +84 -0
  25. package/dist/migrations/0007-orgs.d.ts +2 -0
  26. package/dist/migrations/0007-orgs.js +59 -0
  27. package/dist/migrations/index.js +4 -0
  28. package/dist/openapi.js +340 -3
  29. package/dist/org-store.d.ts +73 -0
  30. package/dist/org-store.js +12 -0
  31. package/dist/routes/orgs.d.ts +27 -0
  32. package/dist/routes/orgs.js +185 -0
  33. package/dist/schemas.d.ts +18 -0
  34. package/dist/schemas.js +19 -0
  35. package/dist/server.d.ts +39 -0
  36. package/dist/server.js +85 -7
  37. package/dist/services/orgs.d.ts +95 -0
  38. package/dist/services/orgs.js +159 -0
  39. package/dist/storage.d.ts +6 -0
  40. package/dist/storage.js +14 -0
  41. package/openapi.json +1279 -1
  42. package/package.json +5 -11
@@ -1,19 +1,6 @@
1
1
  import { runMigrations } from "../migrator";
2
- import { foldEvents } from "../storage";
3
- /**
4
- * 10-char alphanumeric id, e.g. `p_a8b3c2d4`. Cryptographic randomness via
5
- * the platform's getRandomValues; collision probability negligible for any
6
- * realistic person count.
7
- */
8
- function defaultPersonId() {
9
- const ALPHA = "abcdefghijklmnopqrstuvwxyz0123456789";
10
- const buf = new Uint8Array(8);
11
- crypto.getRandomValues(buf);
12
- let out = "p_";
13
- for (const b of buf)
14
- out += ALPHA[b % ALPHA.length];
15
- return out;
16
- }
2
+ import { foldEvents, newPersonId } from "../storage";
3
+ import { isoString, pickPatch } from "./util";
17
4
  export class PostgresAdapter {
18
5
  workspaceId;
19
6
  sql;
@@ -21,7 +8,7 @@ export class PostgresAdapter {
21
8
  constructor(opts) {
22
9
  this.workspaceId = opts.workspaceId;
23
10
  this.sql = opts.sql;
24
- this.newPersonId = opts.newPersonId ?? defaultPersonId;
11
+ this.newPersonId = opts.newPersonId ?? newPersonId;
25
12
  }
26
13
  /**
27
14
  * Apply all pending schema migrations. Safe to call repeatedly and
@@ -125,29 +112,27 @@ export class PostgresAdapter {
125
112
  return foldEvents(toSessionRow(rows[0]), events.map((e) => e.payload), new Date());
126
113
  }
127
114
  async updateSession(sessionId, patch) {
128
- // Translate JS undefined "no change" via a per-column "provided"
129
- // flag the SQL evaluates with CASE WHEN. null in patch becomes a
130
- // real null in the DB (clear), value becomes a set.
131
- const titleProvided = patch.title !== undefined;
132
- const channelProvided = patch.channel !== undefined;
133
- const statusProvided = patch.status !== undefined;
134
- const summaryProvided = patch.summary !== undefined;
135
- const modelProvided = patch.model !== undefined;
136
- const tcIdProvided = patch.trigger_conversation_id !== undefined;
137
- const teIdProvided = patch.trigger_event_id !== undefined;
115
+ // Per-column "provided" flags drive a CASE WHEN per field: undefined
116
+ // leaves the column alone, null clears, value sets. `status` defaults
117
+ // to "active" on clear to match the column default.
118
+ const title = pickPatch(patch, "title");
119
+ const channel = pickPatch(patch, "channel");
120
+ const status = pickPatch(patch, "status", "active");
121
+ const summary = pickPatch(patch, "summary");
122
+ const model = pickPatch(patch, "model");
123
+ const tcId = pickPatch(patch, "trigger_conversation_id");
124
+ const teId = pickPatch(patch, "trigger_event_id");
138
125
  const rows = await this.sql `
139
126
  UPDATE engram_sessions SET
140
- title = CASE WHEN ${titleProvided} THEN ${patch.title ?? null} ELSE title END,
141
- channel = CASE WHEN ${channelProvided} THEN ${patch.channel ?? null} ELSE channel END,
142
- status = CASE WHEN ${statusProvided} THEN ${patch.status ?? "active"} ELSE status END,
143
- summary = CASE WHEN ${summaryProvided} THEN ${patch.summary ?? null} ELSE summary END,
144
- model = CASE WHEN ${modelProvided} THEN ${patch.model ?? null} ELSE model END,
145
- trigger_conversation_id = CASE WHEN ${tcIdProvided}
146
- THEN ${patch.trigger_conversation_id ?? null}
147
- ELSE trigger_conversation_id END,
148
- trigger_event_id = CASE WHEN ${teIdProvided}
149
- THEN ${patch.trigger_event_id ?? null}
150
- ELSE trigger_event_id END,
127
+ title = CASE WHEN ${title.provided} THEN ${title.value} ELSE title END,
128
+ channel = CASE WHEN ${channel.provided} THEN ${channel.value} ELSE channel END,
129
+ status = CASE WHEN ${status.provided} THEN ${status.value} ELSE status END,
130
+ summary = CASE WHEN ${summary.provided} THEN ${summary.value} ELSE summary END,
131
+ model = CASE WHEN ${model.provided} THEN ${model.value} ELSE model END,
132
+ trigger_conversation_id = CASE WHEN ${tcId.provided}
133
+ THEN ${tcId.value} ELSE trigger_conversation_id END,
134
+ trigger_event_id = CASE WHEN ${teId.provided}
135
+ THEN ${teId.value} ELSE trigger_event_id END,
151
136
  updated_at = now()
152
137
  WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
153
138
  RETURNING id
@@ -246,17 +231,18 @@ export class PostgresAdapter {
246
231
  return toPersonInfo(rows[0]);
247
232
  }
248
233
  async updatePerson(id, patch) {
249
- // Treat `null` as an explicit clear; `undefined` as no-op.
250
- const nameProvided = patch.display_name !== undefined;
251
- const roleProvided = patch.role !== undefined;
252
- const teamProvided = patch.team !== undefined;
253
- const sourceProvided = patch.source !== undefined;
234
+ // undefined = no-op, null = clear (source clears to "auto" to match
235
+ // the column default).
236
+ const name = pickPatch(patch, "display_name");
237
+ const role = pickPatch(patch, "role");
238
+ const team = pickPatch(patch, "team");
239
+ const source = pickPatch(patch, "source", "auto");
254
240
  const rows = await this.sql `
255
241
  UPDATE engram_persons SET
256
- display_name = CASE WHEN ${nameProvided} THEN ${patch.display_name ?? null} ELSE display_name END,
257
- role = CASE WHEN ${roleProvided} THEN ${patch.role ?? null} ELSE role END,
258
- team = CASE WHEN ${teamProvided} THEN ${patch.team ?? null} ELSE team END,
259
- source = CASE WHEN ${sourceProvided} THEN ${patch.source ?? "auto"} ELSE source END,
242
+ display_name = CASE WHEN ${name.provided} THEN ${name.value} ELSE display_name END,
243
+ role = CASE WHEN ${role.provided} THEN ${role.value} ELSE role END,
244
+ team = CASE WHEN ${team.provided} THEN ${team.value} ELSE team END,
245
+ source = CASE WHEN ${source.provided} THEN ${source.value} ELSE source END,
260
246
  updated_at = now()
261
247
  WHERE workspace_id = ${this.workspaceId} AND id = ${id}
262
248
  RETURNING id, display_name, role, team, source, created_at, updated_at
@@ -326,15 +312,13 @@ export class PostgresAdapter {
326
312
  }
327
313
  // --- Aliases ------------------------------------------------------
328
314
  async upsertAlias(personId, input) {
329
- // Pre-check rather than rely on the FK so unknown persons return
330
- // `null` instead of throwing a constraint violation.
331
- const personExists = await this.sql `
332
- SELECT id FROM engram_persons
333
- WHERE workspace_id = ${this.workspaceId} AND id = ${personId}
334
- LIMIT 1
315
+ // Same race as upsertIdentity alias telemetry can land before
316
+ // the matching person telemetry. Auto-create a stub person.
317
+ await this.sql `
318
+ INSERT INTO engram_persons (workspace_id, id)
319
+ VALUES (${this.workspaceId}, ${personId})
320
+ ON CONFLICT (workspace_id, id) DO NOTHING
335
321
  `;
336
- if (personExists.length === 0)
337
- return null;
338
322
  const increment = input.increment ?? true;
339
323
  // Branch on the upsert behaviour. `increment=true` bumps usage_count
340
324
  // and replaces caller/last_used; `increment=false` is idempotent —
@@ -390,15 +374,15 @@ export class PostgresAdapter {
390
374
  }
391
375
  // --- Identities ---------------------------------------------------
392
376
  async upsertIdentity(ref, input) {
393
- // Pre-check rather than rely on the FK so unknown persons return
394
- // `null` instead of throwing a constraint violation.
395
- const personExists = await this.sql `
396
- SELECT id FROM engram_persons
397
- WHERE workspace_id = ${this.workspaceId} AND id = ${input.person_id}
398
- LIMIT 1
377
+ // Auto-create a stub person if missing. The two telemetry calls
378
+ // (person + identity) leave monet fire-and-forget and race over
379
+ // the network, so it's normal for the identity PUT to land first.
380
+ // Empty stub gets enriched by the trailing person PUT.
381
+ await this.sql `
382
+ INSERT INTO engram_persons (workspace_id, id)
383
+ VALUES (${this.workspaceId}, ${input.person_id})
384
+ ON CONFLICT (workspace_id, id) DO NOTHING
399
385
  `;
400
- if (personExists.length === 0)
401
- return null;
402
386
  // unlinked_at semantics: undefined = leave alone, null = clear,
403
387
  // value = set. matches the patch contract for the other fields.
404
388
  const unlinkedProvided = input.unlinked_at !== undefined;
@@ -460,27 +444,25 @@ export class PostgresAdapter {
460
444
  }
461
445
  }
462
446
  function toPersonInfo(r) {
463
- const toIso = (v) => (typeof v === "string" ? v : v.toISOString());
464
447
  return {
465
448
  id: r.id,
466
449
  display_name: r.display_name,
467
450
  role: r.role,
468
451
  team: r.team,
469
452
  source: r.source,
470
- created_at: toIso(r.created_at),
471
- updated_at: toIso(r.updated_at),
453
+ created_at: isoString(r.created_at),
454
+ updated_at: isoString(r.updated_at),
472
455
  };
473
456
  }
474
457
  function toSessionRow(r) {
475
- const toIso = (v) => (typeof v === "string" ? v : v.toISOString());
476
458
  return {
477
459
  id: r.id,
478
460
  ...(r.title ? { title: r.title } : {}),
479
461
  ...(r.channel ? { channel: r.channel } : {}),
480
462
  participants: r.participants,
481
463
  viewable_by: r.viewable_by,
482
- createdAt: toIso(r.created_at),
483
- updatedAt: toIso(r.updated_at),
464
+ createdAt: isoString(r.created_at),
465
+ updatedAt: isoString(r.updated_at),
484
466
  ...(r.status === "active" || r.status === "idle" || r.status === "completed"
485
467
  ? { status: r.status }
486
468
  : {}),
@@ -495,24 +477,19 @@ function toSessionRow(r) {
495
477
  };
496
478
  }
497
479
  function toAliasInfo(r) {
498
- const toIso = (v) => (typeof v === "string" ? v : v.toISOString());
499
480
  // last_used is a DATE column; postgres-js returns it as a Date at UTC
500
481
  // midnight. Render as plain YYYY-MM-DD to match the wire type.
501
- const lastUsed = typeof r.last_used === "string"
502
- ? r.last_used.slice(0, 10)
503
- : r.last_used.toISOString().slice(0, 10);
504
482
  return {
505
483
  person_id: r.person_id,
506
484
  name: r.name,
507
485
  caller: r.caller,
508
486
  usage_count: r.usage_count,
509
- last_used: lastUsed,
510
- created_at: toIso(r.created_at),
511
- updated_at: toIso(r.updated_at),
487
+ last_used: isoString(r.last_used).slice(0, 10),
488
+ created_at: isoString(r.created_at),
489
+ updated_at: isoString(r.updated_at),
512
490
  };
513
491
  }
514
492
  function toIdentityInfo(r) {
515
- const toIso = (v) => (typeof v === "string" ? v : v.toISOString());
516
493
  // linked_at is now TIMESTAMPTZ (post-migration 0004). Emit full ISO.
517
494
  return {
518
495
  ref: r.ref,
@@ -523,9 +500,9 @@ function toIdentityInfo(r) {
523
500
  source: r.source,
524
501
  is_primary: r.is_primary,
525
502
  picture: r.picture,
526
- linked_at: toIso(r.linked_at),
527
- unlinked_at: r.unlinked_at === null ? null : toIso(r.unlinked_at),
528
- created_at: toIso(r.created_at),
529
- updated_at: toIso(r.updated_at),
503
+ linked_at: isoString(r.linked_at),
504
+ unlinked_at: r.unlinked_at === null ? null : isoString(r.unlinked_at),
505
+ created_at: isoString(r.created_at),
506
+ updated_at: isoString(r.updated_at),
530
507
  };
531
508
  }
@@ -0,0 +1,27 @@
1
+ /** Postgres drivers return timestamp columns as either string or Date depending
2
+ * on the type parser. Normalise to ISO-8601 string for the wire format. */
3
+ export declare function isoString(v: string | Date): string;
4
+ /**
5
+ * Apply a partial patch with the SDK's three-state semantics:
6
+ * - `undefined` → keep `existing[key]`
7
+ * - `null` → clear (set to `clearTo`, default `null`)
8
+ * - value → set
9
+ *
10
+ * Used by the in-memory adapter to mirror the postgres adapter's
11
+ * per-column CASE WHEN behaviour without hand-writing the if/else
12
+ * tree for every field. Returns a new object — never mutates.
13
+ */
14
+ export declare function applyPartial<E, P>(existing: E, patch: P,
15
+ /** Default value when a key is explicitly cleared (`null` in the patch). */
16
+ clearTo?: Partial<{
17
+ [K in keyof P]: unknown;
18
+ }>): E;
19
+ /**
20
+ * Per-field patch helper for postgres CASE WHEN. Returns the
21
+ * `{provided, value}` tuple the SQL template uses; `clearTo` plugs in
22
+ * when the patch explicitly cleared the field with `null`.
23
+ */
24
+ export declare function pickPatch<P, K extends keyof P>(patch: P, key: K, clearTo?: NonNullable<P[K]> | null): {
25
+ provided: boolean;
26
+ value: NonNullable<P[K]> | null;
27
+ };
@@ -0,0 +1,47 @@
1
+ /** Postgres drivers return timestamp columns as either string or Date depending
2
+ * on the type parser. Normalise to ISO-8601 string for the wire format. */
3
+ export function isoString(v) {
4
+ return typeof v === "string" ? v : v.toISOString();
5
+ }
6
+ /**
7
+ * Apply a partial patch with the SDK's three-state semantics:
8
+ * - `undefined` → keep `existing[key]`
9
+ * - `null` → clear (set to `clearTo`, default `null`)
10
+ * - value → set
11
+ *
12
+ * Used by the in-memory adapter to mirror the postgres adapter's
13
+ * per-column CASE WHEN behaviour without hand-writing the if/else
14
+ * tree for every field. Returns a new object — never mutates.
15
+ */
16
+ export function applyPartial(existing, patch,
17
+ /** Default value when a key is explicitly cleared (`null` in the patch). */
18
+ clearTo = {}) {
19
+ const next = { ...existing };
20
+ for (const key of Object.keys(patch)) {
21
+ const value = patch[key];
22
+ if (value === undefined)
23
+ continue;
24
+ if (value === null) {
25
+ const fallback = clearTo[key];
26
+ if (fallback === undefined)
27
+ delete next[key];
28
+ else
29
+ next[key] = fallback;
30
+ }
31
+ else {
32
+ next[key] = value;
33
+ }
34
+ }
35
+ return next;
36
+ }
37
+ /**
38
+ * Per-field patch helper for postgres CASE WHEN. Returns the
39
+ * `{provided, value}` tuple the SQL template uses; `clearTo` plugs in
40
+ * when the patch explicitly cleared the field with `null`.
41
+ */
42
+ export function pickPatch(patch, key, clearTo = null) {
43
+ const provided = patch[key] !== undefined;
44
+ const raw = patch[key];
45
+ const value = raw == null ? clearTo : raw;
46
+ return { provided, value };
47
+ }
package/dist/admin.d.ts CHANGED
@@ -1,9 +1,17 @@
1
1
  import { Hono } from "hono";
2
2
  import { type KeyStore } from "./key-store";
3
+ import type { OrgStore } from "./org-store";
3
4
  export interface AdminOptions {
4
5
  /** Bearer token required for every /admin/v1 request. */
5
6
  token: string;
6
7
  keyStore: KeyStore;
8
+ /**
9
+ * Optional org store. When provided, mounts the orgs surface
10
+ * (\`/admin/v1/orgs/*\` plus \`/admin/v1/orgs/:id/workspaces\` for
11
+ * org-scoped workspace creation). Without it only the legacy
12
+ * \`/admin/v1/workspaces\` endpoints are exposed.
13
+ */
14
+ orgStore?: OrgStore;
7
15
  }
8
16
  interface Env {
9
17
  Variables: {
@@ -11,11 +19,25 @@ interface Env {
11
19
  };
12
20
  }
13
21
  /**
14
- * Build the admin sub-router. Mount under `/admin/v1`.
22
+ * Build the admin sub-router. Mount under \`/admin/v1\`.
15
23
  *
16
- * Auth model: a single platform-level bearer token (`ENGRAM_ADMIN_TOKEN`).
17
- * The admin token is checked here only — it never reaches the workspace
18
- * KeyStore, so an admin token cannot accidentally double as a workspace key.
24
+ * Auth model: a single platform-level bearer token
25
+ * (\`ENGRAM_ADMIN_TOKEN\`). Never crosses with workspace api-keys.
26
+ *
27
+ * Surface, when orgStore is wired:
28
+ *
29
+ * POST /orgs create org
30
+ * GET /orgs list orgs
31
+ * GET /orgs/:id get org
32
+ * DELETE /orgs/:id delete org (CASCADE)
33
+ * POST /orgs/:id/members add member (email|userId)
34
+ * GET /orgs/:id/members
35
+ * DELETE /orgs/:id/members/:userId
36
+ * POST /orgs/:id/workspaces create + key (under org)
37
+ * GET /orgs/:id/workspaces
38
+ *
39
+ * Plus the lower-level workspace + key endpoints from before
40
+ * (\`/workspaces\`, \`/workspaces/:id/keys\`, ...).
19
41
  */
20
42
  export declare function createAdminRouter(opts: AdminOptions): Hono<Env>;
21
43
  export {};
package/dist/admin.js CHANGED
@@ -1,12 +1,27 @@
1
1
  import { Hono } from "hono";
2
2
  import { isValidWorkspaceId } from "./key-store";
3
3
  import { createWorkspaceSchema, issueKeySchema, parseJsonBody } from "./schemas";
4
+ import { addMember, createOrg, createWorkspaceUnderOrg, deleteOrg, getOrgOrThrow, OrgServiceError, removeMember, requireWorkspaceInOrg, revokeWorkspaceKey, } from "./services/orgs";
4
5
  /**
5
- * Build the admin sub-router. Mount under `/admin/v1`.
6
+ * Build the admin sub-router. Mount under \`/admin/v1\`.
6
7
  *
7
- * Auth model: a single platform-level bearer token (`ENGRAM_ADMIN_TOKEN`).
8
- * The admin token is checked here only — it never reaches the workspace
9
- * KeyStore, so an admin token cannot accidentally double as a workspace key.
8
+ * Auth model: a single platform-level bearer token
9
+ * (\`ENGRAM_ADMIN_TOKEN\`). Never crosses with workspace api-keys.
10
+ *
11
+ * Surface, when orgStore is wired:
12
+ *
13
+ * POST /orgs create org
14
+ * GET /orgs list orgs
15
+ * GET /orgs/:id get org
16
+ * DELETE /orgs/:id delete org (CASCADE)
17
+ * POST /orgs/:id/members add member (email|userId)
18
+ * GET /orgs/:id/members
19
+ * DELETE /orgs/:id/members/:userId
20
+ * POST /orgs/:id/workspaces create + key (under org)
21
+ * GET /orgs/:id/workspaces
22
+ *
23
+ * Plus the lower-level workspace + key endpoints from before
24
+ * (\`/workspaces\`, \`/workspaces/:id/keys\`, ...).
10
25
  */
11
26
  export function createAdminRouter(opts) {
12
27
  const app = new Hono();
@@ -18,6 +33,9 @@ export function createAdminRouter(opts) {
18
33
  }
19
34
  await next();
20
35
  });
36
+ // -------------------- workspaces (legacy / api-key-only) ----
37
+ // Kept for backwards compatibility; new callers should create
38
+ // workspaces under an org so they're reachable from the web UI.
21
39
  app.post("/workspaces", async (c) => {
22
40
  const body = await parseJsonBody(c, createWorkspaceSchema);
23
41
  if (body instanceof Response)
@@ -31,8 +49,6 @@ export function createAdminRouter(opts) {
31
49
  ...(body.name !== undefined ? { name: body.name } : {}),
32
50
  ...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
33
51
  });
34
- // Default: issue an initial key so the caller can start using the
35
- // workspace in one round trip. Opt out with `issueKey: false`.
36
52
  if (body.issueKey === false) {
37
53
  return c.json({ workspace: ws });
38
54
  }
@@ -68,7 +84,6 @@ export function createAdminRouter(opts) {
68
84
  const ws = await opts.keyStore.getWorkspace(workspaceId);
69
85
  if (!ws)
70
86
  return c.json({ error: "workspace_not_found" }, 404);
71
- // The body is optional here — an empty POST issues a key with no name.
72
87
  const raw = await c.req.json().catch(() => ({}));
73
88
  const parsed = issueKeySchema.safeParse(raw);
74
89
  if (!parsed.success) {
@@ -101,5 +116,109 @@ export function createAdminRouter(opts) {
101
116
  }
102
117
  return c.body(null, 204);
103
118
  });
119
+ // -------------------- orgs (org-scoped admin surface) -------
120
+ const orgStore = opts.orgStore;
121
+ if (!orgStore)
122
+ return app;
123
+ const deps = { orgStore, keyStore: opts.keyStore };
124
+ app.post("/orgs", async (c) => runService(c, async () => {
125
+ const body = (await c.req.json().catch(() => ({})));
126
+ const org = await createOrg(deps, body);
127
+ return c.json({ org });
128
+ }));
129
+ app.get("/orgs", async (c) => {
130
+ const orgs = await orgStore.listOrgs();
131
+ return c.json({ orgs });
132
+ });
133
+ app.get("/orgs/:id", async (c) => runService(c, async () => {
134
+ const org = await getOrgOrThrow(deps, c.req.param("id"));
135
+ return c.json({ org });
136
+ }));
137
+ app.delete("/orgs/:id", async (c) => runService(c, async () => {
138
+ await deleteOrg(deps, c.req.param("id"));
139
+ return c.body(null, 204);
140
+ }));
141
+ // ----- org members ------------------------------------------
142
+ app.get("/orgs/:id/members", async (c) => runService(c, async () => {
143
+ const id = c.req.param("id");
144
+ await getOrgOrThrow(deps, id);
145
+ return c.json({ members: await orgStore.listMembers(id) });
146
+ }));
147
+ app.post("/orgs/:id/members", async (c) => runService(c, async () => {
148
+ const orgId = c.req.param("id");
149
+ await getOrgOrThrow(deps, orgId);
150
+ const body = (await c.req.json().catch(() => ({})));
151
+ const member = await addMember(deps, orgId, body);
152
+ return c.json({ member });
153
+ }));
154
+ app.delete("/orgs/:id/members/:userId", async (c) => runService(c, async () => {
155
+ const orgId = c.req.param("id");
156
+ await getOrgOrThrow(deps, orgId);
157
+ await removeMember(deps, orgId, c.req.param("userId"));
158
+ return c.body(null, 204);
159
+ }));
160
+ // ----- org workspaces ---------------------------------------
161
+ // Stands up a tenant in one round-trip. createWorkspaceUnderOrg
162
+ // wraps createWorkspace + setWorkspaceOrg + issueKey and applies
163
+ // the same id validation as the legacy /workspaces POST.
164
+ app.post("/orgs/:id/workspaces", async (c) => runService(c, async () => {
165
+ const orgId = c.req.param("id");
166
+ await getOrgOrThrow(deps, orgId);
167
+ const body = await parseJsonBody(c, createWorkspaceSchema);
168
+ if (body instanceof Response)
169
+ return body;
170
+ return c.json(await createWorkspaceUnderOrg(deps, orgId, body));
171
+ }));
172
+ app.get("/orgs/:id/workspaces", async (c) => runService(c, async () => {
173
+ const orgId = c.req.param("id");
174
+ await getOrgOrThrow(deps, orgId);
175
+ const workspaces = await orgStore.listWorkspacesForOrg(orgId);
176
+ return c.json({ workspaces });
177
+ }));
178
+ // ----- per-workspace api keys (scoped to an org) -----------
179
+ // Org-scoped equivalent of the legacy /admin/v1/workspaces/:id/keys
180
+ // routes. monet uses these for key rotation after the initial
181
+ // createWorkspaceUnderOrg + key issuance.
182
+ app.get("/orgs/:id/workspaces/:wsId/keys", async (c) => runService(c, async () => {
183
+ const orgId = c.req.param("id");
184
+ const wsId = c.req.param("wsId");
185
+ await getOrgOrThrow(deps, orgId);
186
+ await requireWorkspaceInOrg(deps, orgId, wsId);
187
+ return c.json({ keys: await opts.keyStore.listKeys(wsId) });
188
+ }));
189
+ app.post("/orgs/:id/workspaces/:wsId/keys", async (c) => runService(c, async () => {
190
+ const orgId = c.req.param("id");
191
+ const wsId = c.req.param("wsId");
192
+ await getOrgOrThrow(deps, orgId);
193
+ await requireWorkspaceInOrg(deps, orgId, wsId);
194
+ const raw = await c.req.json().catch(() => ({}));
195
+ const parsed = issueKeySchema.safeParse(raw);
196
+ if (!parsed.success) {
197
+ return c.json({ error: "invalid_body", issues: parsed.error.issues }, 400);
198
+ }
199
+ const key = await opts.keyStore.issueKey(wsId, {
200
+ ...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}),
201
+ });
202
+ return c.json(key);
203
+ }));
204
+ app.delete("/orgs/:id/workspaces/:wsId/keys/:keyId", async (c) => runService(c, async () => {
205
+ const orgId = c.req.param("id");
206
+ const wsId = c.req.param("wsId");
207
+ await getOrgOrThrow(deps, orgId);
208
+ await requireWorkspaceInOrg(deps, orgId, wsId);
209
+ await revokeWorkspaceKey(deps, wsId, c.req.param("keyId"));
210
+ return c.body(null, 204);
211
+ }));
104
212
  return app;
105
213
  }
214
+ /** Run a service call, mapping OrgServiceError to a JSON response. */
215
+ async function runService(c, fn) {
216
+ try {
217
+ return await fn();
218
+ }
219
+ catch (e) {
220
+ if (e instanceof OrgServiceError)
221
+ return c.json({ error: e.code }, e.status);
222
+ throw e;
223
+ }
224
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Cookie-session → WorkspaceContext bridge (org-based).
3
+ *
4
+ * Each user joins an org once; the org has many workspaces. To
5
+ * resolve a request:
6
+ *
7
+ * 1) Validate the cookie via better-auth.
8
+ * 2) Look up every workspace visible to the user via
9
+ * \`engram_org_members\` JOIN \`engram_workspaces\`.
10
+ * 3) Pick one by, in order:
11
+ * a) x-workspace-id header (verified against the visible list)
12
+ * b) session.currentWorkspaceId (sticky from last UI choice)
13
+ * c) the user's only visible workspace, if exactly one exists
14
+ *
15
+ * Membership at the workspace level is intentionally not modeled —
16
+ * any org member sees every workspace in that org. Add finer-grained
17
+ * scoping at the app level if needed later.
18
+ */
19
+ import type { Pool } from "pg";
20
+ import type { EngramAuth } from "./auth";
21
+ import type { WorkspaceContext } from "./context";
22
+ import type { OrgStore } from "./org-store";
23
+ import type { StorageAdapter } from "./storage";
24
+ export interface CookieResolverOptions {
25
+ auth: EngramAuth;
26
+ pool: Pool;
27
+ orgStore: OrgStore;
28
+ /** Workspace-scoped storage factory; same one wired into api-key auth. */
29
+ getStorage: (workspaceId: string) => StorageAdapter;
30
+ }
31
+ export type CookieAuthResolver = (req: Request) => Promise<WorkspaceContext | null>;
32
+ export declare function makeCookieAuthResolver(opts: CookieResolverOptions): CookieAuthResolver;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Cookie-session → WorkspaceContext bridge (org-based).
3
+ *
4
+ * Each user joins an org once; the org has many workspaces. To
5
+ * resolve a request:
6
+ *
7
+ * 1) Validate the cookie via better-auth.
8
+ * 2) Look up every workspace visible to the user via
9
+ * \`engram_org_members\` JOIN \`engram_workspaces\`.
10
+ * 3) Pick one by, in order:
11
+ * a) x-workspace-id header (verified against the visible list)
12
+ * b) session.currentWorkspaceId (sticky from last UI choice)
13
+ * c) the user's only visible workspace, if exactly one exists
14
+ *
15
+ * Membership at the workspace level is intentionally not modeled —
16
+ * any org member sees every workspace in that org. Add finer-grained
17
+ * scoping at the app level if needed later.
18
+ */
19
+ async function sessionWorkspace(pool, sessionId) {
20
+ const { rows } = await pool.query(`SELECT current_workspace_id FROM engram_auth_sessions WHERE id = $1 LIMIT 1`, [sessionId]);
21
+ return rows[0]?.current_workspace_id ?? null;
22
+ }
23
+ export function makeCookieAuthResolver(opts) {
24
+ return async (req) => {
25
+ const session = await opts.auth.api
26
+ .getSession({ headers: req.headers })
27
+ .catch(() => null);
28
+ if (!session?.user)
29
+ return null;
30
+ const visible = await opts.orgStore.listWorkspacesForUser(session.user.id);
31
+ if (visible.length === 0)
32
+ return null;
33
+ const allowed = new Set(visible.map((w) => w.id));
34
+ const headerWs = req.headers.get("x-workspace-id");
35
+ let pick = null;
36
+ if (headerWs) {
37
+ if (allowed.has(headerWs))
38
+ pick = headerWs;
39
+ else
40
+ return null; // explicit header that isn't allowed → 401
41
+ }
42
+ else {
43
+ const stickied = await sessionWorkspace(opts.pool, session.session.id);
44
+ if (stickied && allowed.has(stickied))
45
+ pick = stickied;
46
+ else if (visible.length === 1)
47
+ pick = visible[0].id;
48
+ }
49
+ if (!pick)
50
+ return null;
51
+ return { workspaceId: pick, storage: opts.getStorage(pick) };
52
+ };
53
+ }