@hexis-ai/engram-server 0.11.2 → 0.12.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/auth.js ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * better-auth instance for engram-server.
3
+ *
4
+ * Two auth modes coexist on /v1/*:
5
+ * - x-api-key / Bearer → machine callers (monet telemetry / MCP),
6
+ * resolved through KeyStore → workspace.
7
+ * - cookie session → human users from engram-web,
8
+ * resolved through this module → user →
9
+ * membership check → workspace.
10
+ *
11
+ * Tables live under \`engram_auth_*\` (see migration 0006-auth) so the
12
+ * shorter \`engram_session\` namespace remains agent-trajectory only.
13
+ *
14
+ * Required env (production):
15
+ * ENGRAM_AUTH_SECRET cookie/JWT signing secret (>= 32 chars)
16
+ * ENGRAM_AUTH_URL full base URL of this server
17
+ * (e.g. https://api.engram.hexis.ltd)
18
+ * ENGRAM_AUTH_GOOGLE_ID Google OAuth client id
19
+ * ENGRAM_AUTH_GOOGLE_SECRET Google OAuth client secret
20
+ *
21
+ * Optional:
22
+ * ENGRAM_AUTH_COOKIE_DOMAIN set to .hexis.ltd to share cookies
23
+ * between engram.hexis.ltd (web) and
24
+ * api.engram.hexis.ltd (this server)
25
+ * ENGRAM_AUTH_TRUSTED_ORIGINS comma-separated extra origins, e.g.
26
+ * engram-web's URL in dev
27
+ * ENGRAM_AUTH_EMAIL_DOMAIN restrict sign-in to one email domain
28
+ * (default: hexis.ltd)
29
+ */
30
+ import { randomUUID } from "node:crypto";
31
+ import { betterAuth } from "better-auth";
32
+ import { bearer } from "better-auth/plugins";
33
+ /**
34
+ * Construct the better-auth instance. The returned value exposes
35
+ * - \`handler(req: Request): Response\` for Hono \`.all("/auth/*", ...)\`
36
+ * - \`api.getSession({ headers })\` for cookie → session lookup
37
+ */
38
+ export function buildAuth(opts) {
39
+ const emailDomain = opts.emailDomain ?? "hexis.ltd";
40
+ const secure = opts.secureCookies ?? process.env.NODE_ENV === "production";
41
+ return betterAuth({
42
+ database: opts.pool,
43
+ secret: opts.secret,
44
+ baseURL: opts.baseURL,
45
+ basePath: "/auth",
46
+ trustedOrigins: opts.trustedOrigins ?? [],
47
+ // ---------- table mapping → engram_auth_* ---------------------
48
+ user: {
49
+ modelName: "engram_auth_users",
50
+ fields: {
51
+ emailVerified: "email_verified",
52
+ createdAt: "created_at",
53
+ updatedAt: "updated_at",
54
+ },
55
+ },
56
+ session: {
57
+ modelName: "engram_auth_sessions",
58
+ fields: {
59
+ userId: "user_id",
60
+ expiresAt: "expires_at",
61
+ ipAddress: "ip_address",
62
+ userAgent: "user_agent",
63
+ createdAt: "created_at",
64
+ updatedAt: "updated_at",
65
+ token: "token",
66
+ },
67
+ additionalFields: {
68
+ currentWorkspaceId: {
69
+ type: "string",
70
+ required: false,
71
+ input: false,
72
+ fieldName: "current_workspace_id",
73
+ },
74
+ },
75
+ cookieCache: { enabled: true, maxAge: 5 * 60 },
76
+ },
77
+ account: {
78
+ modelName: "engram_auth_accounts",
79
+ fields: {
80
+ userId: "user_id",
81
+ accountId: "account_id",
82
+ providerId: "provider_id",
83
+ accessToken: "access_token",
84
+ refreshToken: "refresh_token",
85
+ idToken: "id_token",
86
+ accessTokenExpiresAt: "access_token_expires_at",
87
+ refreshTokenExpiresAt: "refresh_token_expires_at",
88
+ scope: "scope",
89
+ password: "password",
90
+ createdAt: "created_at",
91
+ updatedAt: "updated_at",
92
+ },
93
+ // Auto-link the Google account to a same-email user. Engram
94
+ // doesn't pre-seed users — every user shows up via OAuth — so
95
+ // this is mostly defensive (covers DB restores / re-imports).
96
+ accountLinking: { enabled: true, trustedProviders: ["google"] },
97
+ },
98
+ verification: {
99
+ modelName: "engram_auth_verifications",
100
+ fields: {
101
+ expiresAt: "expires_at",
102
+ createdAt: "created_at",
103
+ updatedAt: "updated_at",
104
+ },
105
+ },
106
+ // ---------- ID + cookie scope ---------------------------------
107
+ advanced: {
108
+ database: {
109
+ generateId: () => `u_${randomUUID().replace(/-/g, "").substring(0, 16)}`,
110
+ },
111
+ // Distinct prefix so engram's cookie doesn't clobber monet's
112
+ // (both live under .hexis.ltd, both use better-auth defaults).
113
+ cookiePrefix: "engram",
114
+ cookies: opts.cookieDomain
115
+ ? {
116
+ sessionToken: {
117
+ attributes: {
118
+ domain: opts.cookieDomain,
119
+ sameSite: "lax",
120
+ secure,
121
+ httpOnly: true,
122
+ path: "/",
123
+ },
124
+ },
125
+ }
126
+ : undefined,
127
+ useSecureCookies: secure,
128
+ crossSubDomainCookies: opts.cookieDomain
129
+ ? { enabled: true, domain: opts.cookieDomain }
130
+ : undefined,
131
+ },
132
+ // ---------- providers -----------------------------------------
133
+ socialProviders: {
134
+ google: {
135
+ clientId: opts.google.clientId,
136
+ clientSecret: opts.google.clientSecret,
137
+ },
138
+ },
139
+ // ---------- email-domain allowlist ----------------------------
140
+ // Reject sign-ins whose verified email is outside the allowlist.
141
+ // Throwing here surfaces as a generic OAuth failure to the
142
+ // client; we deliberately don't expose the rejected domain.
143
+ databaseHooks: {
144
+ user: {
145
+ create: {
146
+ before: async (user) => {
147
+ const email = user.email ?? "";
148
+ const domain = email.split("@")[1]?.toLowerCase() ?? "";
149
+ if (domain !== emailDomain) {
150
+ throw new Error(`email_domain_not_allowed:${domain}`);
151
+ }
152
+ return { data: user };
153
+ },
154
+ },
155
+ },
156
+ },
157
+ // ---------- plugins -------------------------------------------
158
+ plugins: [
159
+ // Accept Authorization: Bearer <session-token> for clients
160
+ // that can't carry cookies (SPA → cross-domain XHR fallback).
161
+ bearer(),
162
+ ],
163
+ });
164
+ }
package/dist/index.d.ts CHANGED
@@ -8,3 +8,7 @@ export { InMemoryKeyStore } from "./adapters/memory-key-store";
8
8
  export { PostgresKeyStore } from "./adapters/postgres-key-store";
