@hexis-ai/engram-server 0.14.0 → 0.16.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/postgres.d.ts +6 -0
- package/dist/adapters/postgres.js +120 -72
- package/dist/admin.d.ts +16 -16
- package/dist/admin.js +12 -99
- package/dist/main.js +6 -6
- package/dist/migrations/0009-events-type-index.d.ts +2 -0
- package/dist/migrations/0009-events-type-index.js +14 -0
- package/dist/migrations/index.js +2 -0
- package/dist/openapi.js +0 -89
- package/openapi.json +0 -254
- package/package.json +2 -2
|
@@ -10,6 +10,12 @@ export interface SqlClient {
|
|
|
10
10
|
<T = Record<string, unknown>>(strings: TemplateStringsArray, ...values: unknown[]): Promise<T[]>;
|
|
11
11
|
/** Raw query for DDL / multi-statement strings. */
|
|
12
12
|
unsafe: (query: string) => Promise<unknown>;
|
|
13
|
+
/**
|
|
14
|
+
* Open a transaction. Statements issued via the callback's `tx`
|
|
15
|
+
* argument commit together, or roll back together if the callback
|
|
16
|
+
* throws. Mirrors the `postgres` package's signature.
|
|
17
|
+
*/
|
|
18
|
+
begin<T>(fn: (tx: SqlClient) => Promise<T>): Promise<T>;
|
|
13
19
|
}
|
|
14
20
|
export interface PostgresAdapterOptions {
|
|
15
21
|
/** Workspace scope baked into every row. Required for multi-tenant isolation. */
|
|
@@ -57,63 +57,73 @@ export class PostgresAdapter {
|
|
|
57
57
|
async appendEvents(sessionId, events) {
|
|
58
58
|
if (events.length === 0)
|
|
59
59
|
return;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
SET participants = (
|
|
80
|
-
SELECT ARRAY(SELECT DISTINCT unnest(participants || ARRAY[${ev.personId}]::text[]))
|
|
81
|
-
),
|
|
82
|
-
viewable_by = (
|
|
83
|
-
SELECT ARRAY(SELECT DISTINCT unnest(viewable_by || ARRAY[${ev.personId}]::text[]))
|
|
84
|
-
)
|
|
85
|
-
WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
|
|
60
|
+
// Wrap the whole batch in a transaction so the event log and the
|
|
61
|
+
// denormalized session row (participants[] / viewable_by[] /
|
|
62
|
+
// updated_at) can never diverge. Previously each INSERT + UPDATE
|
|
63
|
+
// landed as a separate statement; a mid-batch failure left the
|
|
64
|
+
// materialized state out of sync with the ledger with no way to
|
|
65
|
+
// reconstruct it.
|
|
66
|
+
await this.sql.begin(async (tx) => {
|
|
67
|
+
for (const ev of events) {
|
|
68
|
+
await tx `
|
|
69
|
+
INSERT INTO engram_events (workspace_id, session_id, seq, type, at, payload)
|
|
70
|
+
VALUES (
|
|
71
|
+
${this.workspaceId},
|
|
72
|
+
${sessionId},
|
|
73
|
+
${ev.seq},
|
|
74
|
+
${ev.type},
|
|
75
|
+
${ev.at},
|
|
76
|
+
${ev}
|
|
77
|
+
)
|
|
78
|
+
ON CONFLICT (workspace_id, session_id, seq) DO NOTHING
|
|
86
79
|
`;
|
|
80
|
+
// Participant events also widen the session's participants array
|
|
81
|
+
// so a one-shot listSessions can answer "who took part" without
|
|
82
|
+
// folding events at read time. viewable_by widens too
|
|
83
|
+
// (participants ⊆ viewable_by).
|
|
84
|
+
if (ev.type === "participant") {
|
|
85
|
+
await tx `
|
|
86
|
+
UPDATE engram_sessions
|
|
87
|
+
SET participants = (
|
|
88
|
+
SELECT ARRAY(SELECT DISTINCT unnest(participants || ARRAY[${ev.personId}]::text[]))
|
|
89
|
+
),
|
|
90
|
+
viewable_by = (
|
|
91
|
+
SELECT ARRAY(SELECT DISTINCT unnest(viewable_by || ARRAY[${ev.personId}]::text[]))
|
|
92
|
+
)
|
|
93
|
+
WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
|
|
94
|
+
`;
|
|
95
|
+
}
|
|
87
96
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
97
|
+
// Bump updated_at once per batch so list-by-recent-activity stays
|
|
98
|
+
// accurate. Using the latest event's `at` (not now()) keeps the
|
|
99
|
+
// semantics deterministic for back-dated batches.
|
|
100
|
+
const latest = events[events.length - 1];
|
|
101
|
+
await tx `
|
|
102
|
+
UPDATE engram_sessions
|
|
103
|
+
SET updated_at = GREATEST(updated_at, ${latest.at}::timestamptz)
|
|
104
|
+
WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
|
|
105
|
+
`;
|
|
106
|
+
});
|
|
98
107
|
}
|
|
99
108
|
async getSession(sessionId) {
|
|
100
109
|
const rows = await this.sql `
|
|
101
|
-
SELECT id, title, channel, participants, viewable_by,
|
|
102
|
-
updated_at, status, summary, model,
|
|
103
|
-
trigger_conversation_id, trigger_event_id,
|
|
104
|
-
trigger_purpose, trigger_resume_hint
|
|
105
|
-
|
|
106
|
-
|
|
110
|
+
SELECT s.id, s.title, s.channel, s.participants, s.viewable_by,
|
|
111
|
+
s.created_at, s.updated_at, s.status, s.summary, s.model,
|
|
112
|
+
s.trigger_conversation_id, s.trigger_event_id,
|
|
113
|
+
s.trigger_purpose, s.trigger_resume_hint,
|
|
114
|
+
COALESCE(
|
|
115
|
+
(SELECT json_agg(e.payload ORDER BY e.seq)
|
|
116
|
+
FROM engram_events e
|
|
117
|
+
WHERE e.workspace_id = s.workspace_id AND e.session_id = s.id),
|
|
118
|
+
'[]'::json
|
|
119
|
+
) AS events
|
|
120
|
+
FROM engram_sessions s
|
|
121
|
+
WHERE s.workspace_id = ${this.workspaceId} AND s.id = ${sessionId}
|
|
107
122
|
LIMIT 1
|
|
108
123
|
`;
|
|
109
124
|
if (rows.length === 0)
|
|
110
125
|
return null;
|
|
111
|
-
|
|
112
|
-
SELECT payload FROM engram_events
|
|
113
|
-
WHERE workspace_id = ${this.workspaceId} AND session_id = ${sessionId}
|
|
114
|
-
ORDER BY seq
|
|
115
|
-
`;
|
|
116
|
-
return foldEvents(toSessionRow(rows[0]), events.map((e) => e.payload), new Date());
|
|
126
|
+
return foldRowWithEvents(rows[0]);
|
|
117
127
|
}
|
|
118
128
|
async updateSession(sessionId, patch) {
|
|
119
129
|
// Per-column "provided" flags drive a CASE WHEN per field: undefined
|
|
@@ -173,45 +183,78 @@ export class PostgresAdapter {
|
|
|
173
183
|
const hasTrigger = opts.has_trigger === true;
|
|
174
184
|
const noSummary = opts.no_summary === true;
|
|
175
185
|
const updatedBefore = opts.updated_before ?? null;
|
|
186
|
+
// Single query: session columns + the per-session event payload
|
|
187
|
+
// array via a correlated subquery (uses the engram_events PK index
|
|
188
|
+
// (workspace_id, session_id, seq)). Replaces the prior pattern of
|
|
189
|
+
// SELECT id + N follow-up getSession() round-trips.
|
|
176
190
|
const rows = await this.sql `
|
|
177
|
-
SELECT id
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
191
|
+
SELECT s.id, s.title, s.channel, s.participants, s.viewable_by,
|
|
192
|
+
s.created_at, s.updated_at, s.status, s.summary, s.model,
|
|
193
|
+
s.trigger_conversation_id, s.trigger_event_id,
|
|
194
|
+
s.trigger_purpose, s.trigger_resume_hint,
|
|
195
|
+
COALESCE(
|
|
196
|
+
(SELECT json_agg(e.payload ORDER BY e.seq)
|
|
197
|
+
FROM engram_events e
|
|
198
|
+
WHERE e.workspace_id = s.workspace_id AND e.session_id = s.id),
|
|
199
|
+
'[]'::json
|
|
200
|
+
) AS events
|
|
201
|
+
FROM engram_sessions s
|
|
202
|
+
WHERE s.workspace_id = ${this.workspaceId}
|
|
203
|
+
AND (${channelFilter}::text IS NULL OR s.channel = ${channelFilter}::text)
|
|
204
|
+
AND (${channelPrefix}::text IS NULL OR s.channel LIKE ${channelPrefix ? channelPrefix + "%" : null}::text)
|
|
205
|
+
AND (${statusFilter}::text IS NULL OR s.status = ${statusFilter}::text)
|
|
206
|
+
AND (${hasTrigger}::boolean = FALSE OR s.trigger_conversation_id IS NOT NULL)
|
|
207
|
+
AND (${noSummary}::boolean = FALSE OR s.summary IS NULL)
|
|
208
|
+
AND (${updatedBefore}::timestamptz IS NULL OR s.updated_at < ${updatedBefore}::timestamptz)
|
|
209
|
+
ORDER BY s.updated_at DESC
|
|
186
210
|
LIMIT ${opts.limit}
|
|
187
211
|
`;
|
|
188
|
-
|
|
189
|
-
return sessions.filter((s) => s !== null);
|
|
212
|
+
return rows.map(foldRowWithEvents);
|
|
190
213
|
}
|
|
191
214
|
async sessionsForPerson(personId, opts) {
|
|
192
215
|
const channelFilter = opts.channel ?? null;
|
|
193
216
|
const scope = opts.scope ?? "participant";
|
|
194
217
|
// Identifier (column name) can't be parameterized in tagged templates,
|
|
195
|
-
// so branch the query. Both arms are otherwise identical
|
|
218
|
+
// so branch the query. Both arms are otherwise identical and share
|
|
219
|
+
// the same single-query fold as listSessions.
|
|
196
220
|
const rows = scope === "viewable"
|
|
197
221
|
? await this.sql `
|
|
198
|
-
SELECT id
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
222
|
+
SELECT s.id, s.title, s.channel, s.participants, s.viewable_by,
|
|
223
|
+
s.created_at, s.updated_at, s.status, s.summary, s.model,
|
|
224
|
+
s.trigger_conversation_id, s.trigger_event_id,
|
|
225
|
+
s.trigger_purpose, s.trigger_resume_hint,
|
|
226
|
+
COALESCE(
|
|
227
|
+
(SELECT json_agg(e.payload ORDER BY e.seq)
|
|
228
|
+
FROM engram_events e
|
|
229
|
+
WHERE e.workspace_id = s.workspace_id AND e.session_id = s.id),
|
|
230
|
+
'[]'::json
|
|
231
|
+
) AS events
|
|
232
|
+
FROM engram_sessions s
|
|
233
|
+
WHERE s.workspace_id = ${this.workspaceId}
|
|
234
|
+
AND s.viewable_by @> ARRAY[${personId}]::text[]
|
|
235
|
+
AND (${channelFilter}::text IS NULL OR s.channel = ${channelFilter}::text)
|
|
236
|
+
ORDER BY s.created_at DESC
|
|
203
237
|
LIMIT ${opts.limit}
|
|
204
238
|
`
|
|
205
239
|
: await this.sql `
|
|
206
|
-
SELECT id
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
240
|
+
SELECT s.id, s.title, s.channel, s.participants, s.viewable_by,
|
|
241
|
+
s.created_at, s.updated_at, s.status, s.summary, s.model,
|
|
242
|
+
s.trigger_conversation_id, s.trigger_event_id,
|
|
243
|
+
s.trigger_purpose, s.trigger_resume_hint,
|
|
244
|
+
COALESCE(
|
|
245
|
+
(SELECT json_agg(e.payload ORDER BY e.seq)
|
|
246
|
+
FROM engram_events e
|
|
247
|
+
WHERE e.workspace_id = s.workspace_id AND e.session_id = s.id),
|
|
248
|
+
'[]'::json
|
|
249
|
+
) AS events
|
|
250
|
+
FROM engram_sessions s
|
|
251
|
+
WHERE s.workspace_id = ${this.workspaceId}
|
|
252
|
+
AND s.participants @> ARRAY[${personId}]::text[]
|
|
253
|
+
AND (${channelFilter}::text IS NULL OR s.channel = ${channelFilter}::text)
|
|
254
|
+
ORDER BY s.created_at DESC
|
|
211
255
|
LIMIT ${opts.limit}
|
|
212
256
|
`;
|
|
213
|
-
|
|
214
|
-
return sessions.filter((s) => s !== null);
|
|
257
|
+
return rows.map(foldRowWithEvents);
|
|
215
258
|
}
|
|
216
259
|
// --- Persons ------------------------------------------------------
|
|
217
260
|
async createPerson(input) {
|
|
@@ -464,6 +507,11 @@ function toPersonInfo(r) {
|
|
|
464
507
|
updated_at: isoString(r.updated_at),
|
|
465
508
|
};
|
|
466
509
|
}
|
|
510
|
+
/** Convenience wrapper used by every read path: lifts the joined row
|
|
511
|
+
* into a SessionRow + events and folds in a single step. */
|
|
512
|
+
function foldRowWithEvents(r) {
|
|
513
|
+
return foldEvents(toSessionRow(r), r.events ?? [], new Date());
|
|
514
|
+
}
|
|
467
515
|
function toSessionRow(r) {
|
|
468
516
|
return {
|
|
469
517
|
id: r.id,
|
package/dist/admin.d.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
-
import {
|
|
2
|
+
import type { KeyStore } from "./key-store";
|
|
3
3
|
import type { OrgStore } from "./org-store";
|
|
4
4
|
export interface AdminOptions {
|
|
5
5
|
/** Bearer token required for every /admin/v1 request. */
|
|
6
6
|
token: string;
|
|
7
7
|
keyStore: KeyStore;
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
* (
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* Org store. Required — every admin endpoint is org-scoped.
|
|
10
|
+
* (The previous opt-out, where admin.ts also exposed a non-org
|
|
11
|
+
* `/admin/v1/workspaces` surface for `orgStore`-less deployments,
|
|
12
|
+
* was removed in v0.15.)
|
|
13
13
|
*/
|
|
14
|
-
orgStore
|
|
14
|
+
orgStore: OrgStore;
|
|
15
15
|
}
|
|
16
16
|
interface Env {
|
|
17
17
|
Variables: {
|
|
@@ -24,20 +24,20 @@ interface Env {
|
|
|
24
24
|
* Auth model: a single platform-level bearer token
|
|
25
25
|
* (\`ENGRAM_ADMIN_TOKEN\`). Never crosses with workspace api-keys.
|
|
26
26
|
*
|
|
27
|
-
* Surface
|
|
27
|
+
* Surface:
|
|
28
28
|
*
|
|
29
|
-
* POST /orgs
|
|
30
|
-
* GET /orgs
|
|
31
|
-
* GET /orgs/:id
|
|
32
|
-
* DELETE /orgs/:id
|
|
33
|
-
* POST /orgs/:id/members
|
|
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
34
|
* GET /orgs/:id/members
|
|
35
35
|
* DELETE /orgs/:id/members/:userId
|
|
36
|
-
* POST /orgs/:id/workspaces
|
|
36
|
+
* POST /orgs/:id/workspaces create workspace + key
|
|
37
37
|
* GET /orgs/:id/workspaces
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
38
|
+
* GET /orgs/:id/workspaces/:wsId/keys list workspace keys
|
|
39
|
+
* POST /orgs/:id/workspaces/:wsId/keys issue a new key
|
|
40
|
+
* DELETE /orgs/:id/workspaces/:wsId/keys/:keyId revoke a key
|
|
41
41
|
*/
|
|
42
42
|
export declare function createAdminRouter(opts: AdminOptions): Hono<Env>;
|
|
43
43
|
export {};
|
package/dist/admin.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
-
import { isValidWorkspaceId } from "./key-store";
|
|
3
2
|
import { createWorkspaceSchema, issueKeySchema, parseJsonBody } from "./schemas";
|
|
4
3
|
import { addMember, createOrg, createWorkspaceUnderOrg, deleteOrg, getOrgOrThrow, OrgServiceError, removeMember, requireWorkspaceInOrg, revokeWorkspaceKey, } from "./services/orgs";
|
|
5
4
|
/**
|
|
@@ -8,23 +7,25 @@ import { addMember, createOrg, createWorkspaceUnderOrg, deleteOrg, getOrgOrThrow
|
|
|
8
7
|
* Auth model: a single platform-level bearer token
|
|
9
8
|
* (\`ENGRAM_ADMIN_TOKEN\`). Never crosses with workspace api-keys.
|
|
10
9
|
*
|
|
11
|
-
* Surface
|
|
10
|
+
* Surface:
|
|
12
11
|
*
|
|
13
|
-
* POST /orgs
|
|
14
|
-
* GET /orgs
|
|
15
|
-
* GET /orgs/:id
|
|
16
|
-
* DELETE /orgs/:id
|
|
17
|
-
* POST /orgs/:id/members
|
|
12
|
+
* POST /orgs create org
|
|
13
|
+
* GET /orgs list orgs
|
|
14
|
+
* GET /orgs/:id get org
|
|
15
|
+
* DELETE /orgs/:id delete org (CASCADE)
|
|
16
|
+
* POST /orgs/:id/members add member (email|userId)
|
|
18
17
|
* GET /orgs/:id/members
|
|
19
18
|
* DELETE /orgs/:id/members/:userId
|
|
20
|
-
* POST /orgs/:id/workspaces
|
|
19
|
+
* POST /orgs/:id/workspaces create workspace + key
|
|
21
20
|
* GET /orgs/:id/workspaces
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
21
|
+
* GET /orgs/:id/workspaces/:wsId/keys list workspace keys
|
|
22
|
+
* POST /orgs/:id/workspaces/:wsId/keys issue a new key
|
|
23
|
+
* DELETE /orgs/:id/workspaces/:wsId/keys/:keyId revoke a key
|
|
25
24
|
*/
|
|
26
25
|
export function createAdminRouter(opts) {
|
|
27
26
|
const app = new Hono();
|
|
27
|
+
const { orgStore } = opts;
|
|
28
|
+
const deps = { orgStore, keyStore: opts.keyStore };
|
|
28
29
|
app.use("*", async (c, next) => {
|
|
29
30
|
const supplied = c.req.header("x-admin-token") ??
|
|
30
31
|
c.req.header("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
@@ -33,94 +34,6 @@ export function createAdminRouter(opts) {
|
|
|
33
34
|
}
|
|
34
35
|
await next();
|
|
35
36
|
});
|
|
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.
|
|
39
|
-
app.post("/workspaces", async (c) => {
|
|
40
|
-
const body = await parseJsonBody(c, createWorkspaceSchema);
|
|
41
|
-
if (body instanceof Response)
|
|
42
|
-
return body;
|
|
43
|
-
if (body.id !== undefined && !isValidWorkspaceId(body.id)) {
|
|
44
|
-
return c.json({ error: "invalid_workspace_id" }, 400);
|
|
45
|
-
}
|
|
46
|
-
try {
|
|
47
|
-
const ws = await opts.keyStore.createWorkspace({
|
|
48
|
-
...(body.id !== undefined ? { id: body.id } : {}),
|
|
49
|
-
...(body.name !== undefined ? { name: body.name } : {}),
|
|
50
|
-
...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
|
|
51
|
-
});
|
|
52
|
-
if (body.issueKey === false) {
|
|
53
|
-
return c.json({ workspace: ws });
|
|
54
|
-
}
|
|
55
|
-
const key = await opts.keyStore.issueKey(ws.id, {
|
|
56
|
-
...(body.keyName !== undefined ? { name: body.keyName } : {}),
|
|
57
|
-
});
|
|
58
|
-
return c.json({ workspace: ws, key });
|
|
59
|
-
}
|
|
60
|
-
catch (e) {
|
|
61
|
-
return c.json({ error: e.message }, 400);
|
|
62
|
-
}
|
|
63
|
-
});
|
|
64
|
-
app.get("/workspaces", async (c) => {
|
|
65
|
-
const workspaces = await opts.keyStore.listWorkspaces();
|
|
66
|
-
return c.json({ workspaces });
|
|
67
|
-
});
|
|
68
|
-
app.get("/workspaces/:id", async (c) => {
|
|
69
|
-
const ws = await opts.keyStore.getWorkspace(c.req.param("id"));
|
|
70
|
-
if (!ws)
|
|
71
|
-
return c.json({ error: "workspace_not_found" }, 404);
|
|
72
|
-
return c.json(ws);
|
|
73
|
-
});
|
|
74
|
-
app.delete("/workspaces/:id", async (c) => {
|
|
75
|
-
const id = c.req.param("id");
|
|
76
|
-
const ws = await opts.keyStore.getWorkspace(id);
|
|
77
|
-
if (!ws)
|
|
78
|
-
return c.json({ error: "workspace_not_found" }, 404);
|
|
79
|
-
await opts.keyStore.deleteWorkspace(id);
|
|
80
|
-
return c.body(null, 204);
|
|
81
|
-
});
|
|
82
|
-
app.post("/workspaces/:id/keys", async (c) => {
|
|
83
|
-
const workspaceId = c.req.param("id");
|
|
84
|
-
const ws = await opts.keyStore.getWorkspace(workspaceId);
|
|
85
|
-
if (!ws)
|
|
86
|
-
return c.json({ error: "workspace_not_found" }, 404);
|
|
87
|
-
const raw = await c.req.json().catch(() => ({}));
|
|
88
|
-
const parsed = issueKeySchema.safeParse(raw);
|
|
89
|
-
if (!parsed.success) {
|
|
90
|
-
return c.json({ error: "invalid_body", issues: parsed.error.issues }, 400);
|
|
91
|
-
}
|
|
92
|
-
const key = await opts.keyStore.issueKey(workspaceId, {
|
|
93
|
-
...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}),
|
|
94
|
-
});
|
|
95
|
-
return c.json(key);
|
|
96
|
-
});
|
|
97
|
-
app.get("/workspaces/:id/keys", async (c) => {
|
|
98
|
-
const workspaceId = c.req.param("id");
|
|
99
|
-
const ws = await opts.keyStore.getWorkspace(workspaceId);
|
|
100
|
-
if (!ws)
|
|
101
|
-
return c.json({ error: "workspace_not_found" }, 404);
|
|
102
|
-
const keys = await opts.keyStore.listKeys(workspaceId);
|
|
103
|
-
return c.json({ keys });
|
|
104
|
-
});
|
|
105
|
-
app.delete("/workspaces/:id/keys/:keyId", async (c) => {
|
|
106
|
-
const workspaceId = c.req.param("id");
|
|
107
|
-
const keyId = c.req.param("keyId");
|
|
108
|
-
try {
|
|
109
|
-
await opts.keyStore.revokeKey(workspaceId, keyId);
|
|
110
|
-
}
|
|
111
|
-
catch (e) {
|
|
112
|
-
if (e.message === "key_not_found") {
|
|
113
|
-
return c.json({ error: "key_not_found" }, 404);
|
|
114
|
-
}
|
|
115
|
-
throw e;
|
|
116
|
-
}
|
|
117
|
-
return c.body(null, 204);
|
|
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
37
|
app.post("/orgs", async (c) => runService(c, async () => {
|
|
125
38
|
const body = (await c.req.json().catch(() => ({})));
|
|
126
39
|
const org = await createOrg(deps, body);
|
package/dist/main.js
CHANGED
|
@@ -22,6 +22,10 @@ const CORS_ORIGINS = (process.env.ENGRAM_CORS_ORIGINS ?? "")
|
|
|
22
22
|
.split(",")
|
|
23
23
|
.map((s) => s.trim())
|
|
24
24
|
.filter(Boolean);
|
|
25
|
+
if (!orgStore) {
|
|
26
|
+
console.error("[engram-server] orgStore is required (DATABASE_URL + ENGRAM_AUTH_* env vars) — every admin endpoint is now org-scoped");
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
25
29
|
const app = createServer({
|
|
26
30
|
auth: async (key) => {
|
|
27
31
|
const r = await keyStore.resolveKey(key);
|
|
@@ -31,14 +35,10 @@ const app = createServer({
|
|
|
31
35
|
},
|
|
32
36
|
...(authHandler ? { authHandler } : {}),
|
|
33
37
|
...(cookieAuth ? { cookieAuth } : {}),
|
|
34
|
-
|
|
38
|
+
orgStore,
|
|
35
39
|
keyStore,
|
|
36
40
|
...(CORS_ORIGINS.length > 0 ? { corsOrigins: CORS_ORIGINS } : {}),
|
|
37
|
-
admin: {
|
|
38
|
-
token: ADMIN_TOKEN,
|
|
39
|
-
keyStore,
|
|
40
|
-
...(orgStore ? { orgStore } : {}),
|
|
41
|
-
},
|
|
41
|
+
admin: { token: ADMIN_TOKEN, keyStore, orgStore },
|
|
42
42
|
});
|
|
43
43
|
console.log(`[engram-server] listening on :${PORT}`);
|
|
44
44
|
export default { port: PORT, fetch: app.fetch };
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export declare const name = "0009-events-type-index";
|
|
2
|
+
export declare const sql = "\n-- Type-aware event index for callers that filter by event type\n-- (the most common is monet's conversation-repository, which fetches\n-- only `type='message'` events to materialize the chat transcript).\n--\n-- The existing PK `(workspace_id, session_id, seq)` lets PG find\n-- events for a session quickly, but it has to read every row to filter\n-- by type. This composite index trades a small write-side cost for an\n-- index-only scan on the typical \"messages for this session\" query.\n\nCREATE INDEX IF NOT EXISTS idx_engram_events_workspace_session_type\n ON engram_events (workspace_id, session_id, type);\n";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const name = "0009-events-type-index";
|
|
2
|
+
export const sql = `
|
|
3
|
+
-- Type-aware event index for callers that filter by event type
|
|
4
|
+
-- (the most common is monet's conversation-repository, which fetches
|
|
5
|
+
-- only \`type='message'\` events to materialize the chat transcript).
|
|
6
|
+
--
|
|
7
|
+
-- The existing PK \`(workspace_id, session_id, seq)\` lets PG find
|
|
8
|
+
-- events for a session quickly, but it has to read every row to filter
|
|
9
|
+
-- by type. This composite index trades a small write-side cost for an
|
|
10
|
+
-- index-only scan on the typical "messages for this session" query.
|
|
11
|
+
|
|
12
|
+
CREATE INDEX IF NOT EXISTS idx_engram_events_workspace_session_type
|
|
13
|
+
ON engram_events (workspace_id, session_id, type);
|
|
14
|
+
`;
|
package/dist/migrations/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import * as m0005 from "./0005-session-updated-at";
|
|
|
6
6
|
import * as m0006 from "./0006-auth";
|
|
7
7
|
import * as m0007 from "./0007-orgs";
|
|
8
8
|
import * as m0008 from "./0008-trigger-metadata";
|
|
9
|
+
import * as m0009 from "./0009-events-type-index";
|
|
9
10
|
/**
|
|
10
11
|
* Schema migrations, applied in array order. Add a new file under
|
|
11
12
|
* `migrations/NNNN-<slug>.ts` exporting `name` and `sql`, then append it
|
|
@@ -22,4 +23,5 @@ export const MIGRATIONS = [
|
|
|
22
23
|
{ name: m0006.name, sql: m0006.sql },
|
|
23
24
|
{ name: m0007.name, sql: m0007.sql },
|
|
24
25
|
{ name: m0008.name, sql: m0008.sql },
|
|
26
|
+
{ name: m0009.name, sql: m0009.sql },
|
|
25
27
|
];
|
package/dist/openapi.js
CHANGED
|
@@ -71,14 +71,6 @@ const TAG_DEFS = [
|
|
|
71
71
|
name: "Orgs (admin)",
|
|
72
72
|
description: "プラットフォーム管理者トークンでの org 管理",
|
|
73
73
|
},
|
|
74
|
-
{
|
|
75
|
-
name: "Workspaces (admin)",
|
|
76
|
-
description: "ワークスペースの管理(管理者トークン必須)",
|
|
77
|
-
},
|
|
78
|
-
{
|
|
79
|
-
name: "API Keys (admin)",
|
|
80
|
-
description: "ワークスペース API キーの発行・無効化(管理者トークン必須)",
|
|
81
|
-
},
|
|
82
74
|
];
|
|
83
75
|
/**
|
|
84
76
|
* Stamp `tag` onto every operation in a path-item. The path declares its
|
|
@@ -634,87 +626,6 @@ function buildPaths() {
|
|
|
634
626
|
},
|
|
635
627
|
},
|
|
636
628
|
}),
|
|
637
|
-
"/admin/v1/workspaces": tagged("Workspaces (admin)", {
|
|
638
|
-
post: {
|
|
639
|
-
summary: "ワークスペースを作成する(デフォルトで初期キーも発行する)。",
|
|
640
|
-
security: adminAuth,
|
|
641
|
-
requestBody: jsonBody("CreateWorkspace"),
|
|
642
|
-
responses: {
|
|
643
|
-
"200": res("ワークスペース(issueKey=false でない限りキーも含む)"),
|
|
644
|
-
"400": res("リクエストボディまたはワークスペース id が不正"),
|
|
645
|
-
"401": res("認証エラー"),
|
|
646
|
-
},
|
|
647
|
-
},
|
|
648
|
-
get: {
|
|
649
|
-
summary: "全ワークスペースを一覧取得する。",
|
|
650
|
-
security: adminAuth,
|
|
651
|
-
responses: {
|
|
652
|
-
"200": res("ワークスペース一覧"),
|
|
653
|
-
"401": res("認証エラー"),
|
|
654
|
-
},
|
|
655
|
-
},
|
|
656
|
-
}),
|
|
657
|
-
"/admin/v1/workspaces/{id}": tagged("Workspaces (admin)", {
|
|
658
|
-
get: {
|
|
659
|
-
summary: "単一のワークスペースを取得する。",
|
|
660
|
-
security: adminAuth,
|
|
661
|
-
parameters: [pathParam("id", "ワークスペース id。")],
|
|
662
|
-
responses: {
|
|
663
|
-
"200": res("ワークスペース"),
|
|
664
|
-
"404": res("ワークスペースが見つからない"),
|
|
665
|
-
"401": res("認証エラー"),
|
|
666
|
-
},
|
|
667
|
-
},
|
|
668
|
-
delete: {
|
|
669
|
-
summary: "ワークスペースを削除する(キー・セッション・イベントにカスケードする)。",
|
|
670
|
-
security: adminAuth,
|
|
671
|
-
parameters: [pathParam("id", "ワークスペース id。")],
|
|
672
|
-
responses: {
|
|
673
|
-
"204": res("削除完了"),
|
|
674
|
-
"404": res("ワークスペースが見つからない"),
|
|
675
|
-
"401": res("認証エラー"),
|
|
676
|
-
},
|
|
677
|
-
},
|
|
678
|
-
}),
|
|
679
|
-
"/admin/v1/workspaces/{id}/keys": tagged("API Keys (admin)", {
|
|
680
|
-
post: {
|
|
681
|
-
summary: "ワークスペースに新しい API キーを発行する。",
|
|
682
|
-
security: adminAuth,
|
|
683
|
-
parameters: [pathParam("id", "ワークスペース id。")],
|
|
684
|
-
requestBody: jsonBody("IssueKey", false),
|
|
685
|
-
responses: {
|
|
686
|
-
"200": res("発行されたキー(生のキーは一度のみ返却)"),
|
|
687
|
-
"400": res("リクエストボディが不正"),
|
|
688
|
-
"404": res("ワークスペースが見つからない"),
|
|
689
|
-
"401": res("認証エラー"),
|
|
690
|
-
},
|
|
691
|
-
},
|
|
692
|
-
get: {
|
|
693
|
-
summary: "ワークスペースの API キー一覧を取得する(ハッシュのみ)。",
|
|
694
|
-
security: adminAuth,
|
|
695
|
-
parameters: [pathParam("id", "ワークスペース id。")],
|
|
696
|
-
responses: {
|
|
697
|
-
"200": res("キー一覧"),
|
|
698
|
-
"404": res("ワークスペースが見つからない"),
|
|
699
|
-
"401": res("認証エラー"),
|
|
700
|
-
},
|
|
701
|
-
},
|
|
702
|
-
}),
|
|
703
|
-
"/admin/v1/workspaces/{id}/keys/{keyId}": tagged("API Keys (admin)", {
|
|
704
|
-
delete: {
|
|
705
|
-
summary: "API キーを無効化する。",
|
|
706
|
-
security: adminAuth,
|
|
707
|
-
parameters: [
|
|
708
|
-
pathParam("id", "ワークスペース id。"),
|
|
709
|
-
pathParam("keyId", "キー id。"),
|
|
710
|
-
],
|
|
711
|
-
responses: {
|
|
712
|
-
"204": res("無効化完了(冪等)"),
|
|
713
|
-
"404": res("キーが見つからない"),
|
|
714
|
-
"401": res("認証エラー"),
|
|
715
|
-
},
|
|
716
|
-
},
|
|
717
|
-
}),
|
|
718
629
|
};
|
|
719
630
|
}
|
|
720
631
|
/**
|
package/openapi.json
CHANGED
|
@@ -37,14 +37,6 @@
|
|
|
37
37
|
{
|
|
38
38
|
"name": "Orgs (admin)",
|
|
39
39
|
"description": "プラットフォーム管理者トークンでの org 管理"
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
"name": "Workspaces (admin)",
|
|
43
|
-
"description": "ワークスペースの管理(管理者トークン必須)"
|
|
44
|
-
},
|
|
45
|
-
{
|
|
46
|
-
"name": "API Keys (admin)",
|
|
47
|
-
"description": "ワークスペース API キーの発行・無効化(管理者トークン必須)"
|
|
48
40
|
}
|
|
49
41
|
],
|
|
50
42
|
"servers": [
|
|
@@ -2502,252 +2494,6 @@
|
|
|
2502
2494
|
"Orgs (admin)"
|
|
2503
2495
|
]
|
|
2504
2496
|
}
|
|
2505
|
-
},
|
|
2506
|
-
"/admin/v1/workspaces": {
|
|
2507
|
-
"post": {
|
|
2508
|
-
"summary": "ワークスペースを作成する(デフォルトで初期キーも発行する)。",
|
|
2509
|
-
"security": [
|
|
2510
|
-
{
|
|
2511
|
-
"adminToken": []
|
|
2512
|
-
}
|
|
2513
|
-
],
|
|
2514
|
-
"requestBody": {
|
|
2515
|
-
"required": true,
|
|
2516
|
-
"content": {
|
|
2517
|
-
"application/json": {
|
|
2518
|
-
"schema": {
|
|
2519
|
-
"$ref": "#/components/schemas/CreateWorkspace"
|
|
2520
|
-
}
|
|
2521
|
-
}
|
|
2522
|
-
}
|
|
2523
|
-
},
|
|
2524
|
-
"responses": {
|
|
2525
|
-
"200": {
|
|
2526
|
-
"description": "ワークスペース(issueKey=false でない限りキーも含む)"
|
|
2527
|
-
},
|
|
2528
|
-
"400": {
|
|
2529
|
-
"description": "リクエストボディまたはワークスペース id が不正"
|
|
2530
|
-
},
|
|
2531
|
-
"401": {
|
|
2532
|
-
"description": "認証エラー"
|
|
2533
|
-
}
|
|
2534
|
-
},
|
|
2535
|
-
"tags": [
|
|
2536
|
-
"Workspaces (admin)"
|
|
2537
|
-
]
|
|
2538
|
-
},
|
|
2539
|
-
"get": {
|
|
2540
|
-
"summary": "全ワークスペースを一覧取得する。",
|
|
2541
|
-
"security": [
|
|
2542
|
-
{
|
|
2543
|
-
"adminToken": []
|
|
2544
|
-
}
|
|
2545
|
-
],
|
|
2546
|
-
"responses": {
|
|
2547
|
-
"200": {
|
|
2548
|
-
"description": "ワークスペース一覧"
|
|
2549
|
-
},
|
|
2550
|
-
"401": {
|
|
2551
|
-
"description": "認証エラー"
|
|
2552
|
-
}
|
|
2553
|
-
},
|
|
2554
|
-
"tags": [
|
|
2555
|
-
"Workspaces (admin)"
|
|
2556
|
-
]
|
|
2557
|
-
}
|
|
2558
|
-
},
|
|
2559
|
-
"/admin/v1/workspaces/{id}": {
|
|
2560
|
-
"get": {
|
|
2561
|
-
"summary": "単一のワークスペースを取得する。",
|
|
2562
|
-
"security": [
|
|
2563
|
-
{
|
|
2564
|
-
"adminToken": []
|
|
2565
|
-
}
|
|
2566
|
-
],
|
|
2567
|
-
"parameters": [
|
|
2568
|
-
{
|
|
2569
|
-
"name": "id",
|
|
2570
|
-
"in": "path",
|
|
2571
|
-
"required": true,
|
|
2572
|
-
"schema": {
|
|
2573
|
-
"type": "string"
|
|
2574
|
-
},
|
|
2575
|
-
"description": "ワークスペース id。"
|
|
2576
|
-
}
|
|
2577
|
-
],
|
|
2578
|
-
"responses": {
|
|
2579
|
-
"200": {
|
|
2580
|
-
"description": "ワークスペース"
|
|
2581
|
-
},
|
|
2582
|
-
"401": {
|
|
2583
|
-
"description": "認証エラー"
|
|
2584
|
-
},
|
|
2585
|
-
"404": {
|
|
2586
|
-
"description": "ワークスペースが見つからない"
|
|
2587
|
-
}
|
|
2588
|
-
},
|
|
2589
|
-
"tags": [
|
|
2590
|
-
"Workspaces (admin)"
|
|
2591
|
-
]
|
|
2592
|
-
},
|
|
2593
|
-
"delete": {
|
|
2594
|
-
"summary": "ワークスペースを削除する(キー・セッション・イベントにカスケードする)。",
|
|
2595
|
-
"security": [
|
|
2596
|
-
{
|
|
2597
|
-
"adminToken": []
|
|
2598
|
-
}
|
|
2599
|
-
],
|
|
2600
|
-
"parameters": [
|
|
2601
|
-
{
|
|
2602
|
-
"name": "id",
|
|
2603
|
-
"in": "path",
|
|
2604
|
-
"required": true,
|
|
2605
|
-
"schema": {
|
|
2606
|
-
"type": "string"
|
|
2607
|
-
},
|
|
2608
|
-
"description": "ワークスペース id。"
|
|
2609
|
-
}
|
|
2610
|
-
],
|
|
2611
|
-
"responses": {
|
|
2612
|
-
"204": {
|
|
2613
|
-
"description": "削除完了"
|
|
2614
|
-
},
|
|
2615
|
-
"401": {
|
|
2616
|
-
"description": "認証エラー"
|
|
2617
|
-
},
|
|
2618
|
-
"404": {
|
|
2619
|
-
"description": "ワークスペースが見つからない"
|
|
2620
|
-
}
|
|
2621
|
-
},
|
|
2622
|
-
"tags": [
|
|
2623
|
-
"Workspaces (admin)"
|
|
2624
|
-
]
|
|
2625
|
-
}
|
|
2626
|
-
},
|
|
2627
|
-
"/admin/v1/workspaces/{id}/keys": {
|
|
2628
|
-
"post": {
|
|
2629
|
-
"summary": "ワークスペースに新しい API キーを発行する。",
|
|
2630
|
-
"security": [
|
|
2631
|
-
{
|
|
2632
|
-
"adminToken": []
|
|
2633
|
-
}
|
|
2634
|
-
],
|
|
2635
|
-
"parameters": [
|
|
2636
|
-
{
|
|
2637
|
-
"name": "id",
|
|
2638
|
-
"in": "path",
|
|
2639
|
-
"required": true,
|
|
2640
|
-
"schema": {
|
|
2641
|
-
"type": "string"
|
|
2642
|
-
},
|
|
2643
|
-
"description": "ワークスペース id。"
|
|
2644
|
-
}
|
|
2645
|
-
],
|
|
2646
|
-
"requestBody": {
|
|
2647
|
-
"required": false,
|
|
2648
|
-
"content": {
|
|
2649
|
-
"application/json": {
|
|
2650
|
-
"schema": {
|
|
2651
|
-
"$ref": "#/components/schemas/IssueKey"
|
|
2652
|
-
}
|
|
2653
|
-
}
|
|
2654
|
-
}
|
|
2655
|
-
},
|
|
2656
|
-
"responses": {
|
|
2657
|
-
"200": {
|
|
2658
|
-
"description": "発行されたキー(生のキーは一度のみ返却)"
|
|
2659
|
-
},
|
|
2660
|
-
"400": {
|
|
2661
|
-
"description": "リクエストボディが不正"
|
|
2662
|
-
},
|
|
2663
|
-
"401": {
|
|
2664
|
-
"description": "認証エラー"
|
|
2665
|
-
},
|
|
2666
|
-
"404": {
|
|
2667
|
-
"description": "ワークスペースが見つからない"
|
|
2668
|
-
}
|
|
2669
|
-
},
|
|
2670
|
-
"tags": [
|
|
2671
|
-
"API Keys (admin)"
|
|
2672
|
-
]
|
|
2673
|
-
},
|
|
2674
|
-
"get": {
|
|
2675
|
-
"summary": "ワークスペースの API キー一覧を取得する(ハッシュのみ)。",
|
|
2676
|
-
"security": [
|
|
2677
|
-
{
|
|
2678
|
-
"adminToken": []
|
|
2679
|
-
}
|
|
2680
|
-
],
|
|
2681
|
-
"parameters": [
|
|
2682
|
-
{
|
|
2683
|
-
"name": "id",
|
|
2684
|
-
"in": "path",
|
|
2685
|
-
"required": true,
|
|
2686
|
-
"schema": {
|
|
2687
|
-
"type": "string"
|
|
2688
|
-
},
|
|
2689
|
-
"description": "ワークスペース id。"
|
|
2690
|
-
}
|
|
2691
|
-
],
|
|
2692
|
-
"responses": {
|
|
2693
|
-
"200": {
|
|
2694
|
-
"description": "キー一覧"
|
|
2695
|
-
},
|
|
2696
|
-
"401": {
|
|
2697
|
-
"description": "認証エラー"
|
|
2698
|
-
},
|
|
2699
|
-
"404": {
|
|
2700
|
-
"description": "ワークスペースが見つからない"
|
|
2701
|
-
}
|
|
2702
|
-
},
|
|
2703
|
-
"tags": [
|
|
2704
|
-
"API Keys (admin)"
|
|
2705
|
-
]
|
|
2706
|
-
}
|
|
2707
|
-
},
|
|
2708
|
-
"/admin/v1/workspaces/{id}/keys/{keyId}": {
|
|
2709
|
-
"delete": {
|
|
2710
|
-
"summary": "API キーを無効化する。",
|
|
2711
|
-
"security": [
|
|
2712
|
-
{
|
|
2713
|
-
"adminToken": []
|
|
2714
|
-
}
|
|
2715
|
-
],
|
|
2716
|
-
"parameters": [
|
|
2717
|
-
{
|
|
2718
|
-
"name": "id",
|
|
2719
|
-
"in": "path",
|
|
2720
|
-
"required": true,
|
|
2721
|
-
"schema": {
|
|
2722
|
-
"type": "string"
|
|
2723
|
-
},
|
|
2724
|
-
"description": "ワークスペース id。"
|
|
2725
|
-
},
|
|
2726
|
-
{
|
|
2727
|
-
"name": "keyId",
|
|
2728
|
-
"in": "path",
|
|
2729
|
-
"required": true,
|
|
2730
|
-
"schema": {
|
|
2731
|
-
"type": "string"
|
|
2732
|
-
},
|
|
2733
|
-
"description": "キー id。"
|
|
2734
|
-
}
|
|
2735
|
-
],
|
|
2736
|
-
"responses": {
|
|
2737
|
-
"204": {
|
|
2738
|
-
"description": "無効化完了(冪等)"
|
|
2739
|
-
},
|
|
2740
|
-
"401": {
|
|
2741
|
-
"description": "認証エラー"
|
|
2742
|
-
},
|
|
2743
|
-
"404": {
|
|
2744
|
-
"description": "キーが見つからない"
|
|
2745
|
-
}
|
|
2746
|
-
},
|
|
2747
|
-
"tags": [
|
|
2748
|
-
"API Keys (admin)"
|
|
2749
|
-
]
|
|
2750
|
-
}
|
|
2751
2497
|
}
|
|
2752
2498
|
}
|
|
2753
2499
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hexis-ai/engram-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Engram server: ingest agent session events, persist via a pluggable adapter, expose search.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"engram",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@hexis-ai/engram-core": "^0.3.0",
|
|
53
|
-
"@hexis-ai/engram-sdk": "^0.
|
|
53
|
+
"@hexis-ai/engram-sdk": "^0.16.0",
|
|
54
54
|
"better-auth": "^1.6.11",
|
|
55
55
|
"hono": "^4.6.0",
|
|
56
56
|
"pg": "^8.13.0",
|