@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.
@@ -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
- ${init.participants ?? []},
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
  }
@@ -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
- * Reads configuration from environment:
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
- * ENGRAM_API_KEY required single bearer key. Multi-tenant deploys
7
- * should swap this for a real key store.
8
- * PORT default 8080 HTTP listen port.
9
- * DATABASE_URL optional if set, uses PostgresAdapter; otherwise
10
- * falls back to InMemoryAdapter (NOT
11
- * durable across restarts).
12
- * DATABASE_SOCKET_PATH optional Cloud SQL Auth Proxy unix socket dir.
13
- * When set, postgres connects via
14
- * `host=<DATABASE_SOCKET_PATH>`.
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
+ * 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 API_KEY = process.env.ENGRAM_API_KEY;
24
- const WORKSPACE_ID = process.env.ENGRAM_WORKSPACE_ID ?? "default";
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 (!API_KEY) {
28
- console.error("[engram-server] ENGRAM_API_KEY is required");
33
+ if (!ADMIN_TOKEN) {
34
+ console.error("[engram-server] ENGRAM_ADMIN_TOKEN is required");
29
35
  process.exit(1);
30
36
  }
31
- const storage = await (async () => {
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
- })();
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) => (key === API_KEY ? { workspaceId: WORKSPACE_ID, storage } : null),
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: {