9
9
  export { createAdminRouter, type AdminOptions } from "./admin";
10
10
  export { buildOpenApiDocument } from "./openapi";
11
+ export { buildAuth, type BuildAuthOptions, type EngramAuth } from "./auth";
12
+ export { makeCookieAuthResolver, type CookieAuthResolver, type CookieResolverOptions, } from "./auth-resolver";
13
+ export { type OrgStore, type OrgRow, type OrgMembershipRow, } from "./org-store";
14
+ export { PostgresOrgStore } from "./adapters/postgres-org-store";
package/dist/index.js CHANGED
@@ -8,3 +8,7 @@ export { InMemoryKeyStore } from "./adapters/memory-key-store";
8
8
  export { PostgresKeyStore } from "./adapters/postgres-key-store";
9
9
  export { createAdminRouter } from "./admin";
10
10
  export { buildOpenApiDocument } from "./openapi";
11
+ export { buildAuth } from "./auth";
12
+ export { makeCookieAuthResolver, } from "./auth-resolver";
13
+ export {} from "./org-store";
14
+ export { PostgresOrgStore } from "./adapters/postgres-org-store";
package/dist/main.js CHANGED
@@ -12,6 +12,12 @@
12
12
  * InMemoryAdapter (NOT durable across restarts)
13
13
  * DATABASE_SOCKET_PATH Cloud SQL Auth Proxy unix socket dir
