@hexis-ai/engram-server 0.1.4 → 0.1.5
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 +24 -0
- package/dist/adapters/memory-key-store.js +108 -0
- package/dist/adapters/memory.d.ts +22 -1
- package/dist/adapters/memory.js +104 -2
- package/dist/adapters/postgres-key-store.d.ts +23 -0
- package/dist/adapters/postgres-key-store.js +161 -0
- package/dist/adapters/postgres.d.ts +21 -1
- package/dist/adapters/postgres.js +175 -7
- package/dist/admin.d.ts +21 -0
- package/dist/admin.js +99 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/key-store.d.ts +63 -0
- package/dist/key-store.js +17 -0
- package/dist/main.js +74 -36
- package/dist/server.d.ts +6 -0
- package/dist/server.js +92 -9
- package/dist/storage.d.ts +37 -4
- package/dist/storage.js +4 -1
- package/package.json +3 -3
|
@@ -6,10 +6,15 @@ CREATE TABLE IF NOT EXISTS engram_sessions (
|
|
|
6
6
|
title TEXT,
|
|
7
7
|
channel TEXT,
|
|
8
8
|
participants TEXT[] NOT NULL DEFAULT '{}',
|
|
9
|
+
viewable_by TEXT[] NOT NULL DEFAULT '{}',
|
|
9
10
|
created_at TIMESTAMPTZ NOT NULL,
|
|
10
11
|
PRIMARY KEY (workspace_id, id)
|
|
11
12
|
);
|
|
12
13
|
|
|
14
|
+
-- Existing deployments may pre-date viewable_by; backfill the column on
|
|
15
|
+
-- upgrade. Idempotent.
|
|
16
|
+
ALTER TABLE engram_sessions ADD COLUMN IF NOT EXISTS viewable_by TEXT[] NOT NULL DEFAULT '{}';
|
|
17
|
+
|
|
13
18
|
CREATE TABLE IF NOT EXISTS engram_events (
|
|
14
19
|
workspace_id TEXT NOT NULL,
|
|
15
20
|
session_id TEXT NOT NULL,
|
|
@@ -22,15 +27,49 @@ CREATE TABLE IF NOT EXISTS engram_events (
|
|
|
22
27
|
REFERENCES engram_sessions(workspace_id, id) ON DELETE CASCADE
|
|
23
28
|
);
|
|
24
29
|
|
|
30
|
+
CREATE TABLE IF NOT EXISTS engram_persons (
|
|
31
|
+
workspace_id TEXT NOT NULL,
|
|
32
|
+
id TEXT NOT NULL,
|
|
33
|
+
display_name TEXT,
|
|
34
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
35
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
36
|
+
PRIMARY KEY (workspace_id, id)
|
|
37
|
+
);
|
|
38
|
+
|
|
25
39
|
CREATE INDEX IF NOT EXISTS idx_engram_sessions_workspace_created
|
|
26
40
|
ON engram_sessions (workspace_id, created_at DESC);
|
|
41
|
+
|
|
42
|
+
-- Person-axis lookups. GIN supports the @> contains operator efficiently.
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_engram_sessions_participants
|
|
44
|
+
ON engram_sessions USING GIN (participants);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_engram_sessions_viewable_by
|
|
46
|
+
ON engram_sessions USING GIN (viewable_by);
|
|
47
|
+
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_engram_persons_updated
|
|
49
|
+
ON engram_persons (workspace_id, updated_at DESC);
|
|
27
50
|
`;
|
|
51
|
+
/**
|
|
52
|
+
* 10-char alphanumeric id, e.g. `p_a8b3c2d4`. Cryptographic randomness via
|
|
53
|
+
* the platform's getRandomValues; collision probability negligible for any
|
|
54
|
+
* realistic person count.
|
|
55
|
+
*/
|
|
56
|
+
function defaultPersonId() {
|
|
57
|
+
const ALPHA = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
58
|
+
const buf = new Uint8Array(8);
|
|
59
|
+
crypto.getRandomValues(buf);
|
|
60
|
+
let out = "p_";
|
|
61
|
+
for (const b of buf)
|
|
62
|
+
out += ALPHA[b % ALPHA.length];
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
28
65
|
export class PostgresAdapter {
|
|
29
66
|
workspaceId;
|
|
30
67
|
sql;
|
|
68
|
+
newPersonId;
|
|
31
69
|
constructor(opts) {
|
|
32
70
|
this.workspaceId = opts.workspaceId;
|
|
33
71
|
this.sql = opts.sql;
|
|
72
|
+
this.newPersonId = opts.newPersonId ?? defaultPersonId;
|
|
34
73
|
}
|
|
35
74
|
/**
|
|
36
75
|
* Create the schema. Call once at boot. Safe to invoke repeatedly.
|
|
@@ -39,15 +78,23 @@ export class PostgresAdapter {
|
|
|
39
78
|
async ensureSchema() {
|
|
40
79
|
await this.sql.unsafe(SCHEMA_SQL);
|
|
41
80
|
}
|
|
81
|
+
// --- Sessions -----------------------------------------------------
|
|
42
82
|
async createSession(init) {
|
|
83
|
+
const participants = init.participants ?? [];
|
|
84
|
+
// viewable_by defaults to participants if omitted; if supplied, union
|
|
85
|
+
// with participants so participants ⊆ viewable_by always holds.
|
|
86
|
+
const viewableBy = init.viewable_by
|
|
87
|
+
? Array.from(new Set([...init.viewable_by, ...participants]))
|
|
88
|
+
: [...participants];
|
|
43
89
|
await this.sql `
|
|
44
|
-
INSERT INTO engram_sessions (workspace_id, id, title, channel, participants, created_at)
|
|
90
|
+
INSERT INTO engram_sessions (workspace_id, id, title, channel, participants, viewable_by, created_at)
|
|
45
91
|
VALUES (
|
|
46
92
|
${this.workspaceId},
|
|
47
93
|
${init.id},
|
|
48
94
|
${init.title ?? null},
|
|
49
95
|
${init.channel ?? null},
|
|
50
|
-
${
|
|
96
|
+
${participants},
|
|
97
|
+
${viewableBy},
|
|
51
98
|
${init.createdAt}
|
|
52
99
|
)
|
|
53
100
|
ON CONFLICT (workspace_id, id) DO NOTHING
|
|
@@ -57,10 +104,6 @@ export class PostgresAdapter {
|
|
|
57
104
|
if (events.length === 0)
|
|
58
105
|
return;
|
|
59
106
|
for (const ev of events) {
|
|
60
|
-
// Pass the event object directly: postgres serializes JS objects as
|
|
61
|
-
// JSON for jsonb columns. Doing JSON.stringify ourselves then casting
|
|
62
|
-
// ::jsonb produced a doubly-encoded string value (jsonb containing
|
|
63
|
-
// a string instead of an object).
|
|
64
107
|
await this.sql `
|
|
65
108
|
INSERT INTO engram_events (workspace_id, session_id, seq, type, at, payload)
|
|
66
109
|
VALUES (
|
|
@@ -73,11 +116,26 @@ export class PostgresAdapter {
|
|
|
73
116
|
)
|
|
74
117
|
ON CONFLICT (workspace_id, session_id, seq) DO NOTHING
|
|
75
118
|
`;
|
|
119
|
+
// Participant events also widen the session's participants array so
|
|
120
|
+
// a one-shot listSessions can answer "who took part" without folding
|
|
121
|
+
// events at read time. viewable_by widens too (participants ⊆ viewable_by).
|
|
122
|
+
if (ev.type === "participant") {
|
|
123
|
+
await this.sql `
|
|
124
|
+
UPDATE engram_sessions
|
|
125
|
+
SET participants = (
|
|
126
|
+
SELECT ARRAY(SELECT DISTINCT unnest(participants || ARRAY[${ev.personId}]::text[]))
|
|
127
|
+
),
|
|
128
|
+
viewable_by = (
|
|
129
|
+
SELECT ARRAY(SELECT DISTINCT unnest(viewable_by || ARRAY[${ev.personId}]::text[]))
|
|
130
|
+
)
|
|
131
|
+
WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
|
|
132
|
+
`;
|
|
133
|
+
}
|
|
76
134
|
}
|
|
77
135
|
}
|
|
78
136
|
async getSession(sessionId) {
|
|
79
137
|
const rows = await this.sql `
|
|
80
|
-
SELECT id, title, channel, participants, created_at
|
|
138
|
+
SELECT id, title, channel, participants, viewable_by, created_at
|
|
81
139
|
FROM engram_sessions
|
|
82
140
|
WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
|
|
83
141
|
LIMIT 1
|
|
@@ -95,6 +153,7 @@ export class PostgresAdapter {
|
|
|
95
153
|
...(r.title ? { title: r.title } : {}),
|
|
96
154
|
...(r.channel ? { channel: r.channel } : {}),
|
|
97
155
|
participants: r.participants,
|
|
156
|
+
viewable_by: r.viewable_by,
|
|
98
157
|
createdAt: typeof r.created_at === "string" ? r.created_at : r.created_at.toISOString(),
|
|
99
158
|
};
|
|
100
159
|
return foldEvents(row, events.map((e) => e.payload), new Date());
|
|
@@ -111,4 +170,113 @@ export class PostgresAdapter {
|
|
|
111
170
|
const sessions = await Promise.all(rows.map((r) => this.getSession(r.id)));
|
|
112
171
|
return sessions.filter((s) => s !== null);
|
|
113
172
|
}
|
|
173
|
+
async sessionsForPerson(personId, opts) {
|
|
174
|
+
const channelFilter = opts.channel ?? null;
|
|
175
|
+
const scope = opts.scope ?? "participant";
|
|
176
|
+
// Identifier (column name) can't be parameterized in tagged templates,
|
|
177
|
+
// so branch the query. Both arms are otherwise identical.
|
|
178
|
+
const rows = scope === "viewable"
|
|
179
|
+
? await this.sql `
|
|
180
|
+
SELECT id FROM engram_sessions
|
|
181
|
+
WHERE workspace_id = ${this.workspaceId}
|
|
182
|
+
AND viewable_by @> ARRAY[${personId}]::text[]
|
|
183
|
+
AND (${channelFilter}::text IS NULL OR channel = ${channelFilter}::text)
|
|
184
|
+
ORDER BY created_at DESC
|
|
185
|
+
LIMIT ${opts.limit}
|
|
186
|
+
`
|
|
187
|
+
: await this.sql `
|
|
188
|
+
SELECT id FROM engram_sessions
|
|
189
|
+
WHERE workspace_id = ${this.workspaceId}
|
|
190
|
+
AND participants @> ARRAY[${personId}]::text[]
|
|
191
|
+
AND (${channelFilter}::text IS NULL OR channel = ${channelFilter}::text)
|
|
192
|
+
ORDER BY created_at DESC
|
|
193
|
+
LIMIT ${opts.limit}
|
|
194
|
+
`;
|
|
195
|
+
const sessions = await Promise.all(rows.map((r) => this.getSession(r.id)));
|
|
196
|
+
return sessions.filter((s) => s !== null);
|
|
197
|
+
}
|
|
198
|
+
// --- Persons ------------------------------------------------------
|
|
199
|
+
async createPerson(input) {
|
|
200
|
+
const id = this.newPersonId();
|
|
201
|
+
return this.upsertPerson(id, input);
|
|
202
|
+
}
|
|
203
|
+
async upsertPerson(id, input) {
|
|
204
|
+
const rows = await this.sql `
|
|
205
|
+
INSERT INTO engram_persons (workspace_id, id, display_name)
|
|
206
|
+
VALUES (${this.workspaceId}, ${id}, ${input.display_name ?? null})
|
|
207
|
+
ON CONFLICT (workspace_id, id) DO UPDATE SET
|
|
208
|
+
display_name = COALESCE(EXCLUDED.display_name, engram_persons.display_name),
|
|
209
|
+
updated_at = now()
|
|
210
|
+
RETURNING id, display_name, created_at, updated_at
|
|
211
|
+
`;
|
|
212
|
+
return toPersonInfo(rows[0]);
|
|
213
|
+
}
|
|
214
|
+
async updatePerson(id, patch) {
|
|
215
|
+
// Treat `null` as an explicit clear; `undefined` as no-op.
|
|
216
|
+
const nameProvided = patch.display_name !== undefined;
|
|
217
|
+
const rows = await this.sql `
|
|
218
|
+
UPDATE engram_persons
|
|
219
|
+
SET display_name = CASE WHEN ${nameProvided} THEN ${patch.display_name ?? null} ELSE display_name END,
|
|
220
|
+
updated_at = now()
|
|
221
|
+
WHERE workspace_id = ${this.workspaceId} AND id = ${id}
|
|
222
|
+
RETURNING id, display_name, created_at, updated_at
|
|
223
|
+
`;
|
|
224
|
+
if (rows.length === 0)
|
|
225
|
+
return null;
|
|
226
|
+
return toPersonInfo(rows[0]);
|
|
227
|
+
}
|
|
228
|
+
async getPerson(id) {
|
|
229
|
+
const rows = await this.sql `
|
|
230
|
+
SELECT id, display_name, created_at, updated_at
|
|
231
|
+
FROM engram_persons
|
|
232
|
+
WHERE workspace_id = ${this.workspaceId} AND id = ${id}
|
|
233
|
+
LIMIT 1
|
|
234
|
+
`;
|
|
235
|
+
if (rows.length === 0)
|
|
236
|
+
return null;
|
|
237
|
+
return toPersonInfo(rows[0]);
|
|
238
|
+
}
|
|
239
|
+
async getPersons(ids) {
|
|
240
|
+
if (ids.length === 0)
|
|
241
|
+
return [];
|
|
242
|
+
const rows = await this.sql `
|
|
243
|
+
SELECT id, display_name, created_at, updated_at
|
|
244
|
+
FROM engram_persons
|
|
245
|
+
WHERE workspace_id = ${this.workspaceId}
|
|
246
|
+
AND id = ANY(${ids}::text[])
|
|
247
|
+
`;
|
|
248
|
+
return rows.map(toPersonInfo);
|
|
249
|
+
}
|
|
250
|
+
async listPersons(opts) {
|
|
251
|
+
const q = opts.q?.trim() ?? "";
|
|
252
|
+
if (q) {
|
|
253
|
+
const pattern = `%${q.toLowerCase()}%`;
|
|
254
|
+
const rows = await this.sql `
|
|
255
|
+
SELECT id, display_name, created_at, updated_at
|
|
256
|
+
FROM engram_persons
|
|
257
|
+
WHERE workspace_id = ${this.workspaceId}
|
|
258
|
+
AND (lower(id) LIKE ${pattern} OR lower(coalesce(display_name, '')) LIKE ${pattern})
|
|
259
|
+
ORDER BY updated_at DESC
|
|
260
|
+
LIMIT ${opts.limit}
|
|
261
|
+
`;
|
|
262
|
+
return rows.map(toPersonInfo);
|
|
263
|
+
}
|
|
264
|
+
const rows = await this.sql `
|
|
265
|
+
SELECT id, display_name, created_at, updated_at
|
|
266
|
+
FROM engram_persons
|
|
267
|
+
WHERE workspace_id = ${this.workspaceId}
|
|
268
|
+
ORDER BY updated_at DESC
|
|
269
|
+
LIMIT ${opts.limit}
|
|
270
|
+
`;
|
|
271
|
+
return rows.map(toPersonInfo);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function toPersonInfo(r) {
|
|
275
|
+
const toIso = (v) => (typeof v === "string" ? v : v.toISOString());
|
|
276
|
+
return {
|
|
277
|
+
id: r.id,
|
|
278
|
+
display_name: r.display_name,
|
|
279
|
+
created_at: toIso(r.created_at),
|
|
280
|
+
updated_at: toIso(r.updated_at),
|
|
281
|
+
};
|
|
114
282
|
}
|
package/dist/admin.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { type KeyStore } from "./key-store";
|
|
3
|
+
export interface AdminOptions {
|
|
4
|
+
/** Bearer token required for every /admin/v1 request. */
|
|
5
|
+
token: string;
|
|
6
|
+
keyStore: KeyStore;
|
|
7
|
+
}
|
|
8
|
+
interface Env {
|
|
9
|
+
Variables: {
|
|
10
|
+
request_id: string;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Build the admin sub-router. Mount under `/admin/v1`.
|
|
15
|
+
*
|
|
16
|
+
* Auth model: a single platform-level bearer token (`ENGRAM_ADMIN_TOKEN`).
|
|
17
|
+
* The admin token is checked here only — it never reaches the workspace
|
|
18
|
+
* KeyStore, so an admin token cannot accidentally double as a workspace key.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createAdminRouter(opts: AdminOptions): Hono<Env>;
|
|
21
|
+
export {};
|
package/dist/admin.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { isValidWorkspaceId } from "./key-store";
|
|
3
|
+
/**
|
|
4
|
+
* Build the admin sub-router. Mount under `/admin/v1`.
|
|
5
|
+
*
|
|
6
|
+
* Auth model: a single platform-level bearer token (`ENGRAM_ADMIN_TOKEN`).
|
|
7
|
+
* The admin token is checked here only — it never reaches the workspace
|
|
8
|
+
* KeyStore, so an admin token cannot accidentally double as a workspace key.
|
|
9
|
+
*/
|
|
10
|
+
export function createAdminRouter(opts) {
|
|
11
|
+
const app = new Hono();
|
|
12
|
+
app.use("*", async (c, next) => {
|
|
13
|
+
const supplied = c.req.header("x-admin-token") ??
|
|
14
|
+
c.req.header("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
15
|
+
if (!supplied || supplied !== opts.token) {
|
|
16
|
+
return c.json({ error: "unauthorized" }, 401);
|
|
17
|
+
}
|
|
18
|
+
await next();
|
|
19
|
+
});
|
|
20
|
+
app.post("/workspaces", async (c) => {
|
|
21
|
+
const body = (await c.req.json().catch(() => null));
|
|
22
|
+
if (body === null)
|
|
23
|
+
return c.json({ error: "invalid_json" }, 400);
|
|
24
|
+
if (body.id !== undefined && !isValidWorkspaceId(body.id)) {
|
|
25
|
+
return c.json({ error: "invalid_workspace_id" }, 400);
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const ws = await opts.keyStore.createWorkspace({
|
|
29
|
+
...(body.id !== undefined ? { id: body.id } : {}),
|
|
30
|
+
...(body.name !== undefined ? { name: body.name } : {}),
|
|
31
|
+
...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
|
|
32
|
+
});
|
|
33
|
+
// Default: issue an initial key so the caller can start using the
|
|
34
|
+
// workspace in one round trip. Opt out with `issueKey: false`.
|
|
35
|
+
if (body.issueKey === false) {
|
|
36
|
+
return c.json({ workspace: ws });
|
|
37
|
+
}
|
|
38
|
+
const key = await opts.keyStore.issueKey(ws.id, {
|
|
39
|
+
...(body.keyName !== undefined ? { name: body.keyName } : {}),
|
|
40
|
+
});
|
|
41
|
+
return c.json({ workspace: ws, key });
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
return c.json({ error: e.message }, 400);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
app.get("/workspaces", async (c) => {
|
|
48
|
+
const workspaces = await opts.keyStore.listWorkspaces();
|
|
49
|
+
return c.json({ workspaces });
|
|
50
|
+
});
|
|
51
|
+
app.get("/workspaces/:id", async (c) => {
|
|
52
|
+
const ws = await opts.keyStore.getWorkspace(c.req.param("id"));
|
|
53
|
+
if (!ws)
|
|
54
|
+
return c.json({ error: "workspace_not_found" }, 404);
|
|
55
|
+
return c.json(ws);
|
|
56
|
+
});
|
|
57
|
+
app.delete("/workspaces/:id", async (c) => {
|
|
58
|
+
const id = c.req.param("id");
|
|
59
|
+
const ws = await opts.keyStore.getWorkspace(id);
|
|
60
|
+
if (!ws)
|
|
61
|
+
return c.json({ error: "workspace_not_found" }, 404);
|
|
62
|
+
await opts.keyStore.deleteWorkspace(id);
|
|
63
|
+
return c.body(null, 204);
|
|
64
|
+
});
|
|
65
|
+
app.post("/workspaces/:id/keys", async (c) => {
|
|
66
|
+
const workspaceId = c.req.param("id");
|
|
67
|
+
const ws = await opts.keyStore.getWorkspace(workspaceId);
|
|
68
|
+
if (!ws)
|
|
69
|
+
return c.json({ error: "workspace_not_found" }, 404);
|
|
70
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
71
|
+
const key = await opts.keyStore.issueKey(workspaceId, {
|
|
72
|
+
...(body.name !== undefined ? { name: body.name } : {}),
|
|
73
|
+
});
|
|
74
|
+
return c.json(key);
|
|
75
|
+
});
|
|
76
|
+
app.get("/workspaces/:id/keys", async (c) => {
|
|
77
|
+
const workspaceId = c.req.param("id");
|
|
78
|
+
const ws = await opts.keyStore.getWorkspace(workspaceId);
|
|
79
|
+
if (!ws)
|
|
80
|
+
return c.json({ error: "workspace_not_found" }, 404);
|
|
81
|
+
const keys = await opts.keyStore.listKeys(workspaceId);
|
|
82
|
+
return c.json({ keys });
|
|
83
|
+
});
|
|
84
|
+
app.delete("/workspaces/:id/keys/:keyId", async (c) => {
|
|
85
|
+
const workspaceId = c.req.param("id");
|
|
86
|
+
const keyId = c.req.param("keyId");
|
|
87
|
+
try {
|
|
88
|
+
await opts.keyStore.revokeKey(workspaceId, keyId);
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
if (e.message === "key_not_found") {
|
|
92
|
+
return c.json({ error: "key_not_found" }, 404);
|
|
93
|
+
}
|
|
94
|
+
throw e;
|
|
95
|
+
}
|
|
96
|
+
return c.body(null, 204);
|
|
97
|
+
});
|
|
98
|
+
return app;
|
|
99
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,3 +2,7 @@ export { createServer, type CreateServerOptions, type AuthResolver, type Workspa
|
|
|
2
2
|
export { foldEvents, type StorageAdapter, type SessionRow, } from "./storage";
|
|
3
3
|
export { InMemoryAdapter } from "./adapters/memory";
|
|
4
4
|
export { PostgresAdapter, type PostgresAdapterOptions, type SqlClient, } from "./adapters/postgres";
|
|
5
|
+
export { type KeyStore, type Workspace, type ApiKeyInfo, type IssuedKey, type KeyResolution, generateRawKey, hashKey, keyPrefix, isValidWorkspaceId, } from "./key-store";
|
|
6
|
+
export { InMemoryKeyStore } from "./adapters/memory-key-store";
|
|
7
|
+
export { PostgresKeyStore } from "./adapters/postgres-key-store";
|
|
8
|
+
export { createAdminRouter, type AdminOptions } from "./admin";
|
package/dist/index.js
CHANGED
|
@@ -2,3 +2,7 @@ export { createServer, } from "./server";
|
|
|
2
2
|
export { foldEvents, } from "./storage";
|
|
3
3
|
export { InMemoryAdapter } from "./adapters/memory";
|
|
4
4
|
export { PostgresAdapter, } from "./adapters/postgres";
|
|
5
|
+
export { generateRawKey, hashKey, keyPrefix, isValidWorkspaceId, } from "./key-store";
|
|
6
|
+
export { InMemoryKeyStore } from "./adapters/memory-key-store";
|
|
7
|
+
export { PostgresKeyStore } from "./adapters/postgres-key-store";
|
|
8
|
+
export { createAdminRouter } from "./admin";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KeyStore owns the workspace + API key registry. It is separate from the
|
|
3
|
+
* session StorageAdapter so the two can scale (and be persisted) independently.
|
|
4
|
+
*
|
|
5
|
+
* Wire format for raw keys: `eng_<32-bytes-base64url>`. Only the SHA-256 hash
|
|
6
|
+
* is persisted — the plaintext key is returned exactly once on issuance.
|
|
7
|
+
*/
|
|
8
|
+
export interface Workspace {
|
|
9
|
+
id: string;
|
|
10
|
+
name?: string;
|
|
11
|
+
metadata?: Record<string, unknown>;
|
|
12
|
+
createdAt: string;
|
|
13
|
+
}
|
|
14
|
+
export interface ApiKeyInfo {
|
|
15
|
+
id: string;
|
|
16
|
+
workspaceId: string;
|
|
17
|
+
prefix: string;
|
|
18
|
+
name?: string;
|
|
19
|
+
createdAt: string;
|
|
20
|
+
lastUsedAt?: string;
|
|
21
|
+
revokedAt?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface IssuedKey extends ApiKeyInfo {
|
|
24
|
+
/** Plaintext key. Returned only at creation; never re-derivable. */
|
|
25
|
+
raw: string;
|
|
26
|
+
}
|
|
27
|
+
export interface KeyResolution {
|
|
28
|
+
workspaceId: string;
|
|
29
|
+
keyId: string;
|
|
30
|
+
}
|
|
31
|
+
export interface KeyStore {
|
|
32
|
+
createWorkspace(input: {
|
|
33
|
+
id?: string;
|
|
34
|
+
name?: string;
|
|
35
|
+
metadata?: Record<string, unknown>;
|
|
36
|
+
}): Promise<Workspace>;
|
|
37
|
+
getWorkspace(id: string): Promise<Workspace | null>;
|
|
38
|
+
listWorkspaces(): Promise<Workspace[]>;
|
|
39
|
+
/** Hard delete: cascades to keys, sessions, and events for this workspace. */
|
|
40
|
+
deleteWorkspace(id: string): Promise<void>;
|
|
41
|
+
issueKey(workspaceId: string, opts?: {
|
|
42
|
+
name?: string;
|
|
43
|
+
}): Promise<IssuedKey>;
|
|
44
|
+
listKeys(workspaceId: string): Promise<ApiKeyInfo[]>;
|
|
45
|
+
revokeKey(workspaceId: string, keyId: string): Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* Verify a raw bearer token. Returns the workspace it belongs to, or null
|
|
48
|
+
* if unknown or revoked. Implementations may update last_used_at as a
|
|
49
|
+
* best-effort side effect.
|
|
50
|
+
*/
|
|
51
|
+
resolveKey(rawKey: string): Promise<KeyResolution | null>;
|
|
52
|
+
/**
|
|
53
|
+
* Register a pre-existing raw key under a workspace. Used at bootstrap to
|
|
54
|
+
* preserve a legacy `ENGRAM_API_KEY` without forcing a re-provisioning of
|
|
55
|
+
* existing callers. Idempotent on (workspace_id, key_hash).
|
|
56
|
+
*/
|
|
57
|
+
registerLegacyKey(workspaceId: string, rawKey: string, name?: string): Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
export declare const KEY_PREFIX = "eng_";
|
|
60
|
+
export declare function generateRawKey(): string;
|
|
61
|
+
export declare function hashKey(raw: string): string;
|
|
62
|
+
export declare function keyPrefix(raw: string): string;
|
|
63
|
+
export declare function isValidWorkspaceId(id: string): boolean;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
export const KEY_PREFIX = "eng_";
|
|
3
|
+
const RANDOM_BYTES = 32;
|
|
4
|
+
const PREFIX_DISPLAY_LEN = KEY_PREFIX.length + 8;
|
|
5
|
+
const WORKSPACE_ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
6
|
+
export function generateRawKey() {
|
|
7
|
+
return `${KEY_PREFIX}${randomBytes(RANDOM_BYTES).toString("base64url")}`;
|
|
8
|
+
}
|
|
9
|
+
export function hashKey(raw) {
|
|
10
|
+
return createHash("sha256").update(raw).digest("hex");
|
|
11
|
+
}
|
|
12
|
+
export function keyPrefix(raw) {
|
|
13
|
+
return raw.slice(0, PREFIX_DISPLAY_LEN);
|
|
14
|
+
}
|
|
15
|
+
export function isValidWorkspaceId(id) {
|
|
16
|
+
return WORKSPACE_ID_RE.test(id);
|
|
17
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -1,53 +1,91 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Production entrypoint.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Required env:
|
|
5
|
+
* ENGRAM_ADMIN_TOKEN platform-level bearer for `/admin/v1/*`. Treat as a
|
|
6
|
+
* root credential — anyone with it can mint workspaces
|
|
7
|
+
* and API keys.
|
|
5
8
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
9
|
+
* Optional env:
|
|
10
|
+
* PORT default 8080
|
|
11
|
+
* DATABASE_URL if unset, falls back to InMemoryKeyStore +
|
|
12
|
+
* InMemoryAdapter (NOT durable across restarts)
|
|
13
|
+
* DATABASE_SOCKET_PATH Cloud SQL Auth Proxy unix socket dir
|
|
14
|
+
* ENGRAM_API_KEY legacy single-tenant key. When set, a workspace
|
|
15
|
+
* identified by ENGRAM_WORKSPACE_ID is created on
|
|
16
|
+
* boot and this raw key is registered against it,
|
|
17
|
+
* keeping existing single-tenant deploys working
|
|
18
|
+
* while the multi-tenant flow rolls out.
|
|
19
|
+
* ENGRAM_WORKSPACE_ID default "default" — the workspace id used for
|
|
20
|
+
* the legacy bootstrap above.
|
|
18
21
|
*/
|
|
19
22
|
import { createServer } from "./server";
|
|
20
23
|
import { InMemoryAdapter } from "./adapters/memory";
|
|
21
24
|
import { PostgresAdapter } from "./adapters/postgres";
|
|
25
|
+
import { InMemoryKeyStore } from "./adapters/memory-key-store";
|
|
26
|
+
import { PostgresKeyStore } from "./adapters/postgres-key-store";
|
|
22
27
|
const PORT = Number(process.env.PORT ?? 8080);
|
|
23
|
-
const
|
|
24
|
-
const
|
|
28
|
+
const ADMIN_TOKEN = process.env.ENGRAM_ADMIN_TOKEN;
|
|
29
|
+
const LEGACY_API_KEY = process.env.ENGRAM_API_KEY;
|
|
30
|
+
const LEGACY_WORKSPACE_ID = process.env.ENGRAM_WORKSPACE_ID ?? "default";
|
|
25
31
|
const DATABASE_URL = process.env.DATABASE_URL;
|
|
26
32
|
const DATABASE_SOCKET_PATH = process.env.DATABASE_SOCKET_PATH;
|
|
27
|
-
if (!
|
|
28
|
-
console.error("[engram-server]
|
|
33
|
+
if (!ADMIN_TOKEN) {
|
|
34
|
+
console.error("[engram-server] ENGRAM_ADMIN_TOKEN is required");
|
|
29
35
|
process.exit(1);
|
|
30
36
|
}
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const { default: postgres } = await import("postgres");
|
|
38
|
-
const sql = DATABASE_SOCKET_PATH
|
|
39
|
-
? postgres(DATABASE_URL, { host: DATABASE_SOCKET_PATH })
|
|
40
|
-
: postgres(DATABASE_URL);
|
|
41
|
-
const adapter = new PostgresAdapter({
|
|
42
|
-
workspaceId: WORKSPACE_ID,
|
|
43
|
-
sql: sql,
|
|
44
|
-
});
|
|
45
|
-
await adapter.ensureSchema();
|
|
46
|
-
console.log(`[engram-server] postgres adapter ready (workspace=${WORKSPACE_ID})`);
|
|
47
|
-
return adapter;
|
|
48
|
-
})();
|
|
37
|
+
const { keyStore, getStorage } = await buildStores();
|
|
38
|
+
if (LEGACY_API_KEY) {
|
|
39
|
+
await keyStore.createWorkspace({ id: LEGACY_WORKSPACE_ID, name: "Legacy" });
|
|
40
|
+
await keyStore.registerLegacyKey(LEGACY_WORKSPACE_ID, LEGACY_API_KEY, "ENGRAM_API_KEY");
|
|
41
|
+
console.log(`[engram-server] legacy key bootstrapped (workspace=${LEGACY_WORKSPACE_ID})`);
|
|
42
|
+
}
|
|
49
43
|
const app = createServer({
|
|
50
|
-
auth: (key) =>
|
|
44
|
+
auth: async (key) => {
|
|
45
|
+
const r = await keyStore.resolveKey(key);
|
|
46
|
+
if (!r)
|
|
47
|
+
return null;
|
|
48
|
+
return { workspaceId: r.workspaceId, storage: getStorage(r.workspaceId) };
|
|
49
|
+
},
|
|
50
|
+
admin: { token: ADMIN_TOKEN, keyStore },
|
|
51
51
|
});
|
|
52
52
|
console.log(`[engram-server] listening on :${PORT}`);
|
|
53
53
|
export default { port: PORT, fetch: app.fetch };
|
|
54
|
+
async function buildStores() {
|
|
55
|
+
if (!DATABASE_URL) {
|
|
56
|
+
console.warn("[engram-server] DATABASE_URL not set — in-memory mode (data is volatile)");
|
|
57
|
+
const ks = new InMemoryKeyStore();
|
|
58
|
+
const adapters = new Map();
|
|
59
|
+
return {
|
|
60
|
+
keyStore: ks,
|
|
61
|
+
getStorage: (workspaceId) => {
|
|
62
|
+
let a = adapters.get(workspaceId);
|
|
63
|
+
if (!a) {
|
|
64
|
+
a = new InMemoryAdapter();
|
|
65
|
+
adapters.set(workspaceId, a);
|
|
66
|
+
}
|
|
67
|
+
return a;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const { default: postgres } = await import("postgres");
|
|
72
|
+
const sql = (DATABASE_SOCKET_PATH
|
|
73
|
+
? postgres(DATABASE_URL, { host: DATABASE_SOCKET_PATH })
|
|
74
|
+
: postgres(DATABASE_URL));
|
|
75
|
+
const ks = new PostgresKeyStore(sql);
|
|
76
|
+
await ks.ensureSchema();
|
|
77
|
+
// Session schema is workspace-independent — one bootstrap call is enough.
|
|
78
|
+
await new PostgresAdapter({ workspaceId: "__bootstrap__", sql }).ensureSchema();
|
|
79
|
+
const adapters = new Map();
|
|
80
|
+
return {
|
|
81
|
+
keyStore: ks,
|
|
82
|
+
getStorage: (workspaceId) => {
|
|
83
|
+
let a = adapters.get(workspaceId);
|
|
84
|
+
if (!a) {
|
|
85
|
+
a = new PostgresAdapter({ workspaceId, sql });
|
|
86
|
+
adapters.set(workspaceId, a);
|
|
87
|
+
}
|
|
88
|
+
return a;
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import type { StorageAdapter } from "./storage";
|
|
3
|
+
import { type AdminOptions } from "./admin";
|
|
3
4
|
/**
|
|
4
5
|
* Resolve an API key (raw `Authorization: Bearer <key>` token) into a
|
|
5
6
|
* workspace context. Throwing or returning null short-circuits to 401.
|
|
@@ -25,6 +26,11 @@ export interface CreateServerOptions {
|
|
|
25
26
|
*/
|
|
26
27
|
defaultListLimit?: number;
|
|
27
28
|
maxListLimit?: number;
|
|
29
|
+
/**
|
|
30
|
+
* When set, mounts the admin sub-router at `/admin/v1`. Admin auth is
|
|
31
|
+
* a separate platform token, never crossed with workspace API keys.
|
|
32
|
+
*/
|
|
33
|
+
admin?: AdminOptions;
|
|
28
34
|
}
|
|
29
35
|
interface Env {
|
|
30
36
|
Variables: {
|