@hexis-ai/engram-server 0.12.0 → 0.14.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.
@@ -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
@@ -44,7 +31,8 @@ export class PostgresAdapter {
44
31
  INSERT INTO engram_sessions (
45
32
  workspace_id, id, title, channel, participants, viewable_by,
46
33
  created_at, updated_at, status, summary, model,
47
- trigger_conversation_id, trigger_event_id
34
+ trigger_conversation_id, trigger_event_id,
35
+ trigger_purpose, trigger_resume_hint
48
36
  )
49
37
  VALUES (
50
38
  ${this.workspaceId},
@@ -59,7 +47,9 @@ export class PostgresAdapter {
59
47
  ${init.summary ?? null},
60
48
  ${init.model ?? null},
61
49
  ${init.trigger_conversation_id ?? null},
62
- ${init.trigger_event_id ?? null}
50
+ ${init.trigger_event_id ?? null},
51
+ ${init.trigger_purpose ?? null},
52
+ ${init.trigger_resume_hint ?? null}
63
53
  )
64
54
  ON CONFLICT (workspace_id, id) DO NOTHING
65
55
  `;
@@ -110,7 +100,8 @@ export class PostgresAdapter {
110
100
  const rows = await this.sql `
111
101
  SELECT id, title, channel, participants, viewable_by, created_at,
112
102
  updated_at, status, summary, model,
113
- trigger_conversation_id, trigger_event_id
103
+ trigger_conversation_id, trigger_event_id,
104
+ trigger_purpose, trigger_resume_hint
114
105
  FROM engram_sessions
115
106
  WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
116
107
  LIMIT 1
@@ -125,29 +116,33 @@ export class PostgresAdapter {
125
116
  return foldEvents(toSessionRow(rows[0]), events.map((e) => e.payload), new Date());
126
117
  }
127
118
  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;
119
+ // Per-column "provided" flags drive a CASE WHEN per field: undefined
120
+ // leaves the column alone, null clears, value sets. `status` defaults
121
+ // to "active" on clear to match the column default.
122
+ const title = pickPatch(patch, "title");
123
+ const channel = pickPatch(patch, "channel");
124
+ const status = pickPatch(patch, "status", "active");
125
+ const summary = pickPatch(patch, "summary");
126
+ const model = pickPatch(patch, "model");
127
+ const tcId = pickPatch(patch, "trigger_conversation_id");
128
+ const teId = pickPatch(patch, "trigger_event_id");
129
+ const tPurpose = pickPatch(patch, "trigger_purpose");
130
+ const tResume = pickPatch(patch, "trigger_resume_hint");
138
131
  const rows = await this.sql `
139
132
  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,
133
+ title = CASE WHEN ${title.provided} THEN ${title.value} ELSE title END,
134
+ channel = CASE WHEN ${channel.provided} THEN ${channel.value} ELSE channel END,
135
+ status = CASE WHEN ${status.provided} THEN ${status.value} ELSE status END,
136
+ summary = CASE WHEN ${summary.provided} THEN ${summary.value} ELSE summary END,
137
+ model = CASE WHEN ${model.provided} THEN ${model.value} ELSE model END,
138
+ trigger_conversation_id = CASE WHEN ${tcId.provided}
139
+ THEN ${tcId.value} ELSE trigger_conversation_id END,
140
+ trigger_event_id = CASE WHEN ${teId.provided}
141
+ THEN ${teId.value} ELSE trigger_event_id END,
142
+ trigger_purpose = CASE WHEN ${tPurpose.provided}
143
+ THEN ${tPurpose.value} ELSE trigger_purpose END,
144
+ trigger_resume_hint = CASE WHEN ${tResume.provided}
145
+ THEN ${tResume.value} ELSE trigger_resume_hint END,
151
146
  updated_at = now()
152
147
  WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
153
148
  RETURNING id
@@ -246,17 +241,18 @@ export class PostgresAdapter {
246
241
  return toPersonInfo(rows[0]);
247
242
  }
248
243
  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;
244
+ // undefined = no-op, null = clear (source clears to "auto" to match
245
+ // the column default).
246
+ const name = pickPatch(patch, "display_name");
247
+ const role = pickPatch(patch, "role");
248
+ const team = pickPatch(patch, "team");
249
+ const source = pickPatch(patch, "source", "auto");
254
250
  const rows = await this.sql `
255
251
  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,
252
+ display_name = CASE WHEN ${name.provided} THEN ${name.value} ELSE display_name END,
253
+ role = CASE WHEN ${role.provided} THEN ${role.value} ELSE role END,
254
+ team = CASE WHEN ${team.provided} THEN ${team.value} ELSE team END,
255
+ source = CASE WHEN ${source.provided} THEN ${source.value} ELSE source END,
260
256
  updated_at = now()
261
257
  WHERE workspace_id = ${this.workspaceId} AND id = ${id}
262
258
  RETURNING id, display_name, role, team, source, created_at, updated_at
@@ -326,15 +322,13 @@ export class PostgresAdapter {
326
322
  }
327
323
  // --- Aliases ------------------------------------------------------
328
324
  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
325
+ // Same race as upsertIdentity alias telemetry can land before
326
+ // the matching person telemetry. Auto-create a stub person.
327
+ await this.sql `
328
+ INSERT INTO engram_persons (workspace_id, id)
329
+ VALUES (${this.workspaceId}, ${personId})
330
+ ON CONFLICT (workspace_id, id) DO NOTHING
335
331
  `;
336
- if (personExists.length === 0)
337
- return null;
338
332
  const increment = input.increment ?? true;
339
333
  // Branch on the upsert behaviour. `increment=true` bumps usage_count
340
334
  // and replaces caller/last_used; `increment=false` is idempotent —
@@ -390,15 +384,15 @@ export class PostgresAdapter {
390
384
  }
391
385
  // --- Identities ---------------------------------------------------
392
386
  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
387
+ // Auto-create a stub person if missing. The two telemetry calls
388
+ // (person + identity) leave monet fire-and-forget and race over
389
+ // the network, so it's normal for the identity PUT to land first.
390
+ // Empty stub gets enriched by the trailing person PUT.
391
+ await this.sql `
392
+ INSERT INTO engram_persons (workspace_id, id)
393
+ VALUES (${this.workspaceId}, ${input.person_id})
394
+ ON CONFLICT (workspace_id, id) DO NOTHING
399
395
  `;
400
- if (personExists.length === 0)
401
- return null;
402
396
  // unlinked_at semantics: undefined = leave alone, null = clear,
403
397
  // value = set. matches the patch contract for the other fields.
404
398
  const unlinkedProvided = input.unlinked_at !== undefined;
@@ -460,27 +454,25 @@ export class PostgresAdapter {
460
454
  }
461
455
  }
462
456
  function toPersonInfo(r) {
463
- const toIso = (v) => (typeof v === "string" ? v : v.toISOString());
464
457
  return {
465
458
  id: r.id,
466
459
  display_name: r.display_name,
467
460
  role: r.role,
468
461
  team: r.team,
469
462
  source: r.source,
470
- created_at: toIso(r.created_at),
471
- updated_at: toIso(r.updated_at),
463
+ created_at: isoString(r.created_at),
464
+ updated_at: isoString(r.updated_at),
472
465
  };
473
466
  }
474
467
  function toSessionRow(r) {
475
- const toIso = (v) => (typeof v === "string" ? v : v.toISOString());
476
468
  return {
477
469
  id: r.id,
478
470
  ...(r.title ? { title: r.title } : {}),
479
471
  ...(r.channel ? { channel: r.channel } : {}),
480
472
  participants: r.participants,
481
473
  viewable_by: r.viewable_by,
482
- createdAt: toIso(r.created_at),
483
- updatedAt: toIso(r.updated_at),
474
+ createdAt: isoString(r.created_at),
475
+ updatedAt: isoString(r.updated_at),
484
476
  ...(r.status === "active" || r.status === "idle" || r.status === "completed"
485
477
  ? { status: r.status }
486
478
  : {}),
@@ -492,27 +484,28 @@ function toSessionRow(r) {
492
484
  ...(r.trigger_event_id !== null
493
485
  ? { trigger_event_id: r.trigger_event_id }
494
486
  : {}),
487
+ ...(r.trigger_purpose !== null
488
+ ? { trigger_purpose: r.trigger_purpose }
489
+ : {}),
490
+ ...(r.trigger_resume_hint !== null
491
+ ? { trigger_resume_hint: r.trigger_resume_hint }
492
+ : {}),
495
493
  };
496
494
  }
497
495
  function toAliasInfo(r) {
498
- const toIso = (v) => (typeof v === "string" ? v : v.toISOString());
499
496
  // last_used is a DATE column; postgres-js returns it as a Date at UTC
500
497
  // 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
498
  return {
505
499
  person_id: r.person_id,
506
500
  name: r.name,
507
501
  caller: r.caller,
508
502
  usage_count: r.usage_count,
509
- last_used: lastUsed,
510
- created_at: toIso(r.created_at),
511
- updated_at: toIso(r.updated_at),
503
+ last_used: isoString(r.last_used).slice(0, 10),
504
+ created_at: isoString(r.created_at),
505
+ updated_at: isoString(r.updated_at),
512
506
  };
513
507
  }
514
508
  function toIdentityInfo(r) {
515
- const toIso = (v) => (typeof v === "string" ? v : v.toISOString());
516
509
  // linked_at is now TIMESTAMPTZ (post-migration 0004). Emit full ISO.
517
510
  return {
518
511
  ref: r.ref,
@@ -523,9 +516,9 @@ function toIdentityInfo(r) {
523
516
  source: r.source,
524
517
  is_primary: r.is_primary,
525
518
  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),
519
+ linked_at: isoString(r.linked_at),
520
+ unlinked_at: r.unlinked_at === null ? null : isoString(r.unlinked_at),
521
+ created_at: isoString(r.created_at),
522
+ updated_at: isoString(r.updated_at),
530
523
  };
531
524
  }
@@ -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.js CHANGED
@@ -1,6 +1,7 @@
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
6
  * Build the admin sub-router. Mount under \`/admin/v1\`.
6
7
  *
@@ -119,117 +120,105 @@ export function createAdminRouter(opts) {
119
120
  const orgStore = opts.orgStore;
120
121
  if (!orgStore)
121
122
  return app;
122
- app.post("/orgs", async (c) => {
123
+ const deps = { orgStore, keyStore: opts.keyStore };
124
+ app.post("/orgs", async (c) => runService(c, async () => {
123
125
  const body = (await c.req.json().catch(() => ({})));
124
- try {
125
- const org = await orgStore.createOrg({
126
- ...(body.id !== undefined ? { id: body.id } : {}),
127
- ...(body.name !== undefined ? { name: body.name } : {}),
128
- ...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
129
- });
130
- return c.json({ org });
131
- }
132
- catch (e) {
133
- return c.json({ error: e.message }, 400);
134
- }
135
- });
126
+ const org = await createOrg(deps, body);
127
+ return c.json({ org });
128
+ }));
136
129
  app.get("/orgs", async (c) => {
137
130
  const orgs = await orgStore.listOrgs();
138
131
  return c.json({ orgs });
139
132
  });
140
- app.get("/orgs/:id", async (c) => {
141
- const org = await orgStore.getOrg(c.req.param("id"));
142
- if (!org)
143
- return c.json({ error: "org_not_found" }, 404);
133
+ app.get("/orgs/:id", async (c) => runService(c, async () => {
134
+ const org = await getOrgOrThrow(deps, c.req.param("id"));
144
135
  return c.json({ org });
145
- });
146
- app.delete("/orgs/:id", async (c) => {
147
- const id = c.req.param("id");
148
- const org = await orgStore.getOrg(id);
149
- if (!org)
150
- return c.json({ error: "org_not_found" }, 404);
151
- await orgStore.deleteOrg(id);
136
+ }));
137
+ app.delete("/orgs/:id", async (c) => runService(c, async () => {
138
+ await deleteOrg(deps, c.req.param("id"));
152
139
  return c.body(null, 204);
153
- });
140
+ }));
154
141
  // ----- org members ------------------------------------------
155
- app.get("/orgs/:id/members", async (c) => {
142
+ app.get("/orgs/:id/members", async (c) => runService(c, async () => {
156
143
  const id = c.req.param("id");
157
- const org = await orgStore.getOrg(id);
158
- if (!org)
159
- return c.json({ error: "org_not_found" }, 404);
144
+ await getOrgOrThrow(deps, id);
160
145
  return c.json({ members: await orgStore.listMembers(id) });
161
- });
162
- app.post("/orgs/:id/members", async (c) => {
146
+ }));
147
+ app.post("/orgs/:id/members", async (c) => runService(c, async () => {
163
148
  const orgId = c.req.param("id");
164
- const org = await orgStore.getOrg(orgId);
165
- if (!org)
166
- return c.json({ error: "org_not_found" }, 404);
149
+ await getOrgOrThrow(deps, orgId);
167
150
  const body = (await c.req.json().catch(() => ({})));
168
- let userId = body.userId;
169
- if (!userId && body.email) {
170
- const u = await orgStore.findUserByEmail(body.email);
171
- if (!u)
172
- return c.json({ error: "user_not_found" }, 404);
173
- userId = u.id;
174
- }
175
- if (!userId)
176
- return c.json({ error: "userId_or_email_required" }, 400);
177
- const member = await orgStore.upsertMember({
178
- orgId,
179
- userId,
180
- ...(body.role !== undefined ? { role: body.role } : {}),
181
- });
151
+ const member = await addMember(deps, orgId, body);
182
152
  return c.json({ member });
183
- });
184
- app.delete("/orgs/:id/members/:userId", async (c) => {
153
+ }));
154
+ app.delete("/orgs/:id/members/:userId", async (c) => runService(c, async () => {
185
155
  const orgId = c.req.param("id");
186
- const org = await orgStore.getOrg(orgId);
187
- if (!org)
188
- return c.json({ error: "org_not_found" }, 404);
189
- await orgStore.removeMember(orgId, c.req.param("userId"));
156
+ await getOrgOrThrow(deps, orgId);
157
+ await removeMember(deps, orgId, c.req.param("userId"));
190
158
  return c.body(null, 204);
191
- });
159
+ }));
192
160
  // ----- org workspaces ---------------------------------------
193
- // Combines createWorkspace + setWorkspaceOrg + issueKey so a
194
- // tenant can be stood up in one round-trip. Mirrors the legacy
195
- // /workspaces POST shape but adds org scoping.
196
- app.post("/orgs/:id/workspaces", async (c) => {
161
+ // Stands up a tenant. createWorkspaceUnderOrg writes the workspace
162
+ // + org binding in a single INSERT (atomic) and then issues the
163
+ // initial API key (separate insert; retry-safe via ON CONFLICT).
164
+ app.post("/orgs/:id/workspaces", async (c) => runService(c, async () => {
197
165
  const orgId = c.req.param("id");
198
- const org = await orgStore.getOrg(orgId);
199
- if (!org)
200
- return c.json({ error: "org_not_found" }, 404);
166
+ await getOrgOrThrow(deps, orgId);
201
167
  const body = await parseJsonBody(c, createWorkspaceSchema);
202
168
  if (body instanceof Response)
203
169
  return body;
204
- if (body.id !== undefined && !isValidWorkspaceId(body.id)) {
205
- return c.json({ error: "invalid_workspace_id" }, 400);
206
- }
207
- try {
208
- const ws = await opts.keyStore.createWorkspace({
209
- ...(body.id !== undefined ? { id: body.id } : {}),
210
- ...(body.name !== undefined ? { name: body.name } : {}),
211
- ...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
212
- });
213
- await orgStore.setWorkspaceOrg(ws.id, orgId);
214
- if (body.issueKey === false) {
215
- return c.json({ workspace: { ...ws, orgId } });
216
- }
217
- const key = await opts.keyStore.issueKey(ws.id, {
218
- ...(body.keyName !== undefined ? { name: body.keyName } : {}),
219
- });
220
- return c.json({ workspace: { ...ws, orgId }, key });
221
- }
222
- catch (e) {
223
- return c.json({ error: e.message }, 400);
224
- }
225
- });
226
- app.get("/orgs/:id/workspaces", async (c) => {
170
+ return c.json(await createWorkspaceUnderOrg(deps, orgId, body));
171
+ }));
172
+ app.get("/orgs/:id/workspaces", async (c) => runService(c, async () => {
227
173
  const orgId = c.req.param("id");
228
- const org = await orgStore.getOrg(orgId);
229
- if (!org)
230
- return c.json({ error: "org_not_found" }, 404);
174
+ await getOrgOrThrow(deps, orgId);
231
175
  const workspaces = await orgStore.listWorkspacesForOrg(orgId);
232
176
  return c.json({ workspaces });
233
- });
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
+ }));
234
212
  return app;
235
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
+ }
@@ -29,13 +29,26 @@ export interface KeyResolution {
29
29
  keyId: string;
30
30
  }
31
31
  export interface KeyStore {
32
+ /**
33
+ * Persist a workspace row. `orgId`, when supplied, lands in the same
34
+ * INSERT so the workspace and its org binding are committed atomically
35
+ * (no orphaned workspace if a follow-up `setWorkspaceOrg` would have
36
+ * failed). Legacy callers without an org still work — `org_id` stays
37
+ * NULL.
38
+ */
32
39
  createWorkspace(input: {
33
40
  id?: string;
34
41
  name?: string;
35
42
  metadata?: Record<string, unknown>;
43
+ orgId?: string;
36
44
  }): Promise<Workspace>;
37
45
  getWorkspace(id: string): Promise<Workspace | null>;
38
46
  listWorkspaces(): Promise<Workspace[]>;
47
+ /** Patch a workspace's name (and/or metadata). Returns the updated row. */
48
+ updateWorkspace(id: string, patch: {
49
+ name?: string;
50
+ metadata?: Record<string, unknown>;
51
+ }): Promise<Workspace>;
39
52
  /** Hard delete: cascades to keys, sessions, and events for this workspace. */
40
53
  deleteWorkspace(id: string): Promise<void>;
41
54
  issueKey(workspaceId: string, opts?: {