@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.
@@ -0,0 +1,24 @@
1
+ import { type ApiKeyInfo, type IssuedKey, type KeyResolution, type KeyStore, type Workspace } from "../key-store";
2
+ /**
3
+ * In-process KeyStore for tests and single-node dev. Volatile.
4
+ */
5
+ export declare class InMemoryKeyStore implements KeyStore {
6
+ private readonly workspaces;
7
+ private readonly keys;
8
+ private readonly byHash;
9
+ createWorkspace(input: {
10
+ id?: string;
11
+ name?: string;
12
+ metadata?: Record<string, unknown>;
13
+ }): Promise<Workspace>;
14
+ getWorkspace(id: string): Promise<Workspace | null>;
15
+ listWorkspaces(): Promise<Workspace[]>;
16
+ deleteWorkspace(id: string): Promise<void>;
17
+ issueKey(workspaceId: string, opts?: {
18
+ name?: string;
19
+ }): Promise<IssuedKey>;
20
+ listKeys(workspaceId: string): Promise<ApiKeyInfo[]>;
21
+ revokeKey(workspaceId: string, keyId: string): Promise<void>;
22
+ resolveKey(rawKey: string): Promise<KeyResolution | null>;
23
+ registerLegacyKey(workspaceId: string, rawKey: string, name?: string): Promise<void>;
24
+ }
@@ -0,0 +1,108 @@
1
+ import { generateRawKey, hashKey, isValidWorkspaceId, keyPrefix, } from "../key-store";
2
+ /**
3
+ * In-process KeyStore for tests and single-node dev. Volatile.
4
+ */
5
+ export class InMemoryKeyStore {
6
+ workspaces = new Map();
7
+ keys = new Map();
8
+ byHash = new Map();
9
+ async createWorkspace(input) {
10
+ const id = input.id ?? crypto.randomUUID();
11
+ if (!isValidWorkspaceId(id))
12
+ throw new Error("invalid_workspace_id");
13
+ const existing = this.workspaces.get(id);
14
+ if (existing)
15
+ return existing;
16
+ const ws = {
17
+ id,
18
+ ...(input.name !== undefined ? { name: input.name } : {}),
19
+ ...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
20
+ createdAt: new Date().toISOString(),
21
+ };
22
+ this.workspaces.set(id, ws);
23
+ return ws;
24
+ }
25
+ async getWorkspace(id) {
26
+ return this.workspaces.get(id) ?? null;
27
+ }
28
+ async listWorkspaces() {
29
+ return [...this.workspaces.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
30
+ }
31
+ async deleteWorkspace(id) {
32
+ this.workspaces.delete(id);
33
+ for (const [keyId, row] of this.keys) {
34
+ if (row.workspaceId === id) {
35
+ this.byHash.delete(row.keyHash);
36
+ this.keys.delete(keyId);
37
+ }
38
+ }
39
+ }
40
+ async issueKey(workspaceId, opts = {}) {
41
+ if (!this.workspaces.has(workspaceId))
42
+ throw new Error("workspace_not_found");
43
+ const raw = generateRawKey();
44
+ const row = {
45
+ id: crypto.randomUUID(),
46
+ workspaceId,
47
+ keyHash: hashKey(raw),
48
+ prefix: keyPrefix(raw),
49
+ ...(opts.name !== undefined ? { name: opts.name } : {}),
50
+ createdAt: new Date().toISOString(),
51
+ };
52
+ this.keys.set(row.id, row);
53
+ this.byHash.set(row.keyHash, row.id);
54
+ return { ...toInfo(row), raw };
55
+ }
56
+ async listKeys(workspaceId) {
57
+ return [...this.keys.values()]
58
+ .filter((r) => r.workspaceId === workspaceId)
59
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
60
+ .map(toInfo);
61
+ }
62
+ async revokeKey(workspaceId, keyId) {
63
+ const row = this.keys.get(keyId);
64
+ if (!row || row.workspaceId !== workspaceId)
65
+ throw new Error("key_not_found");
66
+ if (row.revokedAt)
67
+ return;
68
+ this.keys.set(keyId, { ...row, revokedAt: new Date().toISOString() });
69
+ }
70
+ async resolveKey(rawKey) {
71
+ const id = this.byHash.get(hashKey(rawKey));
72
+ if (!id)
73
+ return null;
74
+ const row = this.keys.get(id);
75
+ if (!row || row.revokedAt)
76
+ return null;
77
+ this.keys.set(id, { ...row, lastUsedAt: new Date().toISOString() });
78
+ return { workspaceId: row.workspaceId, keyId: row.id };
79
+ }
80
+ async registerLegacyKey(workspaceId, rawKey, name) {
81
+ if (!this.workspaces.has(workspaceId))
82
+ throw new Error("workspace_not_found");
83
+ const hash = hashKey(rawKey);
84
+ if (this.byHash.has(hash))
85
+ return;
86
+ const row = {
87
+ id: crypto.randomUUID(),
88
+ workspaceId,
89
+ keyHash: hash,
90
+ prefix: keyPrefix(rawKey),
91
+ ...(name !== undefined ? { name } : {}),
92
+ createdAt: new Date().toISOString(),
93
+ };
94
+ this.keys.set(row.id, row);
95
+ this.byHash.set(hash, row.id);
96
+ }
97
+ }
98
+ function toInfo(row) {
99
+ return {
100
+ id: row.id,
101
+ workspaceId: row.workspaceId,
102
+ prefix: row.prefix,
103
+ ...(row.name !== undefined ? { name: row.name } : {}),
104
+ createdAt: row.createdAt,
105
+ ...(row.lastUsedAt !== undefined ? { lastUsedAt: row.lastUsedAt } : {}),
106
+ ...(row.revokedAt !== undefined ? { revokedAt: row.revokedAt } : {}),
107
+ };
108
+ }
@@ -1,12 +1,19 @@
1
1
  import type { Session } from "@hexis-ai/engram-core";
2
- import type { SessionEvent, SessionInit } from "@hexis-ai/engram-sdk";
2
+ import type { PersonCreate, PersonInfo, PersonUpdate, SessionEvent, SessionInit } from "@hexis-ai/engram-sdk";
3
3
  import { type StorageAdapter } from "../storage";
4
+ export interface InMemoryAdapterOptions {
5
+ /** Override for tests. Default: `p_${random}` with 8 chars. */
6
+ newPersonId?: () => string;
7
+ }
4
8
  /**
5
9
  * In-process storage adapter for tests, dev, and small single-node deploys.
6
10
  * Idempotency: events keyed by (sessionId, seq) — duplicates overwrite by seq.
7
11
  */
8
12
  export declare class InMemoryAdapter implements StorageAdapter {
9
13
  private readonly sessions;
14
+ private readonly persons;
15
+ private readonly newPersonId;
16
+ constructor(opts?: InMemoryAdapterOptions);
10
17
  createSession(init: SessionInit & {
11
18
  id: string;
12
19
  createdAt: string;
@@ -17,4 +24,18 @@ export declare class InMemoryAdapter implements StorageAdapter {
17
24
  limit: number;
18
25
  channel?: string;
19
26
  }): Promise<Session[]>;
27
+ sessionsForPerson(personId: string, opts: {
28
+ limit: number;
29
+ channel?: string;
30
+ scope?: "participant" | "viewable";
31
+ }): Promise<Session[]>;
32
+ createPerson(input: PersonCreate): Promise<PersonInfo>;
33
+ upsertPerson(id: string, input: PersonCreate): Promise<PersonInfo>;
34
+ updatePerson(id: string, patch: PersonUpdate): Promise<PersonInfo | null>;
35
+ getPerson(id: string): Promise<PersonInfo | null>;
36
+ getPersons(ids: string[]): Promise<PersonInfo[]>;
37
+ listPersons(opts: {
38
+ limit: number;
39
+ q?: string;
40
+ }): Promise<PersonInfo[]>;
20
41
  }
@@ -1,19 +1,37 @@
1
1
  import { foldEvents } from "../storage";
2
+ function defaultPersonId() {
3
+ const ALPHA = "abcdefghijklmnopqrstuvwxyz0123456789";
4
+ let out = "p_";
5
+ for (let i = 0; i < 8; i++)
6
+ out += ALPHA[Math.floor(Math.random() * ALPHA.length)];
7
+ return out;
8
+ }
2
9
  /**
3
10
  * In-process storage adapter for tests, dev, and small single-node deploys.
4
11
  * Idempotency: events keyed by (sessionId, seq) — duplicates overwrite by seq.
5
12
  */
6
13
  export class InMemoryAdapter {
7
14
  sessions = new Map();
15
+ persons = new Map();
16
+ newPersonId;
17
+ constructor(opts = {}) {
18
+ this.newPersonId = opts.newPersonId ?? defaultPersonId;
19
+ }
20
+ // --- Sessions -----------------------------------------------------
8
21
  async createSession(init) {
9
22
  if (this.sessions.has(init.id))
10
23
  return;
24
+ const participants = init.participants ?? [];
25
+ const viewable_by = init.viewable_by
26
+ ? Array.from(new Set([...init.viewable_by, ...participants]))
27
+ : [...participants];
11
28
  this.sessions.set(init.id, {
12
29
  row: {
13
30
  id: init.id,
14
31
  ...(init.title ? { title: init.title } : {}),
15
32
  ...(init.channel ? { channel: init.channel } : {}),
16
- participants: init.participants ?? [],
33
+ participants,
34
+ viewable_by,
17
35
  createdAt: init.createdAt,
18
36
  },
19
37
  events: new Map(),
@@ -23,8 +41,20 @@ export class InMemoryAdapter {
23
41
  const s = this.sessions.get(sessionId);
24
42
  if (!s)
25
43
  throw new Error(`session not found: ${sessionId}`);
26
- for (const ev of events)
44
+ for (const ev of events) {
27
45
  s.events.set(ev.seq, ev);
46
+ // Mirror participant events into the row so listSessions sees them
47
+ // without re-folding events at read time.
48
+ if (ev.type === "participant") {
49
+ const next = new Set([...s.row.participants, ev.personId]);
50
+ const view = new Set([...s.row.viewable_by, ev.personId]);
51
+ s.row = {
52
+ ...s.row,
53
+ participants: [...next],
54
+ viewable_by: [...view],
55
+ };
56
+ }
57
+ }
28
58
  }
29
59
  async getSession(sessionId) {
30
60
  const s = this.sessions.get(sessionId);
@@ -46,4 +76,76 @@ export class InMemoryAdapter {
46
76
  all.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
47
77
  return all.slice(0, opts.limit).map((x) => x.s);
48
78
  }
79
+ async sessionsForPerson(personId, opts) {
80
+ const scope = opts.scope ?? "participant";
81
+ const now = new Date();
82
+ const all = [];
83
+ for (const stored of this.sessions.values()) {
84
+ if (opts.channel && stored.row.channel !== opts.channel)
85
+ continue;
86
+ const list = scope === "viewable" ? stored.row.viewable_by : stored.row.participants;
87
+ if (!list.includes(personId))
88
+ continue;
89
+ all.push({
90
+ s: foldEvents(stored.row, [...stored.events.values()], now),
91
+ createdAt: stored.row.createdAt,
92
+ });
93
+ }
94
+ all.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
95
+ return all.slice(0, opts.limit).map((x) => x.s);
96
+ }
97
+ // --- Persons ------------------------------------------------------
98
+ async createPerson(input) {
99
+ const id = this.newPersonId();
100
+ return this.upsertPerson(id, input);
101
+ }
102
+ async upsertPerson(id, input) {
103
+ const now = new Date().toISOString();
104
+ const existing = this.persons.get(id);
105
+ const next = {
106
+ id,
107
+ display_name: input.display_name !== undefined
108
+ ? input.display_name
109
+ : existing?.display_name ?? null,
110
+ created_at: existing?.created_at ?? now,
111
+ updated_at: now,
112
+ };
113
+ this.persons.set(id, next);
114
+ return next;
115
+ }
116
+ async updatePerson(id, patch) {
117
+ const existing = this.persons.get(id);
118
+ if (!existing)
119
+ return null;
120
+ const now = new Date().toISOString();
121
+ const next = {
122
+ ...existing,
123
+ display_name: patch.display_name !== undefined ? patch.display_name : existing.display_name,
124
+ updated_at: now,
125
+ };
126
+ this.persons.set(id, next);
127
+ return next;
128
+ }
129
+ async getPerson(id) {
130
+ return this.persons.get(id) ?? null;
131
+ }
132
+ async getPersons(ids) {
133
+ const out = [];
134
+ for (const id of ids) {
135
+ const p = this.persons.get(id);
136
+ if (p)
137
+ out.push(p);
138
+ }
139
+ return out;
140
+ }
141
+ async listPersons(opts) {
142
+ const q = opts.q?.trim().toLowerCase() ?? "";
143
+ const all = [...this.persons.values()];
144
+ const matched = q
145
+ ? all.filter((p) => p.id.toLowerCase().includes(q) ||
146
+ (p.display_name?.toLowerCase().includes(q) ?? false))
147
+ : all;
148
+ matched.sort((a, b) => b.updated_at.localeCompare(a.updated_at));
149
+ return matched.slice(0, opts.limit);
150
+ }
49
151
  }
@@ -0,0 +1,26 @@
1
+ import { type ApiKeyInfo, type IssuedKey, type KeyResolution, type KeyStore, type Workspace } from "../key-store";
2
+ import type { SqlClient } from "./postgres";
3
+ export declare class PostgresKeyStore implements KeyStore {
4
+ private readonly sql;
5
+ constructor(sql: SqlClient);
6
+ /**
7
+ * Apply all pending schema migrations. Safe to call repeatedly and
8
+ * concurrently with other instances — see `runMigrations` for details.
9
+ */
10
+ ensureSchema(): Promise<void>;
11
+ createWorkspace(input: {
12
+ id?: string;
13
+ name?: string;
14
+ metadata?: Record<string, unknown>;
15
+ }): Promise<Workspace>;
16
+ getWorkspace(id: string): Promise<Workspace | null>;
17
+ listWorkspaces(): Promise<Workspace[]>;
18
+ deleteWorkspace(id: string): Promise<void>;
19
+ issueKey(workspaceId: string, opts?: {
20
+ name?: string;
21
+ }): Promise<IssuedKey>;
22
+ listKeys(workspaceId: string): Promise<ApiKeyInfo[]>;
23
+ revokeKey(workspaceId: string, keyId: string): Promise<void>;
24
+ resolveKey(rawKey: string): Promise<KeyResolution | null>;
25
+ registerLegacyKey(workspaceId: string, rawKey: string, name?: string): Promise<void>;
26
+ }
@@ -0,0 +1,143 @@
1
+ import { generateRawKey, hashKey, isValidWorkspaceId, keyPrefix, } from "../key-store";
2
+ import { runMigrations } from "../migrator";
3
+ export class PostgresKeyStore {
4
+ sql;
5
+ constructor(sql) {
6
+ this.sql = sql;
7
+ }
8
+ /**
9
+ * Apply all pending schema migrations. Safe to call repeatedly and
10
+ * concurrently with other instances — see `runMigrations` for details.
11
+ */
12
+ async ensureSchema() {
13
+ await runMigrations(this.sql);
14
+ }
15
+ async createWorkspace(input) {
16
+ const id = input.id ?? crypto.randomUUID();
17
+ if (!isValidWorkspaceId(id))
18
+ throw new Error("invalid_workspace_id");
19
+ await this.sql `
20
+ INSERT INTO engram_workspaces (id, name, metadata)
21
+ VALUES (${id}, ${input.name ?? null}, ${(input.metadata ?? {})})
22
+ ON CONFLICT (id) DO NOTHING
23
+ `;
24
+ const ws = await this.getWorkspace(id);
25
+ if (!ws)
26
+ throw new Error("workspace_create_failed");
27
+ return ws;
28
+ }
29
+ async getWorkspace(id) {
30
+ const rows = await this.sql `
31
+ SELECT id, name, metadata, created_at FROM engram_workspaces WHERE id = ${id} LIMIT 1
32
+ `;
33
+ return rows.length === 0 ? null : toWorkspace(rows[0]);
34
+ }
35
+ async listWorkspaces() {
36
+ const rows = await this.sql `
37
+ SELECT id, name, metadata, created_at FROM engram_workspaces ORDER BY created_at DESC
38
+ `;
39
+ return rows.map(toWorkspace);
40
+ }
41
+ async deleteWorkspace(id) {
42
+ // engram_api_keys cascades via FK. engram_sessions/events are explicit
43
+ // since they predate the workspaces table and have no FK to it.
44
+ await this.sql `DELETE FROM engram_events WHERE workspace_id = ${id}`;
45
+ await this.sql `DELETE FROM engram_sessions WHERE workspace_id = ${id}`;
46
+ await this.sql `DELETE FROM engram_workspaces WHERE id = ${id}`;
47
+ }
48
+ async issueKey(workspaceId, opts = {}) {
49
+ const ws = await this.getWorkspace(workspaceId);
50
+ if (!ws)
51
+ throw new Error("workspace_not_found");
52
+ const raw = generateRawKey();
53
+ const id = crypto.randomUUID();
54
+ const hash = hashKey(raw);
55
+ const prefix = keyPrefix(raw);
56
+ const rows = await this.sql `
57
+ INSERT INTO engram_api_keys (id, workspace_id, key_hash, prefix, name)
58
+ VALUES (${id}, ${workspaceId}, ${hash}, ${prefix}, ${opts.name ?? null})
59
+ RETURNING id, workspace_id, prefix, name, created_at, last_used_at, revoked_at
60
+ `;
61
+ return { ...toInfo(rows[0]), raw };
62
+ }
63
+ async listKeys(workspaceId) {
64
+ const rows = await this.sql `
65
+ SELECT id, workspace_id, prefix, name, created_at, last_used_at, revoked_at
66
+ FROM engram_api_keys
67
+ WHERE workspace_id = ${workspaceId}
68
+ ORDER BY created_at DESC
69
+ `;
70
+ return rows.map(toInfo);
71
+ }
72
+ async revokeKey(workspaceId, keyId) {
73
+ const rows = await this.sql `
74
+ UPDATE engram_api_keys
75
+ SET revoked_at = NOW()
76
+ WHERE id = ${keyId}
77
+ AND workspace_id = ${workspaceId}
78
+ AND revoked_at IS NULL
79
+ RETURNING id
80
+ `;
81
+ if (rows.length === 0) {
82
+ // Distinguish "not found" from "already revoked": if the row exists at
83
+ // all under this workspace, treat as no-op success.
84
+ const check = await this.sql `
85
+ SELECT id FROM engram_api_keys
86
+ WHERE id = ${keyId} AND workspace_id = ${workspaceId} LIMIT 1
87
+ `;
88
+ if (check.length === 0)
89
+ throw new Error("key_not_found");
90
+ }
91
+ }
92
+ async resolveKey(rawKey) {
93
+ const hash = hashKey(rawKey);
94
+ const rows = await this.sql `
95
+ SELECT id, workspace_id FROM engram_api_keys
96
+ WHERE key_hash = ${hash} AND revoked_at IS NULL
97
+ LIMIT 1
98
+ `;
99
+ if (rows.length === 0)
100
+ return null;
101
+ const row = rows[0];
102
+ // Fire-and-forget last_used update. Errors here would only mask the
103
+ // success of the actual auth — swallow them.
104
+ void this.sql `
105
+ UPDATE engram_api_keys SET last_used_at = NOW() WHERE id = ${row.id}
106
+ `.catch(() => undefined);
107
+ return { workspaceId: row.workspace_id, keyId: row.id };
108
+ }
109
+ async registerLegacyKey(workspaceId, rawKey, name) {
110
+ const ws = await this.getWorkspace(workspaceId);
111
+ if (!ws)
112
+ throw new Error("workspace_not_found");
113
+ const hash = hashKey(rawKey);
114
+ const id = crypto.randomUUID();
115
+ await this.sql `
116
+ INSERT INTO engram_api_keys (id, workspace_id, key_hash, prefix, name)
117
+ VALUES (${id}, ${workspaceId}, ${hash}, ${keyPrefix(rawKey)}, ${name ?? "legacy"})
118
+ ON CONFLICT (key_hash) DO NOTHING
119
+ `;
120
+ }
121
+ }
122
+ function toWorkspace(r) {
123
+ return {
124
+ id: r.id,
125
+ ...(r.name !== null ? { name: r.name } : {}),
126
+ ...(r.metadata !== null && Object.keys(r.metadata).length > 0 ? { metadata: r.metadata } : {}),
127
+ createdAt: isoString(r.created_at),
128
+ };
129
+ }
130
+ function toInfo(r) {
131
+ return {
132
+ id: r.id,
133
+ workspaceId: r.workspace_id,
134
+ prefix: r.prefix,
135
+ ...(r.name !== null ? { name: r.name } : {}),
136
+ createdAt: isoString(r.created_at),
137
+ ...(r.last_used_at !== null ? { lastUsedAt: isoString(r.last_used_at) } : {}),
138
+ ...(r.revoked_at !== null ? { revokedAt: isoString(r.revoked_at) } : {}),
139
+ };
140
+ }
141
+ function isoString(v) {
142
+ return typeof v === "string" ? v : v.toISOString();
143
+ }
@@ -1,5 +1,5 @@
1
1
  import type { Session } from "@hexis-ai/engram-core";
2
- import type { SessionEvent, SessionInit } from "@hexis-ai/engram-sdk";
2
+ import type { PersonCreate, PersonInfo, PersonUpdate, SessionEvent, SessionInit } from "@hexis-ai/engram-sdk";
3
3
  import { type StorageAdapter } from "../storage";
4
4
  /**
5
5
  * Minimal subset of `postgres` driver's tagged-template surface that this
@@ -15,14 +15,20 @@ export interface PostgresAdapterOptions {
15
15
  /** Workspace scope baked into every row. Required for multi-tenant isolation. */
16
16
  workspaceId: string;
17
17
  sql: SqlClient;
18
+ /**
19
+ * Generates ids for newly created persons. Default: `p_${randomShort()}`.
20
+ * Override in tests for determinism.
21
+ */
22
+ newPersonId?: () => string;
18
23
  }
19
24
  export declare class PostgresAdapter implements StorageAdapter {
20
25
  private readonly workspaceId;
21
26
  private readonly sql;
27
+ private readonly newPersonId;
22
28
  constructor(opts: PostgresAdapterOptions);
23
29
  /**
24
- * Create the schema. Call once at boot. Safe to invoke repeatedly.
25
- * Uses `sql.unsafe()` to ship the multi-statement DDL as a single batch.
30
+ * Apply all pending schema migrations. Safe to call repeatedly and
31
+ * concurrently with other instances see `runMigrations` for details.
26
32
  */
27
33
  ensureSchema(): Promise<void>;
28
34
  createSession(init: SessionInit & {
@@ -35,4 +41,18 @@ export declare class PostgresAdapter implements StorageAdapter {
35
41
  limit: number;
36
42
  channel?: string;
37
43
  }): Promise<Session[]>;
44
+ sessionsForPerson(personId: string, opts: {
45
+ limit: number;
46
+ channel?: string;
47
+ scope?: "participant" | "viewable";
48
+ }): Promise<Session[]>;
49
+ createPerson(input: PersonCreate): Promise<PersonInfo>;
50
+ upsertPerson(id: string, input: PersonCreate): Promise<PersonInfo>;
51
+ updatePerson(id: string, patch: PersonUpdate): Promise<PersonInfo | null>;
52
+ getPerson(id: string): Promise<PersonInfo | null>;
53
+ getPersons(ids: string[]): Promise<PersonInfo[]>;
54
+ listPersons(opts: {
55
+ limit: number;
56
+ q?: string;
57
+ }): Promise<PersonInfo[]>;
38
58
  }