@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.
- package/dist/adapters/memory-key-store.d.ts +13 -0
- package/dist/adapters/memory-key-store.js +27 -0
- package/dist/adapters/memory.js +53 -66
- package/dist/adapters/pg-tagged.d.ts +18 -0
- package/dist/adapters/pg-tagged.js +29 -0
- package/dist/adapters/postgres-key-store.d.ts +5 -0
- package/dist/adapters/postgres-key-store.js +21 -5
- package/dist/adapters/postgres-org-store.d.ts +5 -1
- package/dist/adapters/postgres-org-store.js +25 -8
- package/dist/adapters/postgres.js +76 -83
- package/dist/adapters/util.d.ts +27 -0
- package/dist/adapters/util.js +47 -0
- package/dist/admin.js +78 -89
- package/dist/key-store.d.ts +13 -0
- package/dist/main.js +29 -44
- package/dist/migrations/0008-trigger-metadata.d.ts +2 -0
- package/dist/migrations/0008-trigger-metadata.js +15 -0
- package/dist/migrations/index.js +2 -0
- package/dist/openapi.js +340 -3
- package/dist/org-store.d.ts +7 -6
- package/dist/routes/orgs.d.ts +27 -0
- package/dist/routes/orgs.js +185 -0
- package/dist/schemas.d.ts +22 -0
- package/dist/schemas.js +23 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +40 -23
- package/dist/services/orgs.d.ts +99 -0
- package/dist/services/orgs.js +163 -0
- package/dist/storage.d.ts +8 -0
- package/dist/storage.js +20 -0
- package/openapi.json +1331 -13
- package/package.json +4 -13
|
@@ -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 ??
|
|
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
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
const
|
|
135
|
-
const
|
|
136
|
-
const
|
|
137
|
-
const
|
|
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 ${
|
|
141
|
-
channel = CASE WHEN ${
|
|
142
|
-
status = CASE WHEN ${
|
|
143
|
-
summary = CASE WHEN ${
|
|
144
|
-
model = CASE WHEN ${
|
|
145
|
-
trigger_conversation_id = CASE WHEN ${
|
|
146
|
-
THEN ${
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
ELSE
|
|
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
|
-
//
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
const
|
|
253
|
-
const
|
|
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 ${
|
|
257
|
-
role = CASE WHEN ${
|
|
258
|
-
team = CASE WHEN ${
|
|
259
|
-
source = CASE WHEN ${
|
|
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
|
-
//
|
|
330
|
-
//
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
//
|
|
394
|
-
//
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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:
|
|
471
|
-
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:
|
|
483
|
-
updatedAt:
|
|
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:
|
|
510
|
-
created_at:
|
|
511
|
-
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:
|
|
527
|
-
unlinked_at: r.unlinked_at === null ? null :
|
|
528
|
-
created_at:
|
|
529
|
-
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/key-store.d.ts
CHANGED
|
@@ -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?: {
|