@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
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Cookie-auth org self-service routes (Wave G5).
3
+ *
4
+ * Auth gating only — the underlying logic lives in services/orgs.ts so
5
+ * the admin-token surface (`/admin/v1/orgs/*`) can call the same code
6
+ * without duplicating it.
7
+ *
8
+ * Mount BEFORE the workspace gate in src/server.ts; these endpoints
9
+ * have their own membership check and a chosen workspace would be
10
+ * irrelevant noise.
11
+ */
12
+ import { Hono } from "hono";
13
+ import { addMember, createWorkspaceUnderOrg, OrgServiceError, removeMember, revokeWorkspaceKey, updateOrg, updateOrgWorkspace, } from "../services/orgs";
14
+ const WRITE_ROLES = new Set(["owner", "admin"]);
15
+ export function orgsRoutes(opts) {
16
+ const app = new Hono();
17
+ const deps = { orgStore: opts.orgStore, keyStore: opts.keyStore };
18
+ const requireMember = async (req, orgId, minRole = "member") => {
19
+ const s = await opts.authHandler.api
20
+ .getSession({ headers: req.headers })
21
+ .catch(() => null);
22
+ if (!s?.user)
23
+ return jsonResponse(401, "unauthorized");
24
+ const memberships = await opts.orgStore.listOrgsForUser(s.user.id);
25
+ const here = memberships.find((m) => m.orgId === orgId);
26
+ if (!here)
27
+ return jsonResponse(403, "forbidden");
28
+ if (minRole !== "member" && !WRITE_ROLES.has(here.role)) {
29
+ return jsonResponse(403, "forbidden");
30
+ }
31
+ return { user: { id: s.user.id }, role: here.role };
32
+ };
33
+ // ---------- org ---------------------------------------------
34
+ app.get("/v1/orgs/:id", async (c) => {
35
+ const gate = await requireMember(c.req.raw, c.req.param("id"));
36
+ if (gate instanceof Response)
37
+ return gate;
38
+ const org = await opts.orgStore.getOrg(c.req.param("id"));
39
+ if (!org)
40
+ return c.json({ error: "org_not_found" }, 404);
41
+ return c.json({ org, role: gate.role });
42
+ });
43
+ app.patch("/v1/orgs/:id", async (c) => {
44
+ const id = c.req.param("id");
45
+ const gate = await requireMember(c.req.raw, id, "admin");
46
+ if (gate instanceof Response)
47
+ return gate;
48
+ return runService(c, async () => {
49
+ const body = (await c.req.json().catch(() => ({})));
50
+ const org = await updateOrg(deps, id, body);
51
+ return c.json({ org });
52
+ });
53
+ });
54
+ // ---------- members -----------------------------------------
55
+ app.get("/v1/orgs/:id/members", async (c) => {
56
+ const gate = await requireMember(c.req.raw, c.req.param("id"));
57
+ if (gate instanceof Response)
58
+ return gate;
59
+ return c.json({ members: await opts.orgStore.listMembers(c.req.param("id")) });
60
+ });
61
+ app.post("/v1/orgs/:id/members", async (c) => {
62
+ const orgId = c.req.param("id");
63
+ const gate = await requireMember(c.req.raw, orgId, "admin");
64
+ if (gate instanceof Response)
65
+ return gate;
66
+ return runService(c, async () => {
67
+ const body = (await c.req.json().catch(() => ({})));
68
+ const member = await addMember(deps, orgId, body);
69
+ return c.json({ member });
70
+ });
71
+ });
72
+ app.delete("/v1/orgs/:id/members/:userId", async (c) => {
73
+ const orgId = c.req.param("id");
74
+ const gate = await requireMember(c.req.raw, orgId, "admin");
75
+ if (gate instanceof Response)
76
+ return gate;
77
+ return runService(c, async () => {
78
+ await removeMember(deps, orgId, c.req.param("userId"));
79
+ return c.body(null, 204);
80
+ });
81
+ });
82
+ // ---------- workspaces -------------------------------------
83
+ app.get("/v1/orgs/:id/workspaces", async (c) => {
84
+ const gate = await requireMember(c.req.raw, c.req.param("id"));
85
+ if (gate instanceof Response)
86
+ return gate;
87
+ const workspaces = await opts.orgStore.listWorkspacesForOrg(c.req.param("id"));
88
+ return c.json({ workspaces });
89
+ });
90
+ app.patch("/v1/orgs/:id/workspaces/:wsId", async (c) => {
91
+ const orgId = c.req.param("id");
92
+ const gate = await requireMember(c.req.raw, orgId, "admin");
93
+ if (gate instanceof Response)
94
+ return gate;
95
+ const wsId = c.req.param("wsId");
96
+ const ok = await opts.orgStore.userCanAccessWorkspace(gate.user.id, wsId);
97
+ if (!ok)
98
+ return c.json({ error: "workspace_not_in_org" }, 404);
99
+ return runService(c, async () => {
100
+ const body = (await c.req.json().catch(() => ({})));
101
+ return c.json(await updateOrgWorkspace(deps, orgId, wsId, body));
102
+ });
103
+ });
104
+ app.delete("/v1/orgs/:id/workspaces/:wsId", async (c) => {
105
+ const orgId = c.req.param("id");
106
+ const gate = await requireMember(c.req.raw, orgId, "admin");
107
+ if (gate instanceof Response)
108
+ return gate;
109
+ const wsId = c.req.param("wsId");
110
+ const ok = await opts.orgStore.userCanAccessWorkspace(gate.user.id, wsId);
111
+ if (!ok)
112
+ return c.json({ error: "workspace_not_in_org" }, 404);
113
+ await opts.keyStore.deleteWorkspace(wsId);
114
+ return c.body(null, 204);
115
+ });
116
+ app.post("/v1/orgs/:id/workspaces", async (c) => {
117
+ const orgId = c.req.param("id");
118
+ const gate = await requireMember(c.req.raw, orgId, "admin");
119
+ if (gate instanceof Response)
120
+ return gate;
121
+ return runService(c, async () => {
122
+ const body = (await c.req.json().catch(() => ({})));
123
+ return c.json(await createWorkspaceUnderOrg(deps, orgId, body));
124
+ });
125
+ });
126
+ // ---------- api keys (scoped to a workspace) ----------------
127
+ app.get("/v1/orgs/:id/workspaces/:wsId/keys", async (c) => {
128
+ const orgId = c.req.param("id");
129
+ const gate = await requireMember(c.req.raw, orgId, "admin");
130
+ if (gate instanceof Response)
131
+ return gate;
132
+ const wsId = c.req.param("wsId");
133
+ const ok = await opts.orgStore.userCanAccessWorkspace(gate.user.id, wsId);
134
+ if (!ok)
135
+ return c.json({ error: "workspace_not_in_org" }, 404);
136
+ return c.json({ keys: await opts.keyStore.listKeys(wsId) });
137
+ });
138
+ app.post("/v1/orgs/:id/workspaces/:wsId/keys", async (c) => {
139
+ const orgId = c.req.param("id");
140
+ const gate = await requireMember(c.req.raw, orgId, "admin");
141
+ if (gate instanceof Response)
142
+ return gate;
143
+ const wsId = c.req.param("wsId");
144
+ const ok = await opts.orgStore.userCanAccessWorkspace(gate.user.id, wsId);
145
+ if (!ok)
146
+ return c.json({ error: "workspace_not_in_org" }, 404);
147
+ const body = (await c.req.json().catch(() => ({})));
148
+ const key = await opts.keyStore.issueKey(wsId, {
149
+ ...(body.name !== undefined ? { name: body.name } : {}),
150
+ });
151
+ return c.json(key);
152
+ });
153
+ app.delete("/v1/orgs/:id/workspaces/:wsId/keys/:keyId", async (c) => {
154
+ const orgId = c.req.param("id");
155
+ const gate = await requireMember(c.req.raw, orgId, "admin");
156
+ if (gate instanceof Response)
157
+ return gate;
158
+ const wsId = c.req.param("wsId");
159
+ const ok = await opts.orgStore.userCanAccessWorkspace(gate.user.id, wsId);
160
+ if (!ok)
161
+ return c.json({ error: "workspace_not_in_org" }, 404);
162
+ return runService(c, async () => {
163
+ await revokeWorkspaceKey(deps, wsId, c.req.param("keyId"));
164
+ return c.body(null, 204);
165
+ });
166
+ });
167
+ return app;
168
+ }
169
+ function jsonResponse(status, error) {
170
+ return new Response(JSON.stringify({ error }), {
171
+ status,
172
+ headers: { "content-type": "application/json" },
173
+ });
174
+ }
175
+ /** Run a service call, mapping OrgServiceError to a JSON response. */
176
+ async function runService(c, fn) {
177
+ try {
178
+ return await fn();
179
+ }
180
+ catch (e) {
181
+ if (e instanceof OrgServiceError)
182
+ return c.json({ error: e.code }, e.status);
183
+ throw e;
184
+ }
185
+ }
package/dist/schemas.d.ts CHANGED
@@ -161,6 +161,24 @@ export declare const createWorkspaceSchema: z.ZodObject<{
161
161
  export declare const issueKeySchema: z.ZodObject<{
162
162
  name: z.ZodOptional<z.ZodString>;
163
163
  }, z.core.$strip>;
164
+ export declare const createOrgSchema: z.ZodObject<{
165
+ id: z.ZodOptional<z.ZodString>;
166
+ name: z.ZodOptional<z.ZodString>;
167
+ metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
168
+ }, z.core.$strip>;
169
+ export declare const orgPatchSchema: z.ZodObject<{
170
+ name: z.ZodOptional<z.ZodString>;
171
+ metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
172
+ }, z.core.$strip>;
173
+ export declare const workspacePatchSchema: z.ZodObject<{
174
+ name: z.ZodOptional<z.ZodString>;
175
+ metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
176
+ }, z.core.$strip>;
177
+ export declare const addMemberSchema: z.ZodObject<{
178
+ userId: z.ZodOptional<z.ZodString>;
179
+ email: z.ZodOptional<z.ZodString>;
180
+ role: z.ZodOptional<z.ZodString>;
181
+ }, z.core.$strip>;
164
182
  /**
165
183
  * Read and validate a JSON request body. Returns the parsed value, or a
166
184
  * `Response` (400) the caller should return as-is:
package/dist/schemas.js CHANGED
@@ -151,6 +151,25 @@ export const createWorkspaceSchema = z.object({
151
151
  export const issueKeySchema = z.object({
152
152
  name: z.string().optional(),
153
153
  });
154
+ // --- Orgs (admin + self-serve share these bodies) --------------------
155
+ export const createOrgSchema = z.object({
156
+ id: z.string().optional(),
157
+ name: z.string().optional(),
158
+ metadata: z.record(z.string(), z.unknown()).optional(),
159
+ });
160
+ export const orgPatchSchema = z.object({
161
+ name: z.string().optional(),
162
+ metadata: z.record(z.string(), z.unknown()).optional(),
163
+ });
164
+ export const workspacePatchSchema = z.object({
165
+ name: z.string().optional(),
166
+ metadata: z.record(z.string(), z.unknown()).optional(),
167
+ });
168
+ export const addMemberSchema = z.object({
169
+ userId: z.string().optional(),
170
+ email: z.string().optional(),
171
+ role: z.string().optional(),
172
+ });
154
173
  // --- Helper ----------------------------------------------------------
155
174
  /**
156
175
  * Read and validate a JSON request body. Returns the parsed value, or a
package/dist/server.d.ts CHANGED
@@ -1,9 +1,42 @@
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";
7
+ import type { KeyStore } from "./key-store";
4
8
  export interface CreateServerOptions {
5
9
  /** Resolves Bearer / X-Api-Key tokens into workspace contexts. */
6
10
  auth: AuthResolver;
11
+ /**
12
+ * Optional: cookie-session → workspace context. When provided and
13
+ * the request has no x-api-key / Bearer, the gate falls back to
14
+ * this resolver (engram-web human users).
15
+ */
16
+ cookieAuth?: CookieAuthResolver;
17
+ /**
18
+ * Optional: better-auth instance. When provided, mounted at
19
+ * `/auth/*` so engram-web can hit /auth/sign-in/social etc.
20
+ */
21
+ authHandler?: EngramAuth;
22
+ /**
23
+ * Optional: org store. When provided AND \`authHandler\` is set,
24
+ * exposes \`GET /v1/me/workspaces\` and \`GET /v1/me/orgs\` so
25
+ * engram-web can populate its org / workspace switcher.
26
+ *
27
+ * Combined with \`keyStore\` it also unlocks the cookie-auth
28
+ * \`/v1/orgs/:id/*\` self-service surface (members, workspaces,
29
+ * api keys) — see routes/orgs.ts.
30
+ */
31
+ orgStore?: OrgStore;
32
+ /**
33
+ * Optional: key store, shared between the admin router and the
34
+ * cookie-auth orgs surface. The admin router takes its own
35
+ * reference (\`admin.keyStore\`) for the legacy / api-key flows;
36
+ * this top-level handle lets \`/v1/orgs/:id/*\` issue and revoke
37
+ * workspace keys without the admin token.
38
+ */
39
+ keyStore?: KeyStore;
7
40
  /**
8
41
  * Generates session ids. Defaults to `crypto.randomUUID()`.
9
42
  * Provide a custom function for deterministic ids in tests.
@@ -19,6 +52,12 @@ export interface CreateServerOptions {
19
52
  * a separate platform token, never crossed with workspace API keys.
20
53
  */
21
54
  admin?: AdminOptions;
55
+ /**
56
+ * Origins allowed to make credentialed (cookie-bearing) requests to
57
+ * /auth/* and /v1/*. engram-web's URL goes here. When unset, CORS
58
+ * is not applied (api-key callers don't need it).
59
+ */
60
+ corsOrigins?: string[];
22
61
  }
