@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.
- package/dist/adapters/memory-key-store.d.ts +4 -0
- package/dist/adapters/memory-key-store.js +12 -0
- package/dist/adapters/memory.js +47 -66
- package/dist/adapters/pg-tagged.d.ts +18 -0
- package/dist/adapters/pg-tagged.js +29 -0
- package/dist/adapters/postgres-key-store.d.ts +4 -0
- package/dist/adapters/postgres-key-store.js +14 -3
- package/dist/adapters/postgres-org-store.d.ts +42 -0
- package/dist/adapters/postgres-org-store.js +120 -0
- package/dist/adapters/postgres.js +57 -80
- package/dist/adapters/util.d.ts +27 -0
- package/dist/adapters/util.js +47 -0
- package/dist/admin.d.ts +26 -4
- package/dist/admin.js +126 -7
- package/dist/auth-resolver.d.ts +32 -0
- package/dist/auth-resolver.js +53 -0
- package/dist/auth.d.ts +196 -0
- package/dist/auth.js +164 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/key-store.d.ts +5 -0
- package/dist/main.js +84 -26
- package/dist/migrations/0006-auth.d.ts +2 -0
- package/dist/migrations/0006-auth.js +84 -0
- package/dist/migrations/0007-orgs.d.ts +2 -0
- package/dist/migrations/0007-orgs.js +59 -0
- package/dist/migrations/index.js +4 -0
- package/dist/openapi.js +340 -3
- package/dist/org-store.d.ts +73 -0
- package/dist/org-store.js +12 -0
- package/dist/routes/orgs.d.ts +27 -0
- package/dist/routes/orgs.js +185 -0
- package/dist/schemas.d.ts +18 -0
- package/dist/schemas.js +19 -0
- package/dist/server.d.ts +39 -0
- package/dist/server.js +85 -7
- package/dist/services/orgs.d.ts +95 -0
- package/dist/services/orgs.js +159 -0
- package/dist/storage.d.ts +6 -0
- package/dist/storage.js +14 -0
- package/openapi.json +1279 -1
- 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
|
-
//
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
87
|
-
//
|
|
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
|
+
}
|