14
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
+ *
15
21
  * Workspaces and their API keys are provisioned exclusively through the
16
22
  * admin API. There is no single-tenant fallback — every caller must hold a
17
23
  * workspace-scoped key issued by `POST /admin/v1/workspaces`.
@@ -21,6 +27,9 @@ import { InMemoryAdapter } from "./adapters/memory";
21
27
  import { PostgresAdapter } from "./adapters/postgres";
22
28
  import { InMemoryKeyStore } from "./adapters/memory-key-store";
23
29
  import { PostgresKeyStore } from "./adapters/postgres-key-store";
30
+ import { buildAuth } from "./auth";
31
+ import { makeCookieAuthResolver } from "./auth-resolver";
32
+ import { PostgresOrgStore } from "./adapters/postgres-org-store";
24
33
  const PORT = Number(process.env.PORT ?? 8080);
25
34
  const ADMIN_TOKEN = process.env.ENGRAM_ADMIN_TOKEN;
26
35
  const DATABASE_URL = process.env.DATABASE_URL;
@@ -30,6 +39,11 @@ if (!ADMIN_TOKEN) {
30
39
  process.exit(1);
31
40
  }
32
41
  const { keyStore, getStorage } = await buildStores();
42
+ const { authHandler, cookieAuth, orgStore } = await buildCookieAuth(getStorage);
43
+ const CORS_ORIGINS = (process.env.ENGRAM_CORS_ORIGINS ?? "")
44
+ .split(",")
45
+ .map((s) => s.trim())
46
+ .filter(Boolean);
33
47
  const app = createServer({
34
48
  auth: async (key) => {
35
49
  const r = await keyStore.resolveKey(key);
@@ -37,7 +51,15 @@ const app = createServer({
37
51
  return null;
38
52
  return { workspaceId: r.workspaceId, storage: getStorage(r.workspaceId) };
39
53
  },
40
- admin: { token: ADMIN_TOKEN, keyStore },
54
+ ...(authHandler ? { authHandler } : {}),
55
+ ...(cookieAuth ? { cookieAuth } : {}),
56
+ ...(orgStore ? { orgStore } : {}),
57
+ ...(CORS_ORIGINS.length > 0 ? { corsOrigins: CORS_ORIGINS } : {}),
58
+ admin: {
59
+ token: ADMIN_TOKEN,
60
+ keyStore,
61
+ ...(orgStore ? { orgStore } : {}),
62
+ },
41
63
  });
42
64
  console.log(`[engram-server] listening on :${PORT}`);
43
65
  export default { port: PORT, fetch: app.fetch };
@@ -79,3 +101,54 @@ async function buildStores() {
79
101
  },
80
102
  };
81
103
  }
