@hexis-ai/engram-server 0.12.0 → 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.
package/dist/admin.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Hono } from "hono";
2
2
  import { isValidWorkspaceId } from "./key-store";
3
3
  import { createWorkspaceSchema, issueKeySchema, parseJsonBody } from "./schemas";
4
+ import { addMember, createOrg, createWorkspaceUnderOrg, deleteOrg, getOrgOrThrow, OrgServiceError, removeMember, requireWorkspaceInOrg, revokeWorkspaceKey, } from "./services/orgs";
4
5
  /**
5
6
  * Build the admin sub-router. Mount under \`/admin/v1\`.
6
7
  *
@@ -119,117 +120,105 @@ export function createAdminRouter(opts) {
119
120
  const orgStore = opts.orgStore;
120
121
  if (!orgStore)
121
122
  return app;
122
- app.post("/orgs", async (c) => {
123
+ const deps = { orgStore, keyStore: opts.keyStore };
124
+ app.post("/orgs", async (c) => runService(c, async () => {
123
125
  const body = (await c.req.json().catch(() => ({})));
124
- try {
125
- const org = await orgStore.createOrg({
126
- ...(body.id !== undefined ? { id: body.id } : {}),
127
- ...(body.name !== undefined ? { name: body.name } : {}),
128
- ...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
129
- });
130
- return c.json({ org });
131
- }
132
- catch (e) {
133
- return c.json({ error: e.message }, 400);
134
- }
135
- });
126
+ const org = await createOrg(deps, body);
127
+ return c.json({ org });
128
+ }));
136
129
  app.get("/orgs", async (c) => {
137
130
  const orgs = await orgStore.listOrgs();
138
131
  return c.json({ orgs });
139
132
  });
140
- app.get("/orgs/:id", async (c) => {
141
- const org = await orgStore.getOrg(c.req.param("id"));
142
- if (!org)
143
- return c.json({ error: "org_not_found" }, 404);
133
+ app.get("/orgs/:id", async (c) => runService(c, async () => {
134
+ const org = await getOrgOrThrow(deps, c.req.param("id"));
144
135
  return c.json({ org });
145
- });
146
- app.delete("/orgs/:id", async (c) => {
147
- const id = c.req.param("id");
148
- const org = await orgStore.getOrg(id);
149
- if (!org)
150
- return c.json({ error: "org_not_found" }, 404);
151
- await orgStore.deleteOrg(id);
136
+ }));
137
+ app.delete("/orgs/:id", async (c) => runService(c, async () => {
138
+ await deleteOrg(deps, c.req.param("id"));
152
139
  return c.body(null, 204);
153
- });
140
+ }));
154
141
  // ----- org members ------------------------------------------
155
- app.get("/orgs/:id/members", async (c) => {
142
+ app.get("/orgs/:id/members", async (c) => runService(c, async () => {
156
143
  const id = c.req.param("id");
157
- const org = await orgStore.getOrg(id);
158
- if (!org)
159
- return c.json({ error: "org_not_found" }, 404);
144
+ await getOrgOrThrow(deps, id);
160
145
  return c.json({ members: await orgStore.listMembers(id) });
161
- });
162
- app.post("/orgs/:id/members", async (c) => {
146
+ }));
147
+ app.post("/orgs/:id/members", async (c) => runService(c, async () => {
163
148
  const orgId = c.req.param("id");
164
- const org = await orgStore.getOrg(orgId);
165
- if (!org)
166
- return c.json({ error: "org_not_found" }, 404);
149
+ await getOrgOrThrow(deps, orgId);
167
150
  const body = (await c.req.json().catch(() => ({})));
168
- let userId = body.userId;
169
- if (!userId && body.email) {
170
- const u = await orgStore.findUserByEmail(body.email);
171
- if (!u)
172
- return c.json({ error: "user_not_found" }, 404);
173
- userId = u.id;
174
- }
175
- if (!userId)
176
- return c.json({ error: "userId_or_email_required" }, 400);
177
- const member = await orgStore.upsertMember({
178
- orgId,
179
- userId,
180
- ...(body.role !== undefined ? { role: body.role } : {}),
181
- });
151
+ const member = await addMember(deps, orgId, body);
182
152
  return c.json({ member });
183
- });
184
- app.delete("/orgs/:id/members/:userId", async (c) => {
153
+ }));
154
+ app.delete("/orgs/:id/members/:userId", async (c) => runService(c, async () => {
185
155
  const orgId = c.req.param("id");
186
- const org = await orgStore.getOrg(orgId);
187
- if (!org)
188
- return c.json({ error: "org_not_found" }, 404);
189
- await orgStore.removeMember(orgId, c.req.param("userId"));
156
+ await getOrgOrThrow(deps, orgId);
157
+ await removeMember(deps, orgId, c.req.param("userId"));
190
158
  return c.body(null, 204);
191
- });
159
+ }));
192
160
  // ----- org workspaces ---------------------------------------
193
- // Combines createWorkspace + setWorkspaceOrg + issueKey so a
194
- // tenant can be stood up in one round-trip. Mirrors the legacy
195
- // /workspaces POST shape but adds org scoping.
196
- app.post("/orgs/:id/workspaces", async (c) => {
161
+ // Stands up a tenant in one round-trip. createWorkspaceUnderOrg
162
+ // wraps createWorkspace + setWorkspaceOrg + issueKey and applies
163
+ // the same id validation as the legacy /workspaces POST.
164
+ app.post("/orgs/:id/workspaces", async (c) => runService(c, async () => {
197
165
  const orgId = c.req.param("id");
198
- const org = await orgStore.getOrg(orgId);
199
- if (!org)
200
- return c.json({ error: "org_not_found" }, 404);
166
+ await getOrgOrThrow(deps, orgId);
201
167
  const body = await parseJsonBody(c, createWorkspaceSchema);
202
168
  if (body instanceof Response)
203
169
  return body;
204
- if (body.id !== undefined && !isValidWorkspaceId(body.id)) {
205
- return c.json({ error: "invalid_workspace_id" }, 400);
206
- }
207
- try {
208
- const ws = await opts.keyStore.createWorkspace({
209
- ...(body.id !== undefined ? { id: body.id } : {}),
210
- ...(body.name !== undefined ? { name: body.name } : {}),
211
- ...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
212
- });
213
- await orgStore.setWorkspaceOrg(ws.id, orgId);
214
- if (body.issueKey === false) {
215
- return c.json({ workspace: { ...ws, orgId } });
216
- }
217
- const key = await opts.keyStore.issueKey(ws.id, {
218
- ...(body.keyName !== undefined ? { name: body.keyName } : {}),
219
- });
220
- return c.json({ workspace: { ...ws, orgId }, key });
221
- }
222
- catch (e) {
223
- return c.json({ error: e.message }, 400);
224
- }
225
- });
226
- app.get("/orgs/:id/workspaces", async (c) => {
170
+ return c.json(await createWorkspaceUnderOrg(deps, orgId, body));
171
+ }));
172
+ app.get("/orgs/:id/workspaces", async (c) => runService(c, async () => {
227
173
  const orgId = c.req.param("id");
228
- const org = await orgStore.getOrg(orgId);
229
- if (!org)
230
- return c.json({ error: "org_not_found" }, 404);
174
+ await getOrgOrThrow(deps, orgId);
231
175
  const workspaces = await orgStore.listWorkspacesForOrg(orgId);
232
176
  return c.json({ workspaces });
233
- });
177
+ }));
178
+ // ----- per-workspace api keys (scoped to an org) -----------
179
+ // Org-scoped equivalent of the legacy /admin/v1/workspaces/:id/keys
180
+ // routes. monet uses these for key rotation after the initial
181
+ // createWorkspaceUnderOrg + key issuance.
182
+ app.get("/orgs/:id/workspaces/:wsId/keys", async (c) => runService(c, async () => {
183
+ const orgId = c.req.param("id");
184
+ const wsId = c.req.param("wsId");
185
+ await getOrgOrThrow(deps, orgId);
186
+ await requireWorkspaceInOrg(deps, orgId, wsId);
187
+ return c.json({ keys: await opts.keyStore.listKeys(wsId) });
188
+ }));
189
+ app.post("/orgs/:id/workspaces/:wsId/keys", async (c) => runService(c, async () => {
190
+ const orgId = c.req.param("id");
191
+ const wsId = c.req.param("wsId");
192
+ await getOrgOrThrow(deps, orgId);
193
+ await requireWorkspaceInOrg(deps, orgId, wsId);
194
+ const raw = await c.req.json().catch(() => ({}));
195
+ const parsed = issueKeySchema.safeParse(raw);
196
+ if (!parsed.success) {
197
+ return c.json({ error: "invalid_body", issues: parsed.error.issues }, 400);
198
+ }
199
+ const key = await opts.keyStore.issueKey(wsId, {
200
+ ...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}),
201
+ });
202
+ return c.json(key);
203
+ }));
204
+ app.delete("/orgs/:id/workspaces/:wsId/keys/:keyId", async (c) => runService(c, async () => {
205
+ const orgId = c.req.param("id");
206
+ const wsId = c.req.param("wsId");
207
+ await getOrgOrThrow(deps, orgId);
208
+ await requireWorkspaceInOrg(deps, orgId, wsId);
209
+ await revokeWorkspaceKey(deps, wsId, c.req.param("keyId"));
210
+ return c.body(null, 204);
211
+ }));
234
212
  return app;
235
213
  }
214
+ /** Run a service call, mapping OrgServiceError to a JSON response. */
215
+ async function runService(c, fn) {
216
+ try {
217
+ return await fn();
218
+ }
219
+ catch (e) {
220
+ if (e instanceof OrgServiceError)
221
+ return c.json({ error: e.code }, e.status);
222
+ throw e;
223
+ }
224
+ }
@@ -36,6 +36,11 @@ export interface KeyStore {
36
36
  }): Promise<Workspace>;
37
37
  getWorkspace(id: string): Promise<Workspace | null>;
38
38
  listWorkspaces(): Promise<Workspace[]>;
39
+ /** Patch a workspace's name (and/or metadata). Returns the updated row. */
40
+ updateWorkspace(id: string, patch: {
41
+ name?: string;
42
+ metadata?: Record<string, unknown>;
43
+ }): Promise<Workspace>;
39
44
  /** Hard delete: cascades to keys, sessions, and events for this workspace. */
40
45
  deleteWorkspace(id: string): Promise<void>;
41
46
  issueKey(workspaceId: string, opts?: {
package/dist/main.js CHANGED
@@ -1,32 +1,9 @@
1
- /**
2
- * Production entrypoint.
3
- *
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.
8
- *
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
- * Optional env (engram-web cookie auth — see ./auth.ts for the full set):
16
- * ENGRAM_AUTH_SECRET, ENGRAM_AUTH_URL,
17
- * ENGRAM_AUTH_GOOGLE_ID, ENGRAM_AUTH_GOOGLE_SECRET,
18
- * ENGRAM_AUTH_COOKIE_DOMAIN, ENGRAM_AUTH_TRUSTED_ORIGINS,
19
- * ENGRAM_AUTH_EMAIL_DOMAIN
20
- *
21
- * Workspaces and their API keys are provisioned exclusively through the
22
- * admin API. There is no single-tenant fallback — every caller must hold a
23
- * workspace-scoped key issued by `POST /admin/v1/workspaces`.
24
- */
25
1
  import { createServer } from "./server";
26
2
  import { InMemoryAdapter } from "./adapters/memory";
27
3
  import { PostgresAdapter } from "./adapters/postgres";
28
4
  import { InMemoryKeyStore } from "./adapters/memory-key-store";
29
5
  import { PostgresKeyStore } from "./adapters/postgres-key-store";
6
+ import { pgSqlClient } from "./adapters/pg-tagged";
30
7
  import { buildAuth } from "./auth";
31
8
  import { makeCookieAuthResolver } from "./auth-resolver";
32
9
  import { PostgresOrgStore } from "./adapters/postgres-org-store";
@@ -38,8 +15,9 @@ if (!ADMIN_TOKEN) {
38
15
  console.error("[engram-server] ENGRAM_ADMIN_TOKEN is required");
39
16
  process.exit(1);
40
17
  }
41
- const { keyStore, getStorage } = await buildStores();
42
- const { authHandler, cookieAuth, orgStore } = await buildCookieAuth(getStorage);
18
+ const pool = await buildPool();
19
+ const { keyStore, getStorage } = await buildStores(pool);
20
+ const { authHandler, cookieAuth, orgStore } = await buildCookieAuth(pool, getStorage);
43
21
  const CORS_ORIGINS = (process.env.ENGRAM_CORS_ORIGINS ?? "")
44
22
  .split(",")
45
23
  .map((s) => s.trim())
@@ -54,6 +32,7 @@ const app = createServer({
54
32
  ...(authHandler ? { authHandler } : {}),
55
33
  ...(cookieAuth ? { cookieAuth } : {}),
56
34
  ...(orgStore ? { orgStore } : {}),
35
+ keyStore,
57
36
  ...(CORS_ORIGINS.length > 0 ? { corsOrigins: CORS_ORIGINS } : {}),
58
37
  admin: {
59
38
  token: ADMIN_TOKEN,
@@ -63,8 +42,26 @@ const app = createServer({
63
42
  });
64
43
  console.log(`[engram-server] listening on :${PORT}`);
65
44
  export default { port: PORT, fetch: app.fetch };
66
- async function buildStores() {
67
- if (!DATABASE_URL) {
45
+ /**
46
+ * Single Postgres connection pool shared by both the engram tables
47
+ * (sessions/persons/keys/orgs via `pg-tagged`) and better-auth's
48
+ * Kysely adapter. Returns `null` in in-memory mode.
49
+ */
50
+ async function buildPool() {
51
+ if (!DATABASE_URL)
52
+ return null;
53
+ const { Pool } = await import("pg");
54
+ const url = new URL(DATABASE_URL);
55
+ return new Pool({
56
+ host: DATABASE_SOCKET_PATH ?? url.hostname,
57
+ port: DATABASE_SOCKET_PATH ? undefined : Number(url.port || 5432),
58
+ user: decodeURIComponent(url.username),
59
+ password: decodeURIComponent(url.password),
60
+ database: url.pathname.replace(/^\//, ""),
61
+ });
62
+ }
63
+ async function buildStores(pool) {
64
+ if (!pool) {
68
65
  console.warn("[engram-server] DATABASE_URL not set — in-memory mode (data is volatile)");
69
66
  const ks = new InMemoryKeyStore();
70
67
  const adapters = new Map();
@@ -80,10 +77,7 @@ async function buildStores() {
80
77
  },
81
78
  };
82
79
  }
83
- const { default: postgres } = await import("postgres");
84
- const sql = (DATABASE_SOCKET_PATH
85
- ? postgres(DATABASE_URL, { host: DATABASE_SOCKET_PATH })
86
- : postgres(DATABASE_URL));
80
+ const sql = pgSqlClient(pool);
87
81
  const ks = new PostgresKeyStore(sql);
88
82
  await ks.ensureSchema();
89
83
  // Session schema is workspace-independent — one bootstrap call is enough.
@@ -107,30 +101,21 @@ async function buildStores() {
107
101
  * Google OAuth configured) — the rest of the server still works,
108
102
  * but only api-key callers can reach /v1.
109
103
  */
110
- async function buildCookieAuth(getStorage) {
104
+ async function buildCookieAuth(pool, getStorage) {
111
105
  const secret = process.env.ENGRAM_AUTH_SECRET;
112
106
  const baseURL = process.env.ENGRAM_AUTH_URL;
113
107
  const googleId = process.env.ENGRAM_AUTH_GOOGLE_ID;
114
108
  const googleSecret = process.env.ENGRAM_AUTH_GOOGLE_SECRET;
115
109
  if (!secret || !baseURL || !googleId || !googleSecret) {
116
- if (DATABASE_URL) {
110
+ if (pool) {
117
111
  console.warn("[engram-server] cookie auth disabled (set ENGRAM_AUTH_SECRET/URL/GOOGLE_ID/GOOGLE_SECRET to enable engram-web sign-in)");
118
112
  }
119
113
  return {};
120
114
  }
121
- if (!DATABASE_URL) {
115
+ if (!pool) {
122
116
  console.warn("[engram-server] cookie auth requires DATABASE_URL — skipping");
123
117
  return {};
124
118
  }
125
- const { Pool } = await import("pg");
126
- const url = new URL(DATABASE_URL);
127
- const pool = new Pool({
128
- host: DATABASE_SOCKET_PATH ?? url.hostname,
129
- port: DATABASE_SOCKET_PATH ? undefined : Number(url.port || 5432),
130
- user: decodeURIComponent(url.username),
131
- password: decodeURIComponent(url.password),
132
- database: url.pathname.replace(/^\//, ""),
133
- });
134
119
  const trusted = (process.env.ENGRAM_AUTH_TRUSTED_ORIGINS ?? "")
135
120
  .split(",")
136
121
  .map((s) => s.trim())