@hexis-ai/engram-server 0.1.4 → 0.1.6
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 +26 -0
- package/dist/adapters/postgres-key-store.js +143 -0
- package/dist/adapters/postgres.d.ts +23 -3
- package/dist/adapters/postgres.js +156 -36
- 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 +64 -36
- package/dist/migrations/0001-baseline.d.ts +2 -0
- package/dist/migrations/0001-baseline.js +72 -0
- package/dist/migrations/index.d.ts +12 -0
- package/dist/migrations/index.js +9 -0
- package/dist/migrator.d.ts +17 -0
- package/dist/migrator.js +37 -0
- 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 +14 -5
|
@@ -1,53 +1,52 @@
|
|
|
1
|
+
import { runMigrations } from "../migrator";
|
|
1
2
|
import { foldEvents } from "../storage";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
seq INTEGER NOT NULL,
|
|
17
|
-
type TEXT NOT NULL,
|
|
18
|
-
at TIMESTAMPTZ NOT NULL,
|
|
19
|
-
payload JSONB NOT NULL,
|
|
20
|
-
PRIMARY KEY (workspace_id, session_id, seq),
|
|
21
|
-
FOREIGN KEY (workspace_id, session_id)
|
|
22
|
-
REFERENCES engram_sessions(workspace_id, id) ON DELETE CASCADE
|
|
23
|
-
);
|
|
24
|
-
|
|
25
|
-
CREATE INDEX IF NOT EXISTS idx_engram_sessions_workspace_created
|
|
26
|
-
ON engram_sessions (workspace_id, created_at DESC);
|
|
27
|
-
`;
|
|
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
|
+
}
|
|
28
17
|
export class PostgresAdapter {
|
|
29
18
|
workspaceId;
|
|
30
19
|
sql;
|
|
20
|
+
newPersonId;
|
|
31
21
|
constructor(opts) {
|
|
32
22
|
this.workspaceId = opts.workspaceId;
|
|
33
23
|
this.sql = opts.sql;
|
|
24
|
+
this.newPersonId = opts.newPersonId ?? defaultPersonId;
|
|
34
25
|
}
|
|
35
26
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
27
|
+
* Apply all pending schema migrations. Safe to call repeatedly and
|
|
28
|
+
* concurrently with other instances — see `runMigrations` for details.
|
|
38
29
|
*/
|
|
39
30
|
async ensureSchema() {
|
|
40
|
-
await this.sql
|
|
31
|
+
await runMigrations(this.sql);
|
|
41
32
|
}
|
|
33
|
+
// --- Sessions -----------------------------------------------------
|
|
42
34
|
async createSession(init) {
|
|
35
|
+
const participants = init.participants ?? [];
|
|
36
|
+
// viewable_by defaults to participants if omitted; if supplied, union
|
|
37
|
+
// with participants so participants ⊆ viewable_by always holds.
|
|
38
|
+
const viewableBy = init.viewable_by
|
|
39
|
+
? Array.from(new Set([...init.viewable_by, ...participants]))
|
|
40
|
+
: [...participants];
|
|
43
41
|
await this.sql `
|
|
44
|
-
INSERT INTO engram_sessions (workspace_id, id, title, channel, participants, created_at)
|
|
42
|
+
INSERT INTO engram_sessions (workspace_id, id, title, channel, participants, viewable_by, created_at)
|
|
45
43
|
VALUES (
|
|
46
44
|
${this.workspaceId},
|
|
47
45
|
${init.id},
|
|
48
46
|
${init.title ?? null},
|
|
49
47
|
${init.channel ?? null},
|
|
50
|
-
${
|
|
48
|
+
${participants},
|
|
49
|
+
${viewableBy},
|
|
51
50
|
${init.createdAt}
|
|
52
51
|
)
|
|
53
52
|
ON CONFLICT (workspace_id, id) DO NOTHING
|
|
@@ -57,10 +56,6 @@ export class PostgresAdapter {
|
|
|
57
56
|
if (events.length === 0)
|
|
58
57
|
return;
|
|
59
58
|
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
59
|
await this.sql `
|
|
65
60
|
INSERT INTO engram_events (workspace_id, session_id, seq, type, at, payload)
|
|
66
61
|
VALUES (
|
|
@@ -73,11 +68,26 @@ export class PostgresAdapter {
|
|
|
73
68
|
)
|
|
74
69
|
ON CONFLICT (workspace_id, session_id, seq) DO NOTHING
|
|
75
70
|
`;
|
|
71
|
+
// Participant events also widen the session's participants array so
|
|
72
|
+
// a one-shot listSessions can answer "who took part" without folding
|
|
73
|
+
// events at read time. viewable_by widens too (participants ⊆ viewable_by).
|
|
74
|
+
if (ev.type === "participant") {
|
|
75
|
+
await this.sql `
|
|
76
|
+
UPDATE engram_sessions
|
|
77
|
+
SET participants = (
|
|
78
|
+
SELECT ARRAY(SELECT DISTINCT unnest(participants || ARRAY[${ev.personId}]::text[]))
|
|
79
|
+
),
|
|
80
|
+
viewable_by = (
|
|
81
|
+
SELECT ARRAY(SELECT DISTINCT unnest(viewable_by || ARRAY[${ev.personId}]::text[]))
|
|
82
|
+
)
|
|
83
|
+
WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
|
|
84
|
+
`;
|
|
85
|
+
}
|
|
76
86
|
}
|
|
77
87
|
}
|
|
78
88
|
async getSession(sessionId) {
|
|
79
89
|
const rows = await this.sql `
|
|
80
|
-
SELECT id, title, channel, participants, created_at
|
|
90
|
+
SELECT id, title, channel, participants, viewable_by, created_at
|
|
81
91
|
FROM engram_sessions
|
|
82
92
|
WHERE workspace_id = ${this.workspaceId} AND id = ${sessionId}
|
|
83
93
|
LIMIT 1
|
|
@@ -95,6 +105,7 @@ export class PostgresAdapter {
|
|
|
95
105
|
...(r.title ? { title: r.title } : {}),
|
|
96
106
|
...(r.channel ? { channel: r.channel } : {}),
|
|
97
107
|
participants: r.participants,
|
|
108
|
+
viewable_by: r.viewable_by,
|
|
98
109
|
createdAt: typeof r.created_at === "string" ? r.created_at : r.created_at.toISOString(),
|
|
99
110
|
};
|
|
100
111
|
return foldEvents(row, events.map((e) => e.payload), new Date());
|
|
@@ -111,4 +122,113 @@ export class PostgresAdapter {
|
|
|
111
122
|
const sessions = await Promise.all(rows.map((r) => this.getSession(r.id)));
|
|
112
123
|
return sessions.filter((s) => s !== null);
|
|
113
124
|
}
|
|
125
|
+
async sessionsForPerson(personId, opts) {
|
|
126
|
+
const channelFilter = opts.channel ?? null;
|
|
127
|
+
const scope = opts.scope ?? "participant";
|
|
128
|
+
// Identifier (column name) can't be parameterized in tagged templates,
|
|
129
|
+
// so branch the query. Both arms are otherwise identical.
|
|
130
|
+
const rows = scope === "viewable"
|
|
131
|
+
? await this.sql `
|
|
132
|
+
SELECT id FROM engram_sessions
|
|
133
|
+
WHERE workspace_id = ${this.workspaceId}
|
|
134
|
+
AND viewable_by @> ARRAY[${personId}]::text[]
|
|
135
|
+
AND (${channelFilter}::text IS NULL OR channel = ${channelFilter}::text)
|
|
136
|
+
ORDER BY created_at DESC
|
|
137
|
+
LIMIT ${opts.limit}
|
|
138
|
+
`
|
|
139
|
+
: await this.sql `
|
|
140
|
+
SELECT id FROM engram_sessions
|
|
141
|
+
WHERE workspace_id = ${this.workspaceId}
|
|
142
|
+
AND participants @> ARRAY[${personId}]::text[]
|
|
143
|
+
AND (${channelFilter}::text IS NULL OR channel = ${channelFilter}::text)
|
|
144
|
+
ORDER BY created_at DESC
|
|
145
|
+
LIMIT ${opts.limit}
|
|
146
|
+
`;
|
|
147
|
+
const sessions = await Promise.all(rows.map((r) => this.getSession(r.id)));
|
|
148
|
+
return sessions.filter((s) => s !== null);
|
|
149
|
+
}
|
|
150
|
+
// --- Persons ------------------------------------------------------
|
|
151
|
+
async createPerson(input) {
|
|
152
|
+
const id = this.newPersonId();
|
|
153
|
+
return this.upsertPerson(id, input);
|
|
154
|
+
}
|
|
155
|
+
async upsertPerson(id, input) {
|
|
156
|
+
const rows = await this.sql `
|
|
157
|
+
INSERT INTO engram_persons (workspace_id, id, display_name)
|
|
158
|
+
VALUES (${this.workspaceId}, ${id}, ${input.display_name ?? null})
|
|
159
|
+
ON CONFLICT (workspace_id, id) DO UPDATE SET
|
|
160
|
+
display_name = COALESCE(EXCLUDED.display_name, engram_persons.display_name),
|
|
161
|
+
updated_at = now()
|
|
162
|
+
RETURNING id, display_name, created_at, updated_at
|
|
163
|
+
`;
|
|
164
|
+
return toPersonInfo(rows[0]);
|
|
165
|
+
}
|
|
166
|
+
async updatePerson(id, patch) {
|
|
167
|
+
// Treat `null` as an explicit clear; `undefined` as no-op.
|
|
168
|
+
const nameProvided = patch.display_name !== undefined;
|
|
169
|
+
const rows = await this.sql `
|
|
170
|
+
UPDATE engram_persons
|
|
171
|
+
SET display_name = CASE WHEN ${nameProvided} THEN ${patch.display_name ?? null} ELSE display_name END,
|
|
172
|
+
updated_at = now()
|
|
173
|
+
WHERE workspace_id = ${this.workspaceId} AND id = ${id}
|
|
174
|
+
RETURNING id, display_name, created_at, updated_at
|
|
175
|
+
`;
|
|
176
|
+
if (rows.length === 0)
|
|
177
|
+
return null;
|
|
178
|
+
return toPersonInfo(rows[0]);
|
|
179
|
+
}
|
|
180
|
+
async getPerson(id) {
|
|
181
|
+
const rows = await this.sql `
|
|
182
|
+
SELECT id, display_name, created_at, updated_at
|
|
183
|
+
FROM engram_persons
|
|
184
|
+
WHERE workspace_id = ${this.workspaceId} AND id = ${id}
|
|
185
|
+
LIMIT 1
|
|
186
|
+
`;
|
|
187
|
+
if (rows.length === 0)
|
|
188
|
+
return null;
|
|
189
|
+
return toPersonInfo(rows[0]);
|
|
190
|
+
}
|
|
191
|
+
async getPersons(ids) {
|
|
192
|
+
if (ids.length === 0)
|
|
193
|
+
return [];
|
|
194
|
+
const rows = await this.sql `
|
|
195
|
+
SELECT id, display_name, created_at, updated_at
|
|
196
|
+
FROM engram_persons
|
|
197
|
+
WHERE workspace_id = ${this.workspaceId}
|
|
198
|
+
AND id = ANY(${ids}::text[])
|
|
199
|
+
`;
|
|
200
|
+
return rows.map(toPersonInfo);
|
|
201
|
+
}
|
|
202
|
+
async listPersons(opts) {
|
|
203
|
+
const q = opts.q?.trim() ?? "";
|
|
204
|
+
if (q) {
|
|
205
|
+
const pattern = `%${q.toLowerCase()}%`;
|
|
206
|
+
const rows = await this.sql `
|
|
207
|
+
SELECT id, display_name, created_at, updated_at
|
|
208
|
+
FROM engram_persons
|
|
209
|
+
WHERE workspace_id = ${this.workspaceId}
|
|
210
|
+
AND (lower(id) LIKE ${pattern} OR lower(coalesce(display_name, '')) LIKE ${pattern})
|
|
211
|
+
ORDER BY updated_at DESC
|
|
212
|
+
LIMIT ${opts.limit}
|
|
213
|
+
`;
|
|
214
|
+
return rows.map(toPersonInfo);
|
|
215
|
+
}
|
|
216
|
+
const rows = await this.sql `
|
|
217
|
+
SELECT id, display_name, created_at, updated_at
|
|
218
|
+
FROM engram_persons
|
|
219
|
+
WHERE workspace_id = ${this.workspaceId}
|
|
220
|
+
ORDER BY updated_at DESC
|
|
221
|
+
LIMIT ${opts.limit}
|
|
222
|
+
`;
|
|
223
|
+
return rows.map(toPersonInfo);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function toPersonInfo(r) {
|
|
227
|
+
const toIso = (v) => (typeof v === "string" ? v : v.toISOString());
|
|
228
|
+
return {
|
|
229
|
+
id: r.id,
|
|
230
|
+
display_name: r.display_name,
|
|
231
|
+
created_at: toIso(r.created_at),
|
|
232
|
+
updated_at: toIso(r.updated_at),
|
|
233
|
+
};
|
|
114
234
|
}
|
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,81 @@
|
|
|
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
|
-
* ENGRAM_WORKSPACE_ID default "default"
|
|
16
|
-
* workspace id baked into the postgres
|
|
17
|
-
* adapter for this single-tenant deploy.
|
|
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
|
+
*
|
|
15
|
+
* Workspaces and their API keys are provisioned exclusively through the
|
|
16
|
+
* admin API. There is no single-tenant fallback — every caller must hold a
|
|
17
|
+
* workspace-scoped key issued by `POST /admin/v1/workspaces`.
|
|
18
18
|
*/
|
|
19
19
|
import { createServer } from "./server";
|
|
20
20
|
import { InMemoryAdapter } from "./adapters/memory";
|
|
21
21
|
import { PostgresAdapter } from "./adapters/postgres";
|
|
22
|
+
import { InMemoryKeyStore } from "./adapters/memory-key-store";
|
|
23
|
+
import { PostgresKeyStore } from "./adapters/postgres-key-store";
|
|
22
24
|
const PORT = Number(process.env.PORT ?? 8080);
|
|
23
|
-
const
|
|
24
|
-
const WORKSPACE_ID = process.env.ENGRAM_WORKSPACE_ID ?? "default";
|
|
25
|
+
const ADMIN_TOKEN = process.env.ENGRAM_ADMIN_TOKEN;
|
|
25
26
|
const DATABASE_URL = process.env.DATABASE_URL;
|
|
26
27
|
const DATABASE_SOCKET_PATH = process.env.DATABASE_SOCKET_PATH;
|
|
27
|
-
if (!
|
|
28
|
-
console.error("[engram-server]
|
|
28
|
+
if (!ADMIN_TOKEN) {
|
|
29
|
+
console.error("[engram-server] ENGRAM_ADMIN_TOKEN is required");
|
|
29
30
|
process.exit(1);
|
|
30
31
|
}
|
|
31
|
-
const
|
|
32
|
-
if (!DATABASE_URL) {
|
|
33
|
-
console.warn("[engram-server] DATABASE_URL not set — using InMemoryAdapter (data is volatile)");
|
|
34
|
-
return new InMemoryAdapter();
|
|
35
|
-
}
|
|
36
|
-
// postgres is a peer dep so we import it lazily; absence is a hard error here.
|
|
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
|
-
})();
|
|
32
|
+
const { keyStore, getStorage } = await buildStores();
|
|
49
33
|
const app = createServer({
|
|
50
|
-
auth: (key) =>
|
|
34
|
+
auth: async (key) => {
|
|
35
|
+
const r = await keyStore.resolveKey(key);
|
|
36
|
+
if (!r)
|
|
37
|
+
return null;
|
|
38
|
+
return { workspaceId: r.workspaceId, storage: getStorage(r.workspaceId) };
|
|
39
|
+
},
|
|
40
|
+
admin: { token: ADMIN_TOKEN, keyStore },
|
|
51
41
|
});
|
|
52
42
|
console.log(`[engram-server] listening on :${PORT}`);
|
|
53
43
|
export default { port: PORT, fetch: app.fetch };
|
|
44
|
+
async function buildStores() {
|
|
45
|
+
if (!DATABASE_URL) {
|
|
46
|
+
console.warn("[engram-server] DATABASE_URL not set — in-memory mode (data is volatile)");
|
|
47
|
+
const ks = new InMemoryKeyStore();
|
|
48
|
+
const adapters = new Map();
|
|
49
|
+
return {
|
|
50
|
+
keyStore: ks,
|
|
51
|
+
getStorage: (workspaceId) => {
|
|
52
|
+
let a = adapters.get(workspaceId);
|
|
53
|
+
if (!a) {
|
|
54
|
+
a = new InMemoryAdapter();
|
|
55
|
+
adapters.set(workspaceId, a);
|
|
56
|
+
}
|
|
57
|
+
return a;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const { default: postgres } = await import("postgres");
|
|
62
|
+
const sql = (DATABASE_SOCKET_PATH
|
|
63
|
+
? postgres(DATABASE_URL, { host: DATABASE_SOCKET_PATH })
|
|
64
|
+
: postgres(DATABASE_URL));
|
|
65
|
+
const ks = new PostgresKeyStore(sql);
|
|
66
|
+
await ks.ensureSchema();
|
|
67
|
+
// Session schema is workspace-independent — one bootstrap call is enough.
|
|
68
|
+
await new PostgresAdapter({ workspaceId: "__bootstrap__", sql }).ensureSchema();
|
|
69
|
+
const adapters = new Map();
|
|
70
|
+
return {
|
|
71
|
+
keyStore: ks,
|
|
72
|
+
getStorage: (workspaceId) => {
|
|
73
|
+
let a = adapters.get(workspaceId);
|
|
74
|
+
if (!a) {
|
|
75
|
+
a = new PostgresAdapter({ workspaceId, sql });
|
|
76
|
+
adapters.set(workspaceId, a);
|
|
77
|
+
}
|
|
78
|
+
return a;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export declare const name = "0001-baseline";
|
|
2
|
+
export declare const sql = "\n-- Session storage (workspace-scoped).\nCREATE TABLE IF NOT EXISTS engram_sessions (\n workspace_id TEXT NOT NULL,\n id TEXT NOT NULL,\n title TEXT,\n channel TEXT,\n participants TEXT[] NOT NULL DEFAULT '{}',\n viewable_by TEXT[] NOT NULL DEFAULT '{}',\n created_at TIMESTAMPTZ NOT NULL,\n PRIMARY KEY (workspace_id, id)\n);\n\n-- Deployments that pre-date viewable_by need the column backfilled.\nALTER TABLE engram_sessions ADD COLUMN IF NOT EXISTS viewable_by TEXT[] NOT NULL DEFAULT '{}';\n\nCREATE TABLE IF NOT EXISTS engram_events (\n workspace_id TEXT NOT NULL,\n session_id TEXT NOT NULL,\n seq INTEGER NOT NULL,\n type TEXT NOT NULL,\n at TIMESTAMPTZ NOT NULL,\n payload JSONB NOT NULL,\n PRIMARY KEY (workspace_id, session_id, seq),\n FOREIGN KEY (workspace_id, session_id)\n REFERENCES engram_sessions(workspace_id, id) ON DELETE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS engram_persons (\n workspace_id TEXT NOT NULL,\n id TEXT NOT NULL,\n display_name TEXT,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n PRIMARY KEY (workspace_id, id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_engram_sessions_workspace_created\n ON engram_sessions (workspace_id, created_at DESC);\n\n-- Person-axis lookups. GIN supports the @> contains operator efficiently.\nCREATE INDEX IF NOT EXISTS idx_engram_sessions_participants\n ON engram_sessions USING GIN (participants);\nCREATE INDEX IF NOT EXISTS idx_engram_sessions_viewable_by\n ON engram_sessions USING GIN (viewable_by);\n\nCREATE INDEX IF NOT EXISTS idx_engram_persons_updated\n ON engram_persons (workspace_id, updated_at DESC);\n\n-- Control plane: workspaces + API keys.\nCREATE TABLE IF NOT EXISTS engram_workspaces (\n id TEXT PRIMARY KEY,\n name TEXT,\n metadata JSONB NOT NULL DEFAULT '{}',\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n\nCREATE TABLE IF NOT EXISTS engram_api_keys (\n id TEXT PRIMARY KEY,\n workspace_id TEXT NOT NULL REFERENCES engram_workspaces(id) ON DELETE CASCADE,\n key_hash TEXT NOT NULL UNIQUE,\n prefix TEXT NOT NULL,\n name TEXT,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n last_used_at TIMESTAMPTZ,\n revoked_at TIMESTAMPTZ\n);\n\nCREATE INDEX IF NOT EXISTS idx_engram_api_keys_workspace\n ON engram_api_keys (workspace_id);\n";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export const name = "0001-baseline";
|
|
2
|
+
export const sql = `
|
|
3
|
+
-- Session storage (workspace-scoped).
|
|
4
|
+
CREATE TABLE IF NOT EXISTS engram_sessions (
|
|
5
|
+
workspace_id TEXT NOT NULL,
|
|
6
|
+
id TEXT NOT NULL,
|
|
7
|
+
title TEXT,
|
|
8
|
+
channel TEXT,
|
|
9
|
+
participants TEXT[] NOT NULL DEFAULT '{}',
|
|
10
|
+
viewable_by TEXT[] NOT NULL DEFAULT '{}',
|
|
11
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
12
|
+
PRIMARY KEY (workspace_id, id)
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
-- Deployments that pre-date viewable_by need the column backfilled.
|
|
16
|
+
ALTER TABLE engram_sessions ADD COLUMN IF NOT EXISTS viewable_by TEXT[] NOT NULL DEFAULT '{}';
|
|
17
|
+
|
|
18
|
+
CREATE TABLE IF NOT EXISTS engram_events (
|
|
19
|
+
workspace_id TEXT NOT NULL,
|
|
20
|
+
session_id TEXT NOT NULL,
|
|
21
|
+
seq INTEGER NOT NULL,
|
|
22
|
+
type TEXT NOT NULL,
|
|
23
|
+
at TIMESTAMPTZ NOT NULL,
|
|
24
|
+
payload JSONB NOT NULL,
|
|
25
|
+
PRIMARY KEY (workspace_id, session_id, seq),
|
|
26
|
+
FOREIGN KEY (workspace_id, session_id)
|
|
27
|
+
REFERENCES engram_sessions(workspace_id, id) ON DELETE CASCADE
|
|
28
|
+
);
|
|
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
|
+
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_engram_sessions_workspace_created
|
|
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);
|
|
50
|
+
|
|
51
|
+
-- Control plane: workspaces + API keys.
|
|
52
|
+
CREATE TABLE IF NOT EXISTS engram_workspaces (
|
|
53
|
+
id TEXT PRIMARY KEY,
|
|
54
|
+
name TEXT,
|
|
55
|
+
metadata JSONB NOT NULL DEFAULT '{}',
|
|
56
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
CREATE TABLE IF NOT EXISTS engram_api_keys (
|
|
60
|
+
id TEXT PRIMARY KEY,
|
|
61
|
+
workspace_id TEXT NOT NULL REFERENCES engram_workspaces(id) ON DELETE CASCADE,
|
|
62
|
+
key_hash TEXT NOT NULL UNIQUE,
|
|
63
|
+
prefix TEXT NOT NULL,
|
|
64
|
+
name TEXT,
|
|
65
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
66
|
+
last_used_at TIMESTAMPTZ,
|
|
67
|
+
revoked_at TIMESTAMPTZ
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
CREATE INDEX IF NOT EXISTS idx_engram_api_keys_workspace
|
|
71
|
+
ON engram_api_keys (workspace_id);
|
|
72
|
+
`;
|