@hexis-ai/engram-server 0.11.3 → 0.13.0

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.
Files changed (42) hide show
  1. package/dist/adapters/memory-key-store.d.ts +4 -0
  2. package/dist/adapters/memory-key-store.js +12 -0
  3. package/dist/adapters/memory.js +47 -66
  4. package/dist/adapters/pg-tagged.d.ts +18 -0
  5. package/dist/adapters/pg-tagged.js +29 -0
  6. package/dist/adapters/postgres-key-store.d.ts +4 -0
  7. package/dist/adapters/postgres-key-store.js +14 -3
  8. package/dist/adapters/postgres-org-store.d.ts +42 -0
  9. package/dist/adapters/postgres-org-store.js +120 -0
  10. package/dist/adapters/postgres.js +57 -80
  11. package/dist/adapters/util.d.ts +27 -0
  12. package/dist/adapters/util.js +47 -0
  13. package/dist/admin.d.ts +26 -4
  14. package/dist/admin.js +126 -7
  15. package/dist/auth-resolver.d.ts +32 -0
  16. package/dist/auth-resolver.js +53 -0
  17. package/dist/auth.d.ts +196 -0
  18. package/dist/auth.js +164 -0
  19. package/dist/index.d.ts +4 -0
  20. package/dist/index.js +4 -0
  21. package/dist/key-store.d.ts +5 -0
  22. package/dist/main.js +84 -26
  23. package/dist/migrations/0006-auth.d.ts +2 -0
  24. package/dist/migrations/0006-auth.js +84 -0
  25. package/dist/migrations/0007-orgs.d.ts +2 -0
  26. package/dist/migrations/0007-orgs.js +59 -0
  27. package/dist/migrations/index.js +4 -0
  28. package/dist/openapi.js +340 -3
  29. package/dist/org-store.d.ts +73 -0
  30. package/dist/org-store.js +12 -0
  31. package/dist/routes/orgs.d.ts +27 -0
  32. package/dist/routes/orgs.js +185 -0
  33. package/dist/schemas.d.ts +18 -0
  34. package/dist/schemas.js +19 -0
  35. package/dist/server.d.ts +39 -0
  36. package/dist/server.js +85 -7
  37. package/dist/services/orgs.d.ts +95 -0
  38. package/dist/services/orgs.js +159 -0
  39. package/dist/storage.d.ts +6 -0
  40. package/dist/storage.js +14 -0
  41. package/openapi.json +1279 -1
  42. package/package.json +5 -11
@@ -13,6 +13,10 @@ export declare class InMemoryKeyStore implements KeyStore {
13
13
  }): Promise<Workspace>;
14
14
  getWorkspace(id: string): Promise<Workspace | null>;
15
15
  listWorkspaces(): Promise<Workspace[]>;
16
+ updateWorkspace(id: string, patch: {
17
+ name?: string;
18
+ metadata?: Record<string, unknown>;
19
+ }): Promise<Workspace>;
16
20
  deleteWorkspace(id: string): Promise<void>;