23
62
  /**
24
63
  * Build the engram HTTP app. Wiring only: this sets up cross-cutting
package/dist/server.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Hono } from "hono";
2
+ import { cors } from "hono/cors";
2
3
  import { log, newRequestId } from "./logger";
3
4
  import { createAdminRouter } from "./admin";
4
5
  import { aliasesRoutes } from "./routes/aliases";
@@ -6,6 +7,8 @@ import { sessionsRoutes } from "./routes/sessions";
6
7
  import { personsRoutes } from "./routes/persons";
7
8
  import { identitiesRoutes } from "./routes/identities";
8
9
  import { searchRoutes } from "./routes/search";
10
+ import { orgsRoutes } from "./routes/orgs";
11
+ import { buildOpenApiDocument } from "./openapi";
9
12
  /**
10
13
  * Build the engram HTTP app. Wiring only: this sets up cross-cutting
11
14
  * middleware (request id + access log), the workspace auth gate, and mounts
@@ -39,6 +42,28 @@ export function createServer(opts) {
39
42
  });
40
43
  }
41
44
  });
45
+ // CORS — required so engram-web (different origin) can carry the
46
+ // auth cookie. Applies to /auth/* and /v1/* only; admin uses a
47
+ // bearer token from server-side callers and doesn't need CORS.
48
+ if (opts.corsOrigins && opts.corsOrigins.length > 0) {
49
+ const origins = opts.corsOrigins;
50
+ const corsMw = cors({
51
+ origin: (o) => (origins.includes(o) ? o : null),
52
+ credentials: true,
53
+ allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
54
+ allowHeaders: [
55
+ "content-type",
56
+ "x-api-key",
57
+ "x-workspace-id",
58
+ "x-request-id",
59
+ "authorization",
60
+ ],
61
+ exposeHeaders: ["x-request-id"],
62
+ maxAge: 600,
63
+ });
64
+ app.use("/auth/*", corsMw);
65
+ app.use("/v1/*", corsMw);
66
+ }
42
67
  app.onError((err, c) => {
43
68
  log.error("unhandled", {
44
69
  request_id: c.var.request_id,
@@ -68,24 +93,77 @@ export function createServer(opts) {
68
93
  },
69
94
  }));
70
95
  app.get("/healthz", (c) => c.json({ ok: true }));
96
+ // OpenAPI 3.1 document for the public /v1 surface. Served live
97
+ // from the same Zod schemas the server validates with, so it
98
+ // can't drift. engram-web's /docs renders this through Scalar.
99
+ app.get("/openapi.json", (c) => c.json(buildOpenApiDocument()));
71
100
  if (opts.admin) {
72
101
  app.route("/admin/v1", createAdminRouter(opts.admin));
73
102
  }
74
- // Workspace auth gate every `/v1/*` route runs behind this.
103
+ // better-auth catchall. Mounted before `/v1` so /auth/* never falls
104
+ // through to the workspace gate.
105
+ if (opts.authHandler) {
106
+ const handler = opts.authHandler;
107
+ app.all("/auth/*", (c) => handler.handler(c.req.raw));
108
+ }
109
+ // Cookie-auth helpers for engram-web: list every workspace / org
110
+ // the signed-in user can reach. Mounted BEFORE the workspace gate
111
+ // because these endpoints are how the UI chooses a workspace —
112
+ // gating them on x-workspace-id would deadlock new sign-ins.
113
+ if (opts.authHandler && opts.orgStore) {
114
+ const handler = opts.authHandler;
115
+ const orgStore = opts.orgStore;
116
+ const requireUser = async (req) => {
117
+ const s = await handler.api.getSession({ headers: req.headers }).catch(() => null);
118
+ return s?.user ?? null;
119
+ };
120
+ app.get("/v1/me/workspaces", async (c) => {
121
+ const user = await requireUser(c.req.raw);
122
+ if (!user)
123
+ return c.json({ error: "unauthorized" }, 401);
124
+ const workspaces = await orgStore.listWorkspacesForUser(user.id);
125
+ return c.json({ workspaces });
126
+ });
127
+ app.get("/v1/me/orgs", async (c) => {
128
+ const user = await requireUser(c.req.raw);
129
+ if (!user)
130
+ return c.json({ error: "unauthorized" }, 401);
131
+ const memberships = await orgStore.listOrgsForUser(user.id);
132
+ const orgs = await Promise.all(memberships.map(async (m) => ({
133
+ ...(await orgStore.getOrg(m.orgId)),
134
+ role: m.role,
135
+ })));
136
+ return c.json({ orgs: orgs.filter((o) => o.id) });
137
+ });
138
+ // Org self-service surface (Wave G5): members, workspaces, api
139
+ // keys — same shape as the admin endpoints but cookie-auth +
140
+ // org-membership + role check.
141
+ if (opts.keyStore) {
142
+ app.route("/", orgsRoutes({
143
+ authHandler: handler,
144
+ orgStore,
145
+ keyStore: opts.keyStore,
146
+ }));
147
+ }
148
+ }
149
+ // Workspace auth gate — every other `/v1/*` route runs behind this.
150
+ // Tries api-key first; falls back to cookie session when configured.
151
+ // The Bearer header is reserved for api-keys here; engram-web uses
152
+ // the session cookie, not a Bearer.
75
153
  app.use("/v1/*", async (c, next) => {
76
154
  const apiKey = c.req.header("x-api-key") ??
77
155
  c.req.header("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1];
78
- if (!apiKey)
79
- return c.json({ error: "unauthorized" }, 401);
80
- const ctx = await opts.auth(apiKey);
156
+ let ctx = apiKey ? await opts.auth(apiKey) : null;
157
+ if (!ctx && !apiKey && opts.cookieAuth) {
158
+ ctx = await opts.cookieAuth(c.req.raw);
159
+ }
81
160
  if (!ctx)
82
161
  return c.json({ error: "unauthorized" }, 401);
83
162
  c.set("ctx", ctx);
84
163
  await next();
85
164
  });
86
- // Identity probe — echoes the workspace the caller's key resolves to.
87
- // Used by host clients (e.g. monet's `/v1/engram/*` proxy) to label
88
- // which tenant they're viewing.
165
+ // Identity probe — echoes the workspace the caller's auth
166
+ // resolves to. Cheap, used as a health/whoami by clients.
89
167
  app.get("/v1/me", (c) => c.json({ workspaceId: c.var.ctx.workspaceId }));
90
168
  app.route("/v1", sessionsRoutes(cfg));
91
169
  app.route("/v1", personsRoutes(cfg));
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Org / workspace business logic shared between the admin-token surface
3
+ * (`/admin/v1/*`) and the cookie-auth self-service surface (`/v1/orgs/*`).
4
+ *
5
+ * Auth and HTTP framing stay in the route modules. This file owns:
6
+ * - the multi-step orchestrations (createWorkspace + setWorkspaceOrg
7
+ * + issueKey; email→userId lookup + upsertMember)
8
+ * - the safety invariants (workspace id validation, last-owner
9
+ * protection on member removal)
10
+ * - the canonical error → status mapping (OrgServiceError.status)
11
+ *
12
+ * Functions throw {@link OrgServiceError} for caller errors; route
13
+ * handlers catch it and translate to JSON. Storage errors that aren't
14
+ * known caller faults propagate.
15
+ */
16
+ import { type IssuedKey, type KeyStore, type Workspace } from "../key-store";
17
+ import type { OrgMembershipRow, OrgRow, OrgStore } from "../org-store";
18
+ export type OrgServiceErrorStatus = 400 | 403 | 404;
19
+ /** Caller-fault errors with a stable code → status mapping. */
20
+ export declare class OrgServiceError extends Error {
21
+ readonly code: string;
22
+ readonly status: OrgServiceErrorStatus;
23
+ constructor(code: string, status: OrgServiceErrorStatus);
24
+ }
25
+ export interface OrgServiceDeps {
26
+ orgStore: OrgStore;
27
+ keyStore: KeyStore;
28
+ }
29
+ export declare function getOrgOrThrow(deps: OrgServiceDeps, id: string): Promise<OrgRow>;
30
+ export declare function createOrg(deps: OrgServiceDeps, body: {
31
+ id?: string;
32
+ name?: string;
33
+ metadata?: Record<string, unknown>;
34
+ }): Promise<OrgRow>;
35
+ export declare function updateOrg(deps: OrgServiceDeps, id: string, body: {
36
+ name?: string;
37
+ metadata?: Record<string, unknown>;
38
+ }): Promise<OrgRow>;
39
+ export declare function deleteOrg(deps: OrgServiceDeps, id: string): Promise<void>;
40
+ export interface AddMemberInput {
41
+ userId?: string;
42
+ email?: string;
43
+ role?: string;
44
+ }
45
+ /**
46
+ * Resolve an email or explicit userId to a membership row, upserting at
47
+ * the requested role. Used by both the admin and self-serve surfaces.
48
+ */
49
+ export declare function addMember(deps: OrgServiceDeps, orgId: string, input: AddMemberInput): Promise<OrgMembershipRow>;
50
+ /**
51
+ * Remove a membership. Refuses to remove the org's sole remaining owner
52
+ * (would brick the org). Applies to admin-token callers too —
53
+ * `deleteOrg` is the path for tearing down an org entirely.
54
+ */
55
+ export declare function removeMember(deps: OrgServiceDeps, orgId: string, userId: string): Promise<void>;
56
+ export interface CreateWorkspaceInput {
57
+ id?: string;
58
+ name?: string;
59
+ metadata?: Record<string, unknown>;
60
+ /** Whether to issue an initial API key. Default true. */
61
+ issueKey?: boolean;
62
+ /** Optional name applied to the initial API key. */
63
+ keyName?: string;
64
+ }
65
+ export interface CreateWorkspaceResult {
66
+ workspace: Workspace & {
67
+ orgId: string;
68
+ };
69
+ /** Present unless `issueKey === false`. */
70
+ key?: IssuedKey;
71
+ }
72
+ /**
73
+ * Stand up a new workspace under an org in one round-trip:
74
+ * createWorkspace → setWorkspaceOrg → (optional) issueKey.
75
+ *
76
+ * Validates the workspace id shape up front so a bad id fails before
77
+ * touching either store. Returns the workspace with `orgId` stamped on
78
+ * so callers don't need a second lookup.
79
+ */
80
+ export declare function createWorkspaceUnderOrg(deps: OrgServiceDeps, orgId: string, body: CreateWorkspaceInput): Promise<CreateWorkspaceResult>;
81
+ export declare function updateOrgWorkspace(deps: OrgServiceDeps, orgId: string, wsId: string, body: {
82
+ name?: string;
83
+ metadata?: Record<string, unknown>;
84
+ }): Promise<{
85
+ workspace: Workspace & {
86
+ orgId: string;
87
+ };
88
+ }>;
89
+ export declare function revokeWorkspaceKey(deps: OrgServiceDeps, wsId: string, keyId: string): Promise<void>;
90
+ /**
91
+ * Throw `workspace_not_in_org` (404) if the workspace's `org_id` is
92
+ * not `orgId`. Used by both the admin-token and cookie-auth surfaces
93
+ * to scope per-workspace operations to the URL's org.
94
+ */
95
+ export declare function requireWorkspaceInOrg(deps: OrgServiceDeps, orgId: string, wsId: string): Promise<void>;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Org / workspace business logic shared between the admin-token surface
3
+ * (`/admin/v1/*`) and the cookie-auth self-service surface (`/v1/orgs/*`).
4
+ *
5
+ * Auth and HTTP framing stay in the route modules. This file owns:
6
+ * - the multi-step orchestrations (createWorkspace + setWorkspaceOrg
7
+ * + issueKey; email→userId lookup + upsertMember)
8
+ * - the safety invariants (workspace id validation, last-owner
9
+ * protection on member removal)
10
+ * - the canonical error → status mapping (OrgServiceError.status)
11
+ *
12
+ * Functions throw {@link OrgServiceError} for caller errors; route
13
+ * handlers catch it and translate to JSON. Storage errors that aren't
14
+ * known caller faults propagate.
15
+ */
16
+ import { isValidWorkspaceId, } from "../key-store";
17
+ /** Caller-fault errors with a stable code → status mapping. */
18
+ export class OrgServiceError extends Error {
19
+ code;
20
+ status;
21
+ constructor(code, status) {
22
+ super(code);
23
+ this.code = code;
24
+ this.status = status;
25
+ }
26
+ }
27
+ // ---------- orgs ---------------------------------------------------
28
+ export async function getOrgOrThrow(deps, id) {
29
+ const org = await deps.orgStore.getOrg(id);
30
+ if (!org)
31
+ throw new OrgServiceError("org_not_found", 404);
32
+ return org;
33
+ }
34
+ export async function createOrg(deps, body) {
35
+ try {
36
+ return await deps.orgStore.createOrg({
37
+ ...(body.id !== undefined ? { id: body.id } : {}),
38
+ ...(body.name !== undefined ? { name: body.name } : {}),
39
+ ...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
40
+ });
41
+ }
42
+ catch (e) {
43
+ throw new OrgServiceError(e.message, 400);
44
+ }
45
+ }
46
+ export async function updateOrg(deps, id, body) {
47
+ try {
48
+ return await deps.orgStore.updateOrg(id, body);
49
+ }
50
+ catch (e) {
51
+ if (e.message === "org_not_found") {
52
+ throw new OrgServiceError("org_not_found", 404);
53
+ }
54
+ throw e;
55
+ }
56
+ }
57
+ export async function deleteOrg(deps, id) {
58
+ await getOrgOrThrow(deps, id);
59
+ await deps.orgStore.deleteOrg(id);
60
+ }
61
+ /**
62
+ * Resolve an email or explicit userId to a membership row, upserting at
63
+ * the requested role. Used by both the admin and self-serve surfaces.
64
+ */
65
+ export async function addMember(deps, orgId, input) {
66
+ let userId = input.userId;
67
+ if (!userId && input.email) {
68
+ const u = await deps.orgStore.findUserByEmail(input.email);
69
+ if (!u)
70
+ throw new OrgServiceError("user_not_found", 404);
71
+ userId = u.id;
72
+ }
73
+ if (!userId)
74
+ throw new OrgServiceError("userId_or_email_required", 400);
75
+ return await deps.orgStore.upsertMember({
76
+ orgId,
77
+ userId,
78
+ ...(input.role !== undefined ? { role: input.role } : {}),
79
+ });
80
+ }
81
+ /**
82
+ * Remove a membership. Refuses to remove the org's sole remaining owner
83
+ * (would brick the org). Applies to admin-token callers too —
84
+ * `deleteOrg` is the path for tearing down an org entirely.
85
+ */
86
+ export async function removeMember(deps, orgId, userId) {
87
+ const members = await deps.orgStore.listMembers(orgId);
88
+ const owners = members.filter((m) => m.role === "owner");
89
+ if (owners.length === 1 && owners[0].userId === userId) {
90
+ throw new OrgServiceError("cannot_remove_last_owner", 400);
91
+ }
92
+ await deps.orgStore.removeMember(orgId, userId);
93
+ }
94
+ /**
95
+ * Stand up a new workspace under an org in one round-trip:
96
+ * createWorkspace → setWorkspaceOrg → (optional) issueKey.
97
+ *
98
+ * Validates the workspace id shape up front so a bad id fails before
99
+ * touching either store. Returns the workspace with `orgId` stamped on
100
+ * so callers don't need a second lookup.
101
+ */
102
+ export async function createWorkspaceUnderOrg(deps, orgId, body) {
103
+ if (body.id !== undefined && !isValidWorkspaceId(body.id)) {
104
+ throw new OrgServiceError("invalid_workspace_id", 400);
105
+ }
106
+ try {
107
+ const ws = await deps.keyStore.createWorkspace({
108
+ ...(body.id !== undefined ? { id: body.id } : {}),
109
+ ...(body.name !== undefined ? { name: body.name } : {}),
110
+ ...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
111
+ });
112
+ await deps.orgStore.setWorkspaceOrg(ws.id, orgId);
113
+ if (body.issueKey === false) {
114
+ return { workspace: { ...ws, orgId } };
115
+ }
116
+ const key = await deps.keyStore.issueKey(ws.id, {
117
+ ...(body.keyName !== undefined ? { name: body.keyName } : {}),
118
+ });
119
+ return { workspace: { ...ws, orgId }, key };
120
+ }
121
+ catch (e) {
122
+ if (e instanceof OrgServiceError)
123
+ throw e;
124
+ throw new OrgServiceError(e.message, 400);
125
+ }
126
+ }
127
+ export async function updateOrgWorkspace(deps, orgId, wsId, body) {
128
+ try {
129
+ const ws = await deps.keyStore.updateWorkspace(wsId, body);
130
+ return { workspace: { ...ws, orgId } };
131
+ }
132
+ catch (e) {
133
+ if (e.message === "workspace_not_found") {
134
+ throw new OrgServiceError("workspace_not_found", 404);
135
+ }
136
+ throw e;
137
+ }
138
+ }
139
+ export async function revokeWorkspaceKey(deps, wsId, keyId) {
140
+ try {
141
+ await deps.keyStore.revokeKey(wsId, keyId);
142
+ }
143
+ catch (e) {
144
+ if (e.message === "key_not_found") {
145
+ throw new OrgServiceError("key_not_found", 404);
146
+ }
147
+ throw e;
148
+ }
149
+ }
150
+ /**
151
+ * Throw `workspace_not_in_org` (404) if the workspace's `org_id` is
152
+ * not `orgId`. Used by both the admin-token and cookie-auth surfaces
153
+ * to scope per-workspace operations to the URL's org.
154
+ */
155
+ export async function requireWorkspaceInOrg(deps, orgId, wsId) {
156
+ if (!(await deps.orgStore.workspaceInOrg(orgId, wsId))) {
157
+ throw new OrgServiceError("workspace_not_in_org", 404);
158
+ }
159
+ }