@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
|
@@ -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
|
}
|
package/dist/adapters/memory.js
CHANGED
|
@@ -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
|
|
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,23 @@
|
|
|
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
|
+
/** Create the key-store schema. Call once at boot. */
|
|
7
|
+
ensureSchema(): Promise<void>;
|
|
8
|
+
createWorkspace(input: {
|
|
9
|
+
id?: string;
|
|
10
|
+
name?: string;
|
|
11
|
+
metadata?: Record<string, unknown>;
|
|
12
|
+
}): Promise<Workspace>;
|
|
13
|
+
getWorkspace(id: string): Promise<Workspace | null>;
|
|
14
|
+
listWorkspaces(): Promise<Workspace[]>;
|
|
15
|
+
deleteWorkspace(id: string): Promise<void>;
|
|
16
|
+
issueKey(workspaceId: string, opts?: {
|
|
17
|
+
name?: string;
|
|
18
|
+
}): Promise<IssuedKey>;
|
|
19
|
+
listKeys(workspaceId: string): Promise<ApiKeyInfo[]>;
|
|
20
|
+
revokeKey(workspaceId: string, keyId: string): Promise<void>;
|
|
21
|
+
resolveKey(rawKey: string): Promise<KeyResolution | null>;
|
|
22
|
+
registerLegacyKey(workspaceId: string, rawKey: string, name?: string): Promise<void>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { generateRawKey, hashKey, isValidWorkspaceId, keyPrefix, } from "../key-store";
|
|
2
|
+
const KEY_STORE_SCHEMA_SQL = `
|
|
3
|
+
CREATE TABLE IF NOT EXISTS engram_workspaces (
|
|
4
|
+
id TEXT PRIMARY KEY,
|
|
5
|
+
name TEXT,
|
|
6
|
+
metadata JSONB NOT NULL DEFAULT '{}',
|
|
7
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
CREATE TABLE IF NOT EXISTS engram_api_keys (
|
|
11
|
+
id TEXT PRIMARY KEY,
|
|
12
|
+
workspace_id TEXT NOT NULL REFERENCES engram_workspaces(id) ON DELETE CASCADE,
|
|
13
|
+
key_hash TEXT NOT NULL UNIQUE,
|
|
14
|
+
prefix TEXT NOT NULL,
|
|
15
|
+
name TEXT,
|
|
16
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
17
|
+
last_used_at TIMESTAMPTZ,
|
|
18
|
+
revoked_at TIMESTAMPTZ
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
CREATE INDEX IF NOT EXISTS idx_engram_api_keys_workspace
|
|
22
|
+
ON engram_api_keys (workspace_id);
|
|
23
|
+
`;
|
|
24
|
+
export class PostgresKeyStore {
|
|
25
|
+
sql;
|
|
26
|
+
constructor(sql) {
|
|
27
|
+
this.sql = sql;
|
|
28
|
+
}
|
|
29
|
+
/** Create the key-store schema. Call once at boot. */
|
|
30
|
+
async ensureSchema() {
|
|
31
|
+
await this.sql.unsafe(KEY_STORE_SCHEMA_SQL);
|
|
32
|
+
}
|
|
33
|
+
async createWorkspace(input) {
|
|
34
|
+
const id = input.id ?? crypto.randomUUID();
|
|
35
|
+
if (!isValidWorkspaceId(id))
|
|
36
|
+
throw new Error("invalid_workspace_id");
|
|
37
|
+
await this.sql `
|
|
38
|
+
INSERT INTO engram_workspaces (id, name, metadata)
|
|
39
|
+
VALUES (${id}, ${input.name ?? null}, ${(input.metadata ?? {})})
|
|
40
|
+
ON CONFLICT (id) DO NOTHING
|
|
41
|
+
`;
|
|
42
|
+
const ws = await this.getWorkspace(id);
|
|
43
|
+
if (!ws)
|
|
44
|
+
throw new Error("workspace_create_failed");
|
|
45
|
+
return ws;
|
|
46
|
+
}
|
|
47
|
+
async getWorkspace(id) {
|
|
48
|
+
const rows = await this.sql `
|
|
49
|
+
SELECT id, name, metadata, created_at FROM engram_workspaces WHERE id = ${id} LIMIT 1
|
|
50
|
+
`;
|
|
51
|
+
return rows.length === 0 ? null : toWorkspace(rows[0]);
|
|
52
|
+
}
|
|
53
|
+
async listWorkspaces() {
|
|
54
|
+
const rows = await this.sql `
|
|
55
|
+
SELECT id, name, metadata, created_at FROM engram_workspaces ORDER BY created_at DESC
|
|
56
|
+
`;
|
|
57
|
+
return rows.map(toWorkspace);
|
|
58
|
+
}
|
|
59
|
+
async deleteWorkspace(id) {
|
|
60
|
+
// engram_api_keys cascades via FK. engram_sessions/events are explicit
|
|
61
|
+
// since they predate the workspaces table and have no FK to it.
|
|
62
|
+
await this.sql `DELETE FROM engram_events WHERE workspace_id = ${id}`;
|
|
63
|
+
await this.sql `DELETE FROM engram_sessions WHERE workspace_id = ${id}`;
|
|
64
|
+
await this.sql `DELETE FROM engram_workspaces WHERE id = ${id}`;
|
|
65
|
+
}
|
|
66
|
+
async issueKey(workspaceId, opts = {}) {
|
|
67
|
+
const ws = await this.getWorkspace(workspaceId);
|
|
68
|
+
if (!ws)
|
|
69
|
+
throw new Error("workspace_not_found");
|
|
70
|
+
const raw = generateRawKey();
|
|
71
|
+
const id = crypto.randomUUID();
|
|
72
|
+
const hash = hashKey(raw);
|
|
73
|
+
const prefix = keyPrefix(raw);
|
|
74
|
+
const rows = await this.sql `
|
|
75
|
+
INSERT INTO engram_api_keys (id, workspace_id, key_hash, prefix, name)
|
|
76
|
+
VALUES (${id}, ${workspaceId}, ${hash}, ${prefix}, ${opts.name ?? null})
|
|
77
|
+
RETURNING id, workspace_id, prefix, name, created_at, last_used_at, revoked_at
|
|
78
|
+
`;
|
|
79
|
+
return { ...toInfo(rows[0]), raw };
|
|
80
|
+
}
|
|
81
|
+
async listKeys(workspaceId) {
|
|
82
|
+
const rows = await this.sql `
|
|
83
|
+
SELECT id, workspace_id, prefix, name, created_at, last_used_at, revoked_at
|
|
84
|
+
FROM engram_api_keys
|
|
85
|
+
WHERE workspace_id = ${workspaceId}
|
|
86
|
+
ORDER BY created_at DESC
|
|
87
|
+
`;
|
|
88
|
+
return rows.map(toInfo);
|
|
89
|
+
}
|
|
90
|
+
async revokeKey(workspaceId, keyId) {
|
|
91
|
+
const rows = await this.sql `
|
|
92
|
+
UPDATE engram_api_keys
|
|
93
|
+
SET revoked_at = NOW()
|
|
94
|
+
WHERE id = ${keyId}
|
|
95
|
+
AND workspace_id = ${workspaceId}
|
|
96
|
+
AND revoked_at IS NULL
|
|
97
|
+
RETURNING id
|
|
98
|
+
`;
|
|
99
|
+
if (rows.length === 0) {
|
|
100
|
+
// Distinguish "not found" from "already revoked": if the row exists at
|
|
101
|
+
// all under this workspace, treat as no-op success.
|
|
102
|
+
const check = await this.sql `
|
|
103
|
+
SELECT id FROM engram_api_keys
|
|
104
|
+
WHERE id = ${keyId} AND workspace_id = ${workspaceId} LIMIT 1
|
|
105
|
+
`;
|
|
106
|
+
if (check.length === 0)
|
|
107
|
+
throw new Error("key_not_found");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async resolveKey(rawKey) {
|
|
111
|
+
const hash = hashKey(rawKey);
|
|
112
|
+
const rows = await this.sql `
|
|
113
|
+
SELECT id, workspace_id FROM engram_api_keys
|
|
114
|
+
WHERE key_hash = ${hash} AND revoked_at IS NULL
|
|
115
|
+
LIMIT 1
|
|
116
|
+
`;
|
|
117
|
+
if (rows.length === 0)
|
|
118
|
+
return null;
|
|
119
|
+
const row = rows[0];
|
|
120
|
+
// Fire-and-forget last_used update. Errors here would only mask the
|
|
121
|
+
// success of the actual auth — swallow them.
|
|
122
|
+
void this.sql `
|
|
123
|
+
UPDATE engram_api_keys SET last_used_at = NOW() WHERE id = ${row.id}
|
|
124
|
+
`.catch(() => undefined);
|
|
125
|
+
return { workspaceId: row.workspace_id, keyId: row.id };
|
|
126
|
+
}
|
|
127
|
+
async registerLegacyKey(workspaceId, rawKey, name) {
|
|
128
|
+
const ws = await this.getWorkspace(workspaceId);
|
|
129
|
+
if (!ws)
|
|
130
|
+
throw new Error("workspace_not_found");
|
|
131
|
+
const hash = hashKey(rawKey);
|
|
132
|
+
const id = crypto.randomUUID();
|
|
133
|
+
await this.sql `
|
|
134
|
+
INSERT INTO engram_api_keys (id, workspace_id, key_hash, prefix, name)
|
|
135
|
+
VALUES (${id}, ${workspaceId}, ${hash}, ${keyPrefix(rawKey)}, ${name ?? "legacy"})
|
|
136
|
+
ON CONFLICT (key_hash) DO NOTHING
|
|
137
|
+
`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function toWorkspace(r) {
|
|
141
|
+
return {
|
|
142
|
+
id: r.id,
|
|
143
|
+
...(r.name !== null ? { name: r.name } : {}),
|
|
144
|
+
...(r.metadata !== null && Object.keys(r.metadata).length > 0 ? { metadata: r.metadata } : {}),
|
|
145
|
+
createdAt: isoString(r.created_at),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function toInfo(r) {
|
|
149
|
+
return {
|
|
150
|
+
id: r.id,
|
|
151
|
+
workspaceId: r.workspace_id,
|
|
152
|
+
prefix: r.prefix,
|
|
153
|
+
...(r.name !== null ? { name: r.name } : {}),
|
|
154
|
+
createdAt: isoString(r.created_at),
|
|
155
|
+
...(r.last_used_at !== null ? { lastUsedAt: isoString(r.last_used_at) } : {}),
|
|
156
|
+
...(r.revoked_at !== null ? { revokedAt: isoString(r.revoked_at) } : {}),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function isoString(v) {
|
|
160
|
+
return typeof v === "string" ? v : v.toISOString();
|
|
161
|
+
}
|
|
@@ -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,10 +15,16 @@ 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
30
|
* Create the schema. Call once at boot. Safe to invoke repeatedly.
|
|
@@ -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
|
}
|