17
21
  issueKey(workspaceId: string, opts?: {
18
22
  name?: string;
@@ -26,6 +26,18 @@ export class InMemoryKeyStore {
26
26
  async listWorkspaces() {
27
27
  return [...this.workspaces.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
28
28
  }
29
+ async updateWorkspace(id, patch) {
30
+ const existing = this.workspaces.get(id);
31
+ if (!existing)
32
+ throw new Error("workspace_not_found");
33
+ const next = {
34
+ ...existing,
35
+ ...(patch.name !== undefined ? { name: patch.name } : {}),
36
+ ...(patch.metadata !== undefined ? { metadata: patch.metadata } : {}),
37
+ };
38
+ this.workspaces.set(id, next);
39
+ return next;
40
+ }
29
41
  async deleteWorkspace(id) {
30
42
  this.workspaces.delete(id);
31
43
  for (const [keyId, row] of this.keys) {
@@ -1,11 +1,5 @@
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
- }
1
+ import { foldEvents, newPersonId } from "../storage";
2
+ import { applyPartial } from "./util";
9
3
  /**
10
4
  * In-process storage adapter for tests, dev, and small single-node deploys.
11
5
  * Idempotency: events keyed by (sessionId, seq) — duplicates overwrite by seq.
@@ -19,7 +13,7 @@ export class InMemoryAdapter {
19
13
  identities = new Map();
20
14
  newPersonId;
21
15
  constructor(opts = {}) {
22
- this.newPersonId = opts.newPersonId ?? defaultPersonId;
16
+ this.newPersonId = opts.newPersonId ?? newPersonId;
23
17
  }
24
18
  // --- Sessions -----------------------------------------------------
25
19
  async createSession(init) {
@@ -88,49 +82,12 @@ export class InMemoryAdapter {
88
82
  const s = this.sessions.get(sessionId);
89
83
  if (!s)
90
84
  return null;
91
- // Patch semantics: undefined = leave alone; null = clear; value = set.
92
- // SessionRow optional fields use `string | undefined`, so a "null"
93
- // request collapses to `undefined` storage-side.
94
- const next = { ...s.row };
95
- if (patch.title !== undefined) {
96
- if (patch.title === null)
97
- delete next.title;
98
- else
99
- next.title = patch.title;
100
- }
101
- if (patch.channel !== undefined) {
102
- if (patch.channel === null)
103
- delete next.channel;
104
- else
105
- next.channel = patch.channel;
106
- }
107
- if (patch.status !== undefined) {
108
- next.status = patch.status;
109
- }
110
- if (patch.summary !== undefined) {
111
- if (patch.summary === null)
112
- delete next.summary;
113
- else
114
- next.summary = patch.summary;
115
- }
116
- if (patch.model !== undefined) {
117
- if (patch.model === null)
118
- delete next.model;
119
- else
120
- next.model = patch.model;
121
- }
122
- if (patch.trigger_conversation_id !== undefined) {
123
- if (patch.trigger_conversation_id === null)
124
- delete next.trigger_conversation_id;
125
- else
126
- next.trigger_conversation_id = patch.trigger_conversation_id;
127
- }
128
- if (patch.trigger_event_id !== undefined) {
129
- if (patch.trigger_event_id === null)
130
- delete next.trigger_event_id;
131
- else
132
- next.trigger_event_id = patch.trigger_event_id;
133
- }
85
+ // Patch semantics: undefined = leave alone; null = clear (deletes the
86
+ // key, matching the SessionRow shape where optional fields are absent);
87
+ // value = set. `status` clears to "active" to match the column default.
88
+ const next = applyPartial(s.row, patch, {
89
+ status: "active",
90
+ });
134
91
  next.updatedAt = new Date().toISOString();
135
92
  s.row = next;
136
93
  return foldEvents(s.row, [...s.events.values()], new Date());
@@ -213,16 +170,15 @@ export class InMemoryAdapter {
213
170
  const existing = this.persons.get(id);
214
171
  if (!existing)
215
172
  return null;
216
- const now = new Date().toISOString();
217
- // undefined = no-op, null = clear (per the SDK contract).
218
- const next = {
219
- ...existing,
220
- display_name: patch.display_name !== undefined ? patch.display_name : existing.display_name,
221
- role: patch.role !== undefined ? patch.role : existing.role ?? null,
222
- team: patch.team !== undefined ? patch.team : existing.team ?? null,
223
- source: patch.source !== undefined ? patch.source : existing.source ?? "auto",
224
- updated_at: now,
225
- };
173
+ // undefined = no-op; null = clear (display_name/role/team to null,
174
+ // source to "auto" to match the column default).
175
+ const next = applyPartial(existing, patch, {
176
+ display_name: null,
177
+ role: null,
178
+ team: null,
179
+ source: "auto",
180
+ });
181
+ next.updated_at = new Date().toISOString();
226
182
  this.persons.set(id, next);
227
183
  return next;
228
184
  }
@@ -269,8 +225,18 @@ export class InMemoryAdapter {
269
225
  }
270
226
  // --- Aliases ------------------------------------------------------
271
227
  async upsertAlias(personId, input) {
272
- if (!this.persons.has(personId))
273
- return null;
228
+ if (!this.persons.has(personId)) {
229
+ const stubNow = new Date().toISOString();
230
+ this.persons.set(personId, {
231
+ id: personId,
232
+ display_name: null,
233
+ role: null,
234
+ team: null,
235
+ source: undefined,
236
+ created_at: stubNow,
237
+ updated_at: stubNow,
238
+ });
239
+ }
274
240
  const key = aliasKey(personId, input.name);
275
241
  const now = new Date().toISOString();
276
242
  const existing = this.aliases.get(key);
@@ -304,8 +270,23 @@ export class InMemoryAdapter {
304
270
  }
305
271
  // --- Identities ---------------------------------------------------
306
272
  async upsertIdentity(ref, input) {
307
- if (!this.persons.has(input.person_id))
308
- return null;
273
+ // Auto-create a stub person when missing. The two telemetry
274
+ // calls (person + identity) race over the network; treating
275
+ // the identity PUT as authoritative for "this person id exists"
276
+ // matches the host-supplied-id upsert model and avoids a 404
277
+ // when the calls arrive out of order.
278
+ if (!this.persons.has(input.person_id)) {
279
+ const stubNow = new Date().toISOString();
280
+ this.persons.set(input.person_id, {
281
+ id: input.person_id,
282
+ display_name: null,
283
+ role: null,
284
+ team: null,
285
+ source: undefined,
286
+ created_at: stubNow,
287
+ updated_at: stubNow,
288
+ });
289
+ }
309
290
  const now = new Date().toISOString();
310
291
  const existing = this.identities.get(ref);
311
292
  // COALESCE-on-conflict for the soft-overlap fields; ref-level
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Tagged-template SQL client backed by node-postgres (`pg`).
3
+ *
4
+ * The rest of the codebase was written against the `postgres` package's
5
+ * tagged-template surface (`SqlClient` in ./postgres.ts). This adapter
6
+ * exposes the same shape on top of a `pg.Pool` (or `pg.PoolClient`) so
7
+ * we can unify on a single SQL driver — better-auth requires `pg`, and
8
+ * keeping two pools around just to satisfy `postgres-js`'s template API
9
+ * was preventing cross-store transactions.
10
+ *
11
+ * Translation rule: each `${value}` becomes `$N` in the emitted SQL,
12
+ * and the values land in the parameter array in tag order. Arrays
13
+ * map directly to Postgres arrays via `pg`'s native handling (callers
14
+ * already include explicit `::text[]` casts where needed).
15
+ */
16
+ import type { Pool, PoolClient } from "pg";
17
+ import type { SqlClient } from "./postgres";
18
+ export declare function pgSqlClient(executor: Pool | PoolClient): SqlClient;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Tagged-template SQL client backed by node-postgres (`pg`).
3
+ *
4
+ * The rest of the codebase was written against the `postgres` package's
5
+ * tagged-template surface (`SqlClient` in ./postgres.ts). This adapter
6
+ * exposes the same shape on top of a `pg.Pool` (or `pg.PoolClient`) so
7
+ * we can unify on a single SQL driver — better-auth requires `pg`, and
8
+ * keeping two pools around just to satisfy `postgres-js`'s template API
9
+ * was preventing cross-store transactions.
10
+ *
11
+ * Translation rule: each `${value}` becomes `$N` in the emitted SQL,
12
+ * and the values land in the parameter array in tag order. Arrays
13
+ * map directly to Postgres arrays via `pg`'s native handling (callers
14
+ * already include explicit `::text[]` casts where needed).
15
+ */
16
+ export function pgSqlClient(executor) {
17
+ const tagged = (async (strings, ...values) => {
18
+ let text = strings[0];
19
+ for (let i = 0; i < values.length; i++) {
20
+ text += `$${i + 1}` + strings[i + 1];
21
+ }
22
+ const r = await executor.query({ text, values });
23
+ return r.rows;
24
+ });
25
+ tagged.unsafe = async (text) => {
26
+ await executor.query(text);
27
+ };
28
+ return tagged;
29
+ }
@@ -15,6 +15,10 @@ export declare class PostgresKeyStore implements KeyStore {
15
15
  }): Promise<Workspace>;
16
16
  getWorkspace(id: string): Promise<Workspace | null>;
17
17
  listWorkspaces(): Promise<Workspace[]>;
18
+ updateWorkspace(id: string, patch: {
19
+ name?: string;
20
+ metadata?: Record<string, unknown>;
21
+ }): Promise<Workspace>;
18
22
  deleteWorkspace(id: string): Promise<void>;
19
23
  issueKey(workspaceId: string, opts?: {
20
24
  name?: string;
@@ -1,5 +1,6 @@
1
1
  import { deriveKey, hashKey, mintKey, resolveWorkspaceId, } from "../key-store";
2
2
  import { runMigrations } from "../migrator";
3
+ import { isoString } from "./util";
3
4
  export class PostgresKeyStore {
4
5
  sql;
5
6
  constructor(sql) {
@@ -36,6 +37,19 @@ export class PostgresKeyStore {
36
37
  `;
37
38
  return rows.map(toWorkspace);
38
39
  }
40
+ async updateWorkspace(id, patch) {
41
+ const rows = await this.sql `
42
+ UPDATE engram_workspaces
43
+ SET
44
+ name = COALESCE(${patch.name ?? null}, name),
45
+ metadata = COALESCE(${patch.metadata ? patch.metadata : null}, metadata)
46
+ WHERE id = ${id}
47
+ RETURNING id, name, metadata, created_at
48
+ `;
49
+ if (rows.length === 0)
50
+ throw new Error("workspace_not_found");
51
+ return toWorkspace(rows[0]);
52
+ }
39
53
  async deleteWorkspace(id) {
40
54
  // engram_api_keys cascades via FK. engram_sessions/events are explicit
41
55
  // since they predate the workspaces table and have no FK to it.
@@ -132,6 +146,3 @@ function toInfo(r) {
132
146
  ...(r.revoked_at !== null ? { revokedAt: isoString(r.revoked_at) } : {}),
133
147
  };
134
148
  }
135
- function isoString(v) {
136
- return typeof v === "string" ? v : v.toISOString();
137
- }
@@ -0,0 +1,42 @@
1
+ import type { Pool } from "pg";
2
+ import type { OrgMembershipRow, OrgRow, OrgStore } from "../org-store";
3
+ export declare class PostgresOrgStore implements OrgStore {
4
+ private readonly pool;
5
+ constructor(pool: Pool);
6
+ createOrg(input: {
7
+ id?: string;
8
+ name?: string;
9
+ metadata?: Record<string, unknown>;
10
+ }): Promise<OrgRow>;
11
+ getOrg(id: string): Promise<OrgRow | null>;
12
+ listOrgs(): Promise<OrgRow[]>;
13
+ updateOrg(id: string, patch: {
14
+ name?: string;
15
+ metadata?: Record<string, unknown>;
16
+ }): Promise<OrgRow>;
17
+ deleteOrg(id: string): Promise<void>;
18
+ listMembers(orgId: string): Promise<OrgMembershipRow[]>;
19
+ upsertMember(input: {
20
+ orgId: string;
21
+ userId: string;
22
+ role?: string;
23
+ }): Promise<OrgMembershipRow>;
24
+ removeMember(orgId: string, userId: string): Promise<void>;
25
+ findUserByEmail(email: string): Promise<{
26
+ id: string;
27
+ email: string;
28
+ } | null>;
29
+ listOrgsForUser(userId: string): Promise<OrgMembershipRow[]>;
30
+ setWorkspaceOrg(workspaceId: string, orgId: string): Promise<void>;
31
+ listWorkspacesForOrg(orgId: string): Promise<{
32
+ id: string;
33
+ name: string | null;
34
+ }[]>;
35
+ listWorkspacesForUser(userId: string): Promise<{
36
+ id: string;
37
+ name: string | null;
38
+ orgId: string;
39
+ }[]>;
40
+ userCanAccessWorkspace(userId: string, workspaceId: string): Promise<boolean>;
41
+ workspaceInOrg(orgId: string, workspaceId: string): Promise<boolean>;
42
+ }
@@ -0,0 +1,120 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { isoString } from "./util";
3
+ function toOrg(r) {
4
+ return {
5
+ id: r.id,
6
+ name: r.name,
7
+ metadata: r.metadata ?? {},
8
+ createdAt: isoString(r.created_at),
9
+ };
10
+ }
11
+ function toMember(r) {
12
+ return {
13
+ orgId: r.org_id,
14
+ userId: r.user_id,
15
+ role: r.role,
16
+ joinedAt: isoString(r.joined_at),
17
+ };
18
+ }
19
+ export class PostgresOrgStore {
20
+ pool;
21
+ constructor(pool) {
22
+ this.pool = pool;
23
+ }
24
+ // ---------- orgs ---------------------------------------------
25
+ async createOrg(input) {
26
+ const id = input.id ?? `org_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
27
+ const { rows } = await this.pool.query(`INSERT INTO engram_orgs (id, name, metadata)
28
+ VALUES ($1, $2, $3::jsonb)
29
+ ON CONFLICT (id) DO NOTHING
30
+ RETURNING id, name, metadata, created_at`, [id, input.name ?? null, JSON.stringify(input.metadata ?? {})]);
31
+ if (rows[0])
32
+ return toOrg(rows[0]);
33
+ const existing = await this.getOrg(id);
34
+ if (!existing)
35
+ throw new Error("org_create_failed");
36
+ return existing;
37
+ }
38
+ async getOrg(id) {
39
+ const { rows } = await this.pool.query(`SELECT id, name, metadata, created_at FROM engram_orgs WHERE id = $1`, [id]);
40
+ return rows[0] ? toOrg(rows[0]) : null;
41
+ }
42
+ async listOrgs() {
43
+ const { rows } = await this.pool.query(`SELECT id, name, metadata, created_at FROM engram_orgs ORDER BY created_at`);
44
+ return rows.map(toOrg);
45
+ }
46
+ async updateOrg(id, patch) {
47
+ const { rows } = await this.pool.query(`UPDATE engram_orgs
48
+ SET
49
+ name = COALESCE($2, name),
50
+ metadata = COALESCE($3::jsonb, metadata)
51
+ WHERE id = $1
52
+ RETURNING id, name, metadata, created_at`, [
53
+ id,
54
+ patch.name ?? null,
55
+ patch.metadata ? JSON.stringify(patch.metadata) : null,
56
+ ]);
57
+ if (rows.length === 0)
58
+ throw new Error("org_not_found");
59
+ return toOrg(rows[0]);
60
+ }
61
+ async deleteOrg(id) {
62
+ // CASCADE drops members + workspaces under this org.
63
+ await this.pool.query(`DELETE FROM engram_orgs WHERE id = $1`, [id]);
64
+ }
65
+ // ---------- members ------------------------------------------
66
+ async listMembers(orgId) {
67
+ const { rows } = await this.pool.query(`SELECT org_id, user_id, role, joined_at
68
+ FROM engram_org_members WHERE org_id = $1 ORDER BY joined_at`, [orgId]);
69
+ return rows.map(toMember);
70
+ }
71
+ async upsertMember(input) {
72
+ const role = input.role ?? "member";
73
+ const { rows } = await this.pool.query(`INSERT INTO engram_org_members (org_id, user_id, role)
74
+ VALUES ($1, $2, $3)
75
+ ON CONFLICT (org_id, user_id) DO UPDATE SET role = EXCLUDED.role
76
+ RETURNING org_id, user_id, role, joined_at`, [input.orgId, input.userId, role]);
77
+ return toMember(rows[0]);
78
+ }
79
+ async removeMember(orgId, userId) {
80
+ await this.pool.query(`DELETE FROM engram_org_members WHERE org_id = $1 AND user_id = $2`, [orgId, userId]);
81
+ }
82
+ async findUserByEmail(email) {
83
+ const { rows } = await this.pool.query(`SELECT id, email FROM engram_auth_users WHERE LOWER(email) = LOWER($1) LIMIT 1`, [email]);
84
+ return rows[0] ?? null;
85
+ }
86
+ async listOrgsForUser(userId) {
87
+ const { rows } = await this.pool.query(`SELECT org_id, user_id, role, joined_at
88
+ FROM engram_org_members WHERE user_id = $1 ORDER BY joined_at`, [userId]);
89
+ return rows.map(toMember);
90
+ }
91
+ // ---------- workspace ↔ org link -----------------------------
92
+ async setWorkspaceOrg(workspaceId, orgId) {
93
+ await this.pool.query(`UPDATE engram_workspaces SET org_id = $1 WHERE id = $2`, [orgId, workspaceId]);
94
+ }
95
+ async listWorkspacesForOrg(orgId) {
96
+ const { rows } = await this.pool.query(`SELECT id, name FROM engram_workspaces WHERE org_id = $1 ORDER BY created_at`, [orgId]);
97
+ return rows;
98
+ }
99
+ async listWorkspacesForUser(userId) {
100
+ const { rows } = await this.pool.query(`SELECT w.id, w.name, w.org_id
101
+ FROM engram_workspaces w
102
+ JOIN engram_org_members m ON m.org_id = w.org_id
103
+ WHERE m.user_id = $1
104
+ ORDER BY w.created_at`, [userId]);
105
+ return rows.map((r) => ({ id: r.id, name: r.name, orgId: r.org_id }));
106
+ }
107
+ async userCanAccessWorkspace(userId, workspaceId) {
108
+ const { rows } = await this.pool.query(`SELECT 1 AS ok
109
+ FROM engram_workspaces w
110
+ JOIN engram_org_members m ON m.org_id = w.org_id
111
+ WHERE m.user_id = $1 AND w.id = $2
112
+ LIMIT 1`, [userId, workspaceId]);
113
+ return rows.length > 0;
114
+ }
115
+ async workspaceInOrg(orgId, workspaceId) {
116
+ const { rows } = await this.pool.query(`SELECT 1 AS ok FROM engram_workspaces
117
+ WHERE id = $1 AND org_id = $2 LIMIT 1`, [workspaceId, orgId]);
118
+ return rows.length > 0;
119
+ }
120
+ }