104
+ /**
105
+ * Wire better-auth + cookie resolver when the required env vars are
106
+ * set. Returns `{}` when auth is disabled (in-memory dev, or no
107
+ * Google OAuth configured) — the rest of the server still works,
108
+ * but only api-key callers can reach /v1.
109
+ */
110
+ async function buildCookieAuth(getStorage) {
111
+ const secret = process.env.ENGRAM_AUTH_SECRET;
112
+ const baseURL = process.env.ENGRAM_AUTH_URL;
113
+ const googleId = process.env.ENGRAM_AUTH_GOOGLE_ID;
114
+ const googleSecret = process.env.ENGRAM_AUTH_GOOGLE_SECRET;
115
+ if (!secret || !baseURL || !googleId || !googleSecret) {
116
+ if (DATABASE_URL) {
117
+ console.warn("[engram-server] cookie auth disabled (set ENGRAM_AUTH_SECRET/URL/GOOGLE_ID/GOOGLE_SECRET to enable engram-web sign-in)");
118
+ }
119
+ return {};
120
+ }
121
+ if (!DATABASE_URL) {
122
+ console.warn("[engram-server] cookie auth requires DATABASE_URL — skipping");
123
+ return {};
124
+ }
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
+ const trusted = (process.env.ENGRAM_AUTH_TRUSTED_ORIGINS ?? "")
135
+ .split(",")
136
+ .map((s) => s.trim())
137
+ .filter(Boolean);
138
+ const auth = buildAuth({
139
+ pool,
140
+ secret,
141
+ baseURL,
142
+ google: { clientId: googleId, clientSecret: googleSecret },
143
+ ...(process.env.ENGRAM_AUTH_COOKIE_DOMAIN
144
+ ? { cookieDomain: process.env.ENGRAM_AUTH_COOKIE_DOMAIN }
145
+ : {}),
146
+ ...(trusted.length > 0 ? { trustedOrigins: trusted } : {}),
147
+ ...(process.env.ENGRAM_AUTH_EMAIL_DOMAIN
148
+ ? { emailDomain: process.env.ENGRAM_AUTH_EMAIL_DOMAIN }
149
+ : {}),
150
+ });
151
+ const orgStore = new PostgresOrgStore(pool);
152
+ const cookieAuth = makeCookieAuthResolver({ auth, pool, orgStore, getStorage });
153
+ return { authHandler: auth, cookieAuth, orgStore };
154
+ }
@@ -0,0 +1,2 @@
1
+ export declare const name = "0006-auth";
2
+ export declare const sql = "\n-- ============================================================\n-- Phase E1: human-user auth for engram-web\n--\n-- engram-server has always authenticated machine callers with\n-- per-workspace api keys (engram_api_keys). engram-web now wants\n-- to authenticate _people_ with Google SSO, so we add the\n-- standard better-auth quartet plus a workspace_members join.\n--\n-- All tables are namespaced `engram_auth_*` to keep them out of\n-- the `engram_*` domain (sessions / events / persons / aliases /\n-- identities) where `session` already means an agent trajectory.\n-- ============================================================\n\nCREATE TABLE IF NOT EXISTS engram_auth_users (\n id TEXT PRIMARY KEY,\n email TEXT NOT NULL UNIQUE,\n email_verified BOOLEAN NOT NULL DEFAULT FALSE,\n name TEXT,\n image TEXT,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\nCREATE INDEX IF NOT EXISTS idx_engram_auth_users_email ON engram_auth_users (email);\n\nCREATE TABLE IF NOT EXISTS engram_auth_sessions (\n id TEXT PRIMARY KEY,\n user_id TEXT NOT NULL REFERENCES engram_auth_users(id) ON DELETE CASCADE,\n token TEXT NOT NULL UNIQUE,\n expires_at TIMESTAMPTZ NOT NULL,\n ip_address TEXT,\n user_agent TEXT,\n current_workspace_id TEXT,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\nCREATE INDEX IF NOT EXISTS idx_engram_auth_sessions_user ON engram_auth_sessions (user_id);\nCREATE INDEX IF NOT EXISTS idx_engram_auth_sessions_token ON engram_auth_sessions (token);\nCREATE INDEX IF NOT EXISTS idx_engram_auth_sessions_expires ON engram_auth_sessions (expires_at);\n\nCREATE TABLE IF NOT EXISTS engram_auth_accounts (\n id TEXT PRIMARY KEY,\n user_id TEXT NOT NULL REFERENCES engram_auth_users(id) ON DELETE CASCADE,\n account_id TEXT NOT NULL,\n provider_id TEXT NOT NULL,\n access_token TEXT,\n refresh_token TEXT,\n id_token TEXT,\n access_token_expires_at TIMESTAMPTZ,\n refresh_token_expires_at TIMESTAMPTZ,\n scope TEXT,\n password TEXT,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n UNIQUE (provider_id, account_id)\n);\nCREATE INDEX IF NOT EXISTS idx_engram_auth_accounts_user ON engram_auth_accounts (user_id);\n\nCREATE TABLE IF NOT EXISTS engram_auth_verifications (\n id TEXT PRIMARY KEY,\n identifier TEXT NOT NULL,\n value TEXT NOT NULL,\n expires_at TIMESTAMPTZ NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\nCREATE INDEX IF NOT EXISTS idx_engram_auth_verifications_identifier\n ON engram_auth_verifications (identifier);\n\n-- workspace \u2194 user membership. A user can belong to many workspaces;\n-- a workspace can have many members. Roles are kept open-ended\n-- (validated at the app layer) since engram doesn't enforce any\n-- per-route authorization today \u2014 membership is the only check.\nCREATE TABLE IF NOT EXISTS engram_workspace_members (\n workspace_id TEXT NOT NULL REFERENCES engram_workspaces(id) ON DELETE CASCADE,\n user_id TEXT NOT NULL REFERENCES engram_auth_users(id) ON DELETE CASCADE,\n role TEXT NOT NULL DEFAULT 'member',\n joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n PRIMARY KEY (workspace_id, user_id)\n);\nCREATE INDEX IF NOT EXISTS idx_engram_workspace_members_user\n ON engram_workspace_members (user_id);\n";
@@ -0,0 +1,84 @@
1
+ export const name = "0006-auth";
2
+ export const sql = `
3
+ -- ============================================================
4
+ -- Phase E1: human-user auth for engram-web
5
+ --
6
+ -- engram-server has always authenticated machine callers with
7
+ -- per-workspace api keys (engram_api_keys). engram-web now wants
8
+ -- to authenticate _people_ with Google SSO, so we add the
9
+ -- standard better-auth quartet plus a workspace_members join.
10
+ --
11
+ -- All tables are namespaced \`engram_auth_*\` to keep them out of
12
+ -- the \`engram_*\` domain (sessions / events / persons / aliases /
13
+ -- identities) where \`session\` already means an agent trajectory.
14
+ -- ============================================================
15
+
16
+ CREATE TABLE IF NOT EXISTS engram_auth_users (
17
+ id TEXT PRIMARY KEY,
18
+ email TEXT NOT NULL UNIQUE,
19
+ email_verified BOOLEAN NOT NULL DEFAULT FALSE,
20
+ name TEXT,
21
+ image TEXT,
22
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
23
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
24
+ );
25
+ CREATE INDEX IF NOT EXISTS idx_engram_auth_users_email ON engram_auth_users (email);
26
+
27
+ CREATE TABLE IF NOT EXISTS engram_auth_sessions (
28
+ id TEXT PRIMARY KEY,
29
+ user_id TEXT NOT NULL REFERENCES engram_auth_users(id) ON DELETE CASCADE,
30
+ token TEXT NOT NULL UNIQUE,
31
+ expires_at TIMESTAMPTZ NOT NULL,
32
+ ip_address TEXT,
33
+ user_agent TEXT,
34
+ current_workspace_id TEXT,
35
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
36
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
37
+ );
38
+ CREATE INDEX IF NOT EXISTS idx_engram_auth_sessions_user ON engram_auth_sessions (user_id);
39
+ CREATE INDEX IF NOT EXISTS idx_engram_auth_sessions_token ON engram_auth_sessions (token);
40
+ CREATE INDEX IF NOT EXISTS idx_engram_auth_sessions_expires ON engram_auth_sessions (expires_at);
41
+
42
+ CREATE TABLE IF NOT EXISTS engram_auth_accounts (
43
+ id TEXT PRIMARY KEY,
44
+ user_id TEXT NOT NULL REFERENCES engram_auth_users(id) ON DELETE CASCADE,
45
+ account_id TEXT NOT NULL,
46
+ provider_id TEXT NOT NULL,
47
+ access_token TEXT,
48
+ refresh_token TEXT,
49
+ id_token TEXT,
50
+ access_token_expires_at TIMESTAMPTZ,
51
+ refresh_token_expires_at TIMESTAMPTZ,
52
+ scope TEXT,
53
+ password TEXT,
54
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
55
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
56
+ UNIQUE (provider_id, account_id)
57
+ );
58
+ CREATE INDEX IF NOT EXISTS idx_engram_auth_accounts_user ON engram_auth_accounts (user_id);
59
+
60
+ CREATE TABLE IF NOT EXISTS engram_auth_verifications (
61
+ id TEXT PRIMARY KEY,
62
+ identifier TEXT NOT NULL,
63
+ value TEXT NOT NULL,
64
+ expires_at TIMESTAMPTZ NOT NULL,
65
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
66
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
67
+ );
68
+ CREATE INDEX IF NOT EXISTS idx_engram_auth_verifications_identifier
69
+ ON engram_auth_verifications (identifier);
70
+
71
+ -- workspace ↔ user membership. A user can belong to many workspaces;
72
+ -- a workspace can have many members. Roles are kept open-ended
73
+ -- (validated at the app layer) since engram doesn't enforce any
74
+ -- per-route authorization today — membership is the only check.
75
+ CREATE TABLE IF NOT EXISTS engram_workspace_members (
76
+ workspace_id TEXT NOT NULL REFERENCES engram_workspaces(id) ON DELETE CASCADE,
77
+ user_id TEXT NOT NULL REFERENCES engram_auth_users(id) ON DELETE CASCADE,
78
+ role TEXT NOT NULL DEFAULT 'member',
79
+ joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
80
+ PRIMARY KEY (workspace_id, user_id)
81
+ );
82
+ CREATE INDEX IF NOT EXISTS idx_engram_workspace_members_user
83
+ ON engram_workspace_members (user_id);
84
+ `;
@@ -0,0 +1,2 @@
1
+ export declare const name = "0007-orgs";
2
+ export declare const sql = "\n-- ============================================================\n-- Wave G: orgs as the top-level grouping (Langfuse-style)\n--\n-- Before: cookie-auth users were members of individual workspaces\n-- (engram_workspace_members). That doesn't scale when a single\n-- dev team operates many tenant workspaces \u2014 every new tenant\n-- meant re-adding every dev. Move membership up one level so a\n-- user joins an org once and sees every workspace under it.\n--\n-- New shape:\n-- engram_orgs top-level grouping\n-- engram_workspaces now has org_id (NULL allowed only for\n-- tests / legacy api-key-only rows;\n-- cookie-auth requires the workspace's\n-- org membership)\n-- engram_org_members replaces engram_workspace_members\n-- ============================================================\n\nCREATE TABLE IF NOT EXISTS engram_orgs (\n id TEXT PRIMARY KEY,\n name TEXT,\n metadata JSONB NOT NULL DEFAULT '{}'::jsonb,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n\nALTER TABLE engram_workspaces ADD COLUMN IF NOT EXISTS org_id TEXT;\n\nDO $$\nBEGIN\n IF NOT EXISTS (\n SELECT 1 FROM pg_constraint\n WHERE conname = 'engram_workspaces_org_id_fkey'\n ) THEN\n ALTER TABLE engram_workspaces\n ADD CONSTRAINT engram_workspaces_org_id_fkey\n FOREIGN KEY (org_id) REFERENCES engram_orgs(id) ON DELETE CASCADE;\n END IF;\nEND $$;\n\nCREATE INDEX IF NOT EXISTS idx_engram_workspaces_org\n ON engram_workspaces (org_id);\n\nCREATE TABLE IF NOT EXISTS engram_org_members (\n org_id TEXT NOT NULL REFERENCES engram_orgs(id) ON DELETE CASCADE,\n user_id TEXT NOT NULL REFERENCES engram_auth_users(id) ON DELETE CASCADE,\n role TEXT NOT NULL DEFAULT 'member',\n joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n PRIMARY KEY (org_id, user_id)\n);\nCREATE INDEX IF NOT EXISTS idx_engram_org_members_user\n ON engram_org_members (user_id);\n\n-- workspace_members from 0006-auth is obsolete in the org-based\n-- model. Drop it; the only path to workspace access is via org\n-- membership.\nDROP TABLE IF EXISTS engram_workspace_members;\n";
@@ -0,0 +1,59 @@
1
+ export const name = "0007-orgs";
2
+ export const sql = `
3
+ -- ============================================================
4
+ -- Wave G: orgs as the top-level grouping (Langfuse-style)
5
+ --
6
+ -- Before: cookie-auth users were members of individual workspaces
7
+ -- (engram_workspace_members). That doesn't scale when a single
8
+ -- dev team operates many tenant workspaces — every new tenant
9
+ -- meant re-adding every dev. Move membership up one level so a
10
+ -- user joins an org once and sees every workspace under it.
11
+ --
12
+ -- New shape:
13
+ -- engram_orgs top-level grouping
14
+ -- engram_workspaces now has org_id (NULL allowed only for
15
+ -- tests / legacy api-key-only rows;
16
+ -- cookie-auth requires the workspace's
17
+ -- org membership)
18
+ -- engram_org_members replaces engram_workspace_members
19
+ -- ============================================================
20
+
21
+ CREATE TABLE IF NOT EXISTS engram_orgs (
22
+ id TEXT PRIMARY KEY,
23
+ name TEXT,
24
+ metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
25
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
26
+ );
27
+
28
+ ALTER TABLE engram_workspaces ADD COLUMN IF NOT EXISTS org_id TEXT;
29
+
30
+ DO $$
31
+ BEGIN
32
+ IF NOT EXISTS (
33
+ SELECT 1 FROM pg_constraint
34
+ WHERE conname = 'engram_workspaces_org_id_fkey'
35
+ ) THEN
36
+ ALTER TABLE engram_workspaces
37
+ ADD CONSTRAINT engram_workspaces_org_id_fkey
38
+ FOREIGN KEY (org_id) REFERENCES engram_orgs(id) ON DELETE CASCADE;
39
+ END IF;
40
+ END $$;
41
+
42
+ CREATE INDEX IF NOT EXISTS idx_engram_workspaces_org
43
+ ON engram_workspaces (org_id);
44
+
45
+ CREATE TABLE IF NOT EXISTS engram_org_members (
46
+ org_id TEXT NOT NULL REFERENCES engram_orgs(id) ON DELETE CASCADE,
47
+ user_id TEXT NOT NULL REFERENCES engram_auth_users(id) ON DELETE CASCADE,
48
+ role TEXT NOT NULL DEFAULT 'member',
49
+ joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
50
+ PRIMARY KEY (org_id, user_id)
51
+ );
52
+ CREATE INDEX IF NOT EXISTS idx_engram_org_members_user
53
+ ON engram_org_members (user_id);
54
+
55
+ -- workspace_members from 0006-auth is obsolete in the org-based
56
+ -- model. Drop it; the only path to workspace access is via org
57
+ -- membership.
58
+ DROP TABLE IF EXISTS engram_workspace_members;
59
+ `;
@@ -3,6 +3,8 @@ import * as m0002 from "./0002-aliases";
3
3
  import * as m0003 from "./0003-identities";
4
4
  import * as m0004 from "./0004-schema-completion";
5
5
  import * as m0005 from "./0005-session-updated-at";
6
+ import * as m0006 from "./0006-auth";
7
+ import * as m0007 from "./0007-orgs";
6
8
  /**
7
9
  * Schema migrations, applied in array order. Add a new file under
8
10
  * `migrations/NNNN-<slug>.ts` exporting `name` and `sql`, then append it
@@ -16,4 +18,6 @@ export const MIGRATIONS = [
16
18
  { name: m0003.name, sql: m0003.sql },
17
19
  { name: m0004.name, sql: m0004.sql },
18
20
  { name: m0005.name, sql: m0005.sql },
21
+ { name: m0006.name, sql: m0006.sql },
22
+ { name: m0007.name, sql: m0007.sql },
19
23
  ];
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Org / org-member / workspace-under-org persistence.
3
+ *
4
+ * Orgs are the top-level grouping in the cookie-auth model: a user
5
+ * joins an org once and gains access to every workspace owned by
6
+ * that org. Workspaces still exist as the data-isolation unit
7
+ * (sessions / persons / aliases / identities are per-workspace).
8
+ *
9
+ * Machine api-keys (api-key auth) are unaware of orgs — they
10
+ * continue to resolve directly to a workspace.
11
+ */
12
+ export interface OrgRow {
13
+ id: string;
14
+ name: string | null;
15
+ metadata: Record<string, unknown>;
16
+ createdAt: string;
17
+ }
18
+ export interface OrgMembershipRow {
19
+ orgId: string;
20
+ userId: string;
21
+ role: string;
22
+ joinedAt: string;
23
+ }
24
+ export interface OrgStore {
25
+ createOrg(input: {
26
+ id?: string;
27
+ name?: string;
28
+ metadata?: Record<string, unknown>;
29
+ }): Promise<OrgRow>;
30
+ getOrg(id: string): Promise<OrgRow | null>;
31
+ listOrgs(): Promise<OrgRow[]>;
32
+ deleteOrg(id: string): Promise<void>;
33
+ listMembers(orgId: string): Promise<OrgMembershipRow[]>;
34
+ upsertMember(input: {
35
+ orgId: string;
36
+ userId: string;
37
+ role?: string;
38
+ }): Promise<OrgMembershipRow>;
39
+ removeMember(orgId: string, userId: string): Promise<void>;
40
+ /** Look up a better-auth user by primary email. */
41
+ findUserByEmail(email: string): Promise<{
42
+ id: string;
43
+ email: string;
44
+ } | null>;
45
+ /** Orgs the given user is a member of. */
46
+ listOrgsForUser(userId: string): Promise<OrgMembershipRow[]>;
47
+ /**
48
+ * Set the org an already-existing workspace belongs to. Called
49
+ * by the admin endpoint that creates a workspace under an org
50
+ * (key issuance and engram_workspaces.org_id are written together).
51
+ */
52
+ setWorkspaceOrg(workspaceId: string, orgId: string): Promise<void>;
53
+ /** Workspaces in the given org. */
54
+ listWorkspacesForOrg(orgId: string): Promise<{
55
+ id: string;
56
+ name: string | null;
57
+ }[]>;
58
+ /** Workspaces visible to the user, aggregated across all their orgs. */
59
+ listWorkspacesForUser(userId: string): Promise<{
60
+ id: string;
61
+ name: string | null;
62
+ orgId: string;
63
+ }[]>;
64
+ /** Returns true if the user is an org-member of the workspace's org. */
65
+ userCanAccessWorkspace(userId: string, workspaceId: string): Promise<boolean>;
66
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Org / org-member / workspace-under-org persistence.
3
+ *
4
+ * Orgs are the top-level grouping in the cookie-auth model: a user
5
+ * joins an org once and gains access to every workspace owned by
6
+ * that org. Workspaces still exist as the data-isolation unit
7
+ * (sessions / persons / aliases / identities are per-workspace).
8
+ *
9
+ * Machine api-keys (api-key auth) are unaware of orgs — they
10
+ * continue to resolve directly to a workspace.
11
+ */
12
+ export {};
package/dist/server.d.ts CHANGED
@@ -1,9 +1,29 @@
1
1
  import { Hono } from "hono";
2
+ import type { EngramAuth } from "./auth";
3
+ import type { CookieAuthResolver } from "./auth-resolver";
2
4
  import type { AuthResolver, Env } from "./context";
5
+ import type { OrgStore } from "./org-store";
3
6
  import { type AdminOptions } from "./admin";
4
7
  export interface CreateServerOptions {
5
8
  /** Resolves Bearer / X-Api-Key tokens into workspace contexts. */
6
9
  auth: AuthResolver;
10
+ /**
11
+ * Optional: cookie-session → workspace context. When provided and
12
+ * the request has no x-api-key / Bearer, the gate falls back to
13
+ * this resolver (engram-web human users).
14
+ */
15
+ cookieAuth?: CookieAuthResolver;
16
+ /**
17
+ * Optional: better-auth instance. When provided, mounted at
18
+ * `/auth/*` so engram-web can hit /auth/sign-in/social etc.
19
+ */
20
+ authHandler?: EngramAuth;
21
+ /**
22
+ * Optional: org store. When provided AND \`authHandler\` is set,
23
+ * exposes \`GET /v1/me/workspaces\` and \`GET /v1/me/orgs\` so
24
+ * engram-web can populate its org / workspace switcher.
25
+ */
26
+ orgStore?: OrgStore;
7
27
  /**
8
28
  * Generates session ids. Defaults to `crypto.randomUUID()`.
9
29
  * Provide a custom function for deterministic ids in tests.
@@ -19,6 +39,12 @@ export interface CreateServerOptions {
19
39
  * a separate platform token, never crossed with workspace API keys.
20
40
  */
21
41
  admin?: AdminOptions;
42
+ /**
43
+ * Origins allowed to make credentialed (cookie-bearing) requests to
44
+ * /auth/* and /v1/*. engram-web's URL goes here. When unset, CORS
45
+ * is not applied (api-key callers don't need it).
46
+ */
47
+ corsOrigins?: string[];
22
48
  }
23
49
  /**
24
50
  * Build the engram HTTP app. Wiring only: this sets up cross-cutting