@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.
- package/dist/adapters/memory-key-store.d.ts +4 -0
- package/dist/adapters/memory-key-store.js +12 -0
- package/dist/adapters/memory.js +47 -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 +4 -0
- package/dist/adapters/postgres-key-store.js +14 -3
- package/dist/adapters/postgres-org-store.d.ts +42 -0
- package/dist/adapters/postgres-org-store.js +120 -0
- package/dist/adapters/postgres.js +57 -80
- package/dist/adapters/util.d.ts +27 -0
- package/dist/adapters/util.js +47 -0
- package/dist/admin.d.ts +26 -4
- package/dist/admin.js +126 -7
- package/dist/auth-resolver.d.ts +32 -0
- package/dist/auth-resolver.js +53 -0
- package/dist/auth.d.ts +196 -0
- package/dist/auth.js +164 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/key-store.d.ts +5 -0
- package/dist/main.js +84 -26
- package/dist/migrations/0006-auth.d.ts +2 -0
- package/dist/migrations/0006-auth.js +84 -0
- package/dist/migrations/0007-orgs.d.ts +2 -0
- package/dist/migrations/0007-orgs.js +59 -0
- package/dist/migrations/index.js +4 -0
- package/dist/openapi.js +340 -3
- package/dist/org-store.d.ts +73 -0
- package/dist/org-store.js +12 -0
- package/dist/routes/orgs.d.ts +27 -0
- package/dist/routes/orgs.js +185 -0
- package/dist/schemas.d.ts +18 -0
- package/dist/schemas.js +19 -0
- package/dist/server.d.ts +39 -0
- package/dist/server.js +85 -7
- package/dist/services/orgs.d.ts +95 -0
- package/dist/services/orgs.js +159 -0
- package/dist/storage.d.ts +6 -0
- package/dist/storage.js +14 -0
- package/openapi.json +1279 -1
- 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 ??
|
|
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
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
const
|
|
135
|
-
const
|
|
136
|
-
const
|
|
137
|
-
const
|
|
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 ${
|
|
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
|
-
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
|
-
//
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
const
|
|
253
|
-
const
|
|
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 ${
|
|
257
|
-
role = CASE WHEN ${
|
|
258
|
-
team = CASE WHEN ${
|
|
259
|
-
source = CASE WHEN ${
|
|
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
|
-
//
|
|
330
|
-
//
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
//
|
|
394
|
-
//
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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:
|
|
471
|
-
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:
|
|
483
|
-
updatedAt:
|
|
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:
|
|
510
|
-
created_at:
|
|
511
|
-
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:
|
|
527
|
-
unlinked_at: r.unlinked_at === null ? null :
|
|
528
|
-
created_at:
|
|
529
|
-
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
|
|
22
|
+
* Build the admin sub-router. Mount under \`/admin/v1\`.
|
|
15
23
|
*
|
|
16
|
-
* Auth model: a single platform-level bearer token
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
6
|
+
* Build the admin sub-router. Mount under \`/admin/v1\`.
|
|
6
7
|
*
|
|
7
|
-
* Auth model: a single platform-level bearer token
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
+
}
|