@hexis-ai/engram-server 0.12.0 → 0.14.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 +13 -0
- package/dist/adapters/memory-key-store.js +27 -0
- package/dist/adapters/memory.js +53 -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 +5 -0
- package/dist/adapters/postgres-key-store.js +21 -5
- package/dist/adapters/postgres-org-store.d.ts +5 -1
- package/dist/adapters/postgres-org-store.js +25 -8
- package/dist/adapters/postgres.js +76 -83
- package/dist/adapters/util.d.ts +27 -0
- package/dist/adapters/util.js +47 -0
- package/dist/admin.js +78 -89
- package/dist/key-store.d.ts +13 -0
- package/dist/main.js +29 -44
- package/dist/migrations/0008-trigger-metadata.d.ts +2 -0
- package/dist/migrations/0008-trigger-metadata.js +15 -0
- package/dist/migrations/index.js +2 -0
- package/dist/openapi.js +340 -3
- package/dist/org-store.d.ts +7 -6
- package/dist/routes/orgs.d.ts +27 -0
- package/dist/routes/orgs.js +185 -0
- package/dist/schemas.d.ts +22 -0
- package/dist/schemas.js +23 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +40 -23
- package/dist/services/orgs.d.ts +99 -0
- package/dist/services/orgs.js +163 -0
- package/dist/storage.d.ts +8 -0
- package/dist/storage.js +20 -0
- package/openapi.json +1331 -13
- package/package.json +4 -13
|
@@ -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
|
@@ -24,6 +24,8 @@ export declare const sessionInitSchema: z.ZodObject<{
|
|
|
24
24
|
model: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
25
25
|
trigger_conversation_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
26
26
|
trigger_event_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
27
|
+
trigger_purpose: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
28
|
+
trigger_resume_hint: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
27
29
|
}, z.core.$strip>;
|
|
28
30
|
export declare const sessionUpdateSchema: z.ZodObject<{
|
|
29
31
|
title: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
@@ -37,6 +39,8 @@ export declare const sessionUpdateSchema: z.ZodObject<{
|
|
|
37
39
|
model: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
38
40
|
trigger_conversation_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
39
41
|
trigger_event_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
42
|
+
trigger_purpose: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
43
|
+
trigger_resume_hint: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
40
44
|
}, z.core.$strip>;
|
|
41
45
|
export declare const sessionEventSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
42
46
|
type: z.ZodLiteral<"step">;
|
|
@@ -161,6 +165,24 @@ export declare const createWorkspaceSchema: z.ZodObject<{
|
|
|
161
165
|
export declare const issueKeySchema: z.ZodObject<{
|
|
162
166
|
name: z.ZodOptional<z.ZodString>;
|
|
163
167
|
}, z.core.$strip>;
|
|
168
|
+
export declare const createOrgSchema: z.ZodObject<{
|
|
169
|
+
id: z.ZodOptional<z.ZodString>;
|
|
170
|
+
name: z.ZodOptional<z.ZodString>;
|
|
171
|
+
metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
172
|
+
}, z.core.$strip>;
|
|
173
|
+
export declare const orgPatchSchema: 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 workspacePatchSchema: z.ZodObject<{
|
|
178
|
+
name: z.ZodOptional<z.ZodString>;
|
|
179
|
+
metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
180
|
+
}, z.core.$strip>;
|
|
181
|
+
export declare const addMemberSchema: z.ZodObject<{
|
|
182
|
+
userId: z.ZodOptional<z.ZodString>;
|
|
183
|
+
email: z.ZodOptional<z.ZodString>;
|
|
184
|
+
role: z.ZodOptional<z.ZodString>;
|
|
185
|
+
}, z.core.$strip>;
|
|
164
186
|
/**
|
|
165
187
|
* Read and validate a JSON request body. Returns the parsed value, or a
|
|
166
188
|
* `Response` (400) the caller should return as-is:
|
package/dist/schemas.js
CHANGED
|
@@ -20,6 +20,8 @@ export const sessionInitSchema = z.object({
|
|
|
20
20
|
model: z.string().nullable().optional(),
|
|
21
21
|
trigger_conversation_id: z.string().nullable().optional(),
|
|
22
22
|
trigger_event_id: z.string().nullable().optional(),
|
|
23
|
+
trigger_purpose: z.string().nullable().optional(),
|
|
24
|
+
trigger_resume_hint: z.string().nullable().optional(),
|
|
23
25
|
});
|
|
24
26
|
export const sessionUpdateSchema = z.object({
|
|
25
27
|
title: z.string().nullable().optional(),
|
|
@@ -29,6 +31,8 @@ export const sessionUpdateSchema = z.object({
|
|
|
29
31
|
model: z.string().nullable().optional(),
|
|
30
32
|
trigger_conversation_id: z.string().nullable().optional(),
|
|
31
33
|
trigger_event_id: z.string().nullable().optional(),
|
|
34
|
+
trigger_purpose: z.string().nullable().optional(),
|
|
35
|
+
trigger_resume_hint: z.string().nullable().optional(),
|
|
32
36
|
});
|
|
33
37
|
const stepEventSchema = z.object({
|
|
34
38
|
type: z.literal("step"),
|
|
@@ -151,6 +155,25 @@ export const createWorkspaceSchema = z.object({
|
|
|
151
155
|
export const issueKeySchema = z.object({
|
|
152
156
|
name: z.string().optional(),
|
|
153
157
|
});
|
|
158
|
+
// --- Orgs (admin + self-serve share these bodies) --------------------
|
|
159
|
+
export const createOrgSchema = z.object({
|
|
160
|
+
id: z.string().optional(),
|
|
161
|
+
name: z.string().optional(),
|
|
162
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
163
|
+
});
|
|
164
|
+
export const orgPatchSchema = z.object({
|
|
165
|
+
name: z.string().optional(),
|
|
166
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
167
|
+
});
|
|
168
|
+
export const workspacePatchSchema = z.object({
|
|
169
|
+
name: z.string().optional(),
|
|
170
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
171
|
+
});
|
|
172
|
+
export const addMemberSchema = z.object({
|
|
173
|
+
userId: z.string().optional(),
|
|
174
|
+
email: z.string().optional(),
|
|
175
|
+
role: z.string().optional(),
|
|
176
|
+
});
|
|
154
177
|
// --- Helper ----------------------------------------------------------
|
|
155
178
|
/**
|
|
156
179
|
* Read and validate a JSON request body. Returns the parsed value, or a
|
package/dist/server.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { CookieAuthResolver } from "./auth-resolver";
|
|
|
4
4
|
import type { AuthResolver, Env } from "./context";
|
|
5
5
|
import type { OrgStore } from "./org-store";
|
|
6
6
|
import { type AdminOptions } from "./admin";
|
|
7
|
+
import type { KeyStore } from "./key-store";
|
|
7
8
|
export interface CreateServerOptions {
|
|
8
9
|
/** Resolves Bearer / X-Api-Key tokens into workspace contexts. */
|
|
9
10
|
auth: AuthResolver;
|
|
@@ -22,8 +23,20 @@ export interface CreateServerOptions {
|
|
|
22
23
|
* Optional: org store. When provided AND \`authHandler\` is set,
|
|
23
24
|
* exposes \`GET /v1/me/workspaces\` and \`GET /v1/me/orgs\` so
|
|
24
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.
|
|
25
30
|
*/
|
|
26
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;
|
|
27
40
|
/**
|
|
28
41
|
* Generates session ids. Defaults to `crypto.randomUUID()`.
|
|
29
42
|
* Provide a custom function for deterministic ids in tests.
|
package/dist/server.js
CHANGED
|
@@ -7,6 +7,8 @@ import { sessionsRoutes } from "./routes/sessions";
|
|
|
7
7
|
import { personsRoutes } from "./routes/persons";
|
|
8
8
|
import { identitiesRoutes } from "./routes/identities";
|
|
9
9
|
import { searchRoutes } from "./routes/search";
|
|
10
|
+
import { orgsRoutes } from "./routes/orgs";
|
|
11
|
+
import { buildOpenApiDocument } from "./openapi";
|
|
10
12
|
/**
|
|
11
13
|
* Build the engram HTTP app. Wiring only: this sets up cross-cutting
|
|
12
14
|
* middleware (request id + access log), the workspace auth gate, and mounts
|
|
@@ -91,6 +93,10 @@ export function createServer(opts) {
|
|
|
91
93
|
},
|
|
92
94
|
}));
|
|
93
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()));
|
|
94
100
|
if (opts.admin) {
|
|
95
101
|
app.route("/admin/v1", createAdminRouter(opts.admin));
|
|
96
102
|
}
|
|
@@ -100,33 +106,15 @@ export function createServer(opts) {
|
|
|
100
106
|
const handler = opts.authHandler;
|
|
101
107
|
app.all("/auth/*", (c) => handler.handler(c.req.raw));
|
|
102
108
|
}
|
|
103
|
-
// Workspace auth gate — every `/v1/*` route runs behind this. Tries
|
|
104
|
-
// api-key first; falls back to cookie session when configured. The
|
|
105
|
-
// Bearer header is reserved for api-keys here; engram-web uses the
|
|
106
|
-
// session cookie, not a Bearer.
|
|
107
|
-
app.use("/v1/*", async (c, next) => {
|
|
108
|
-
const apiKey = c.req.header("x-api-key") ??
|
|
109
|
-
c.req.header("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
110
|
-
let ctx = apiKey ? await opts.auth(apiKey) : null;
|
|
111
|
-
if (!ctx && !apiKey && opts.cookieAuth) {
|
|
112
|
-
ctx = await opts.cookieAuth(c.req.raw);
|
|
113
|
-
}
|
|
114
|
-
if (!ctx)
|
|
115
|
-
return c.json({ error: "unauthorized" }, 401);
|
|
116
|
-
c.set("ctx", ctx);
|
|
117
|
-
await next();
|
|
118
|
-
});
|
|
119
|
-
// Identity probe — echoes the workspace the caller's auth
|
|
120
|
-
// resolves to. Cheap, used as a health/whoami by clients.
|
|
121
|
-
app.get("/v1/me", (c) => c.json({ workspaceId: c.var.ctx.workspaceId }));
|
|
122
109
|
// Cookie-auth helpers for engram-web: list every workspace / org
|
|
123
|
-
// the signed-in user can reach.
|
|
124
|
-
//
|
|
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.
|
|
125
113
|
if (opts.authHandler && opts.orgStore) {
|
|
126
|
-
const
|
|
114
|
+
const handler = opts.authHandler;
|
|
127
115
|
const orgStore = opts.orgStore;
|
|
128
116
|
const requireUser = async (req) => {
|
|
129
|
-
const s = await
|
|
117
|
+
const s = await handler.api.getSession({ headers: req.headers }).catch(() => null);
|
|
130
118
|
return s?.user ?? null;
|
|
131
119
|
};
|
|
132
120
|
app.get("/v1/me/workspaces", async (c) => {
|
|
@@ -147,7 +135,36 @@ export function createServer(opts) {
|
|
|
147
135
|
})));
|
|
148
136
|
return c.json({ orgs: orgs.filter((o) => o.id) });
|
|
149
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
|
+
}
|
|
150
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.
|
|
153
|
+
app.use("/v1/*", async (c, next) => {
|
|
154
|
+
const apiKey = c.req.header("x-api-key") ??
|
|
155
|
+
c.req.header("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
156
|
+
let ctx = apiKey ? await opts.auth(apiKey) : null;
|
|
157
|
+
if (!ctx && !apiKey && opts.cookieAuth) {
|
|
158
|
+
ctx = await opts.cookieAuth(c.req.raw);
|
|
159
|
+
}
|
|
160
|
+
if (!ctx)
|
|
161
|
+
return c.json({ error: "unauthorized" }, 401);
|
|
162
|
+
c.set("ctx", ctx);
|
|
163
|
+
await next();
|
|
164
|
+
});
|
|
165
|
+
// Identity probe — echoes the workspace the caller's auth
|
|
166
|
+
// resolves to. Cheap, used as a health/whoami by clients.
|
|
167
|
+
app.get("/v1/me", (c) => c.json({ workspaceId: c.var.ctx.workspaceId }));
|
|
151
168
|
app.route("/v1", sessionsRoutes(cfg));
|
|
152
169
|
app.route("/v1", personsRoutes(cfg));
|
|
153
170
|
app.route("/v1", identitiesRoutes(cfg));
|
|
@@ -0,0 +1,99 @@
|
|
|
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 (createWorkspaceUnderOrg writes
|
|
7
|
+
* the workspace + org binding atomically and then issues a key;
|
|
8
|
+
* email→userId lookup + upsertMember)
|
|
9
|
+
* - the safety invariants (workspace id validation, last-owner
|
|
10
|
+
* protection on member removal)
|
|
11
|
+
* - the canonical error → status mapping (OrgServiceError.status)
|
|
12
|
+
*
|
|
13
|
+
* Functions throw {@link OrgServiceError} for caller errors; route
|
|
14
|
+
* handlers catch it and translate to JSON. Storage errors that aren't
|
|
15
|
+
* known caller faults propagate.
|
|
16
|
+
*/
|
|
17
|
+
import { type IssuedKey, type KeyStore, type Workspace } from "../key-store";
|
|
18
|
+
import type { OrgMembershipRow, OrgRow, OrgStore } from "../org-store";
|
|
19
|
+
export type OrgServiceErrorStatus = 400 | 403 | 404;
|
|
20
|
+
/** Caller-fault errors with a stable code → status mapping. */
|
|
21
|
+
export declare class OrgServiceError extends Error {
|
|
22
|
+
readonly code: string;
|
|
23
|
+
readonly status: OrgServiceErrorStatus;
|
|
24
|
+
constructor(code: string, status: OrgServiceErrorStatus);
|
|
25
|
+
}
|
|
26
|
+
export interface OrgServiceDeps {
|
|
27
|
+
orgStore: OrgStore;
|
|
28
|
+
keyStore: KeyStore;
|
|
29
|
+
}
|
|
30
|
+
export declare function getOrgOrThrow(deps: OrgServiceDeps, id: string): Promise<OrgRow>;
|
|
31
|
+
export declare function createOrg(deps: OrgServiceDeps, body: {
|
|
32
|
+
id?: string;
|
|
33
|
+
name?: string;
|
|
34
|
+
metadata?: Record<string, unknown>;
|
|
35
|
+
}): Promise<OrgRow>;
|
|
36
|
+
export declare function updateOrg(deps: OrgServiceDeps, id: string, body: {
|
|
37
|
+
name?: string;
|
|
38
|
+
metadata?: Record<string, unknown>;
|
|
39
|
+
}): Promise<OrgRow>;
|
|
40
|
+
export declare function deleteOrg(deps: OrgServiceDeps, id: string): Promise<void>;
|
|
41
|
+
export interface AddMemberInput {
|
|
42
|
+
userId?: string;
|
|
43
|
+
email?: string;
|
|
44
|
+
role?: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Resolve an email or explicit userId to a membership row, upserting at
|
|
48
|
+
* the requested role. Used by both the admin and self-serve surfaces.
|
|
49
|
+
*/
|
|
50
|
+
export declare function addMember(deps: OrgServiceDeps, orgId: string, input: AddMemberInput): Promise<OrgMembershipRow>;
|
|
51
|
+
/**
|
|
52
|
+
* Remove a membership. Refuses to remove the org's sole remaining owner
|
|
53
|
+
* (would brick the org). Applies to admin-token callers too —
|
|
54
|
+
* `deleteOrg` is the path for tearing down an org entirely.
|
|
55
|
+
*/
|
|
56
|
+
export declare function removeMember(deps: OrgServiceDeps, orgId: string, userId: string): Promise<void>;
|
|
57
|
+
export interface CreateWorkspaceInput {
|
|
58
|
+
id?: string;
|
|
59
|
+
name?: string;
|
|
60
|
+
metadata?: Record<string, unknown>;
|
|
61
|
+
/** Whether to issue an initial API key. Default true. */
|
|
62
|
+
issueKey?: boolean;
|
|
63
|
+
/** Optional name applied to the initial API key. */
|
|
64
|
+
keyName?: string;
|
|
65
|
+
}
|
|
66
|
+
export interface CreateWorkspaceResult {
|
|
67
|
+
workspace: Workspace & {
|
|
68
|
+
orgId: string;
|
|
69
|
+
};
|
|
70
|
+
/** Present unless `issueKey === false`. */
|
|
71
|
+
key?: IssuedKey;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Stand up a new workspace under an org. The workspace + org binding
|
|
75
|
+
* commit atomically because the KeyStore writes `org_id` in the same
|
|
76
|
+
* INSERT as the workspace row. Issuing the initial API key remains a
|
|
77
|
+
* follow-up call (idempotent — a retry of the workspace POST returns
|
|
78
|
+
* the existing row via ON CONFLICT DO NOTHING).
|
|
79
|
+
*
|
|
80
|
+
* Validates the workspace id shape up front so a bad id fails before
|
|
81
|
+
* touching the store. Returns the workspace with `orgId` stamped on so
|
|
82
|
+
* callers don't need a second lookup.
|
|
83
|
+
*/
|
|
84
|
+
export declare function createWorkspaceUnderOrg(deps: OrgServiceDeps, orgId: string, body: CreateWorkspaceInput): Promise<CreateWorkspaceResult>;
|
|
85
|
+
export declare function updateOrgWorkspace(deps: OrgServiceDeps, orgId: string, wsId: string, body: {
|
|
86
|
+
name?: string;
|
|
87
|
+
metadata?: Record<string, unknown>;
|
|
88
|
+
}): Promise<{
|
|
89
|
+
workspace: Workspace & {
|
|
90
|
+
orgId: string;
|
|
91
|
+
};
|
|
92
|
+
}>;
|
|
93
|
+
export declare function revokeWorkspaceKey(deps: OrgServiceDeps, wsId: string, keyId: string): Promise<void>;
|
|
94
|
+
/**
|
|
95
|
+
* Throw `workspace_not_in_org` (404) if the workspace's `org_id` is
|
|
96
|
+
* not `orgId`. Used by both the admin-token and cookie-auth surfaces
|
|
97
|
+
* to scope per-workspace operations to the URL's org.
|
|
98
|
+
*/
|
|
99
|
+
export declare function requireWorkspaceInOrg(deps: OrgServiceDeps, orgId: string, wsId: string): Promise<void>;
|
|
@@ -0,0 +1,163 @@
|
|
|
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 (createWorkspaceUnderOrg writes
|
|
7
|
+
* the workspace + org binding atomically and then issues a key;
|
|
8
|
+
* email→userId lookup + upsertMember)
|
|
9
|
+
* - the safety invariants (workspace id validation, last-owner
|
|
10
|
+
* protection on member removal)
|
|
11
|
+
* - the canonical error → status mapping (OrgServiceError.status)
|
|
12
|
+
*
|
|
13
|
+
* Functions throw {@link OrgServiceError} for caller errors; route
|
|
14
|
+
* handlers catch it and translate to JSON. Storage errors that aren't
|
|
15
|
+
* known caller faults propagate.
|
|
16
|
+
*/
|
|
17
|
+
import { isValidWorkspaceId, } from "../key-store";
|
|
18
|
+
/** Caller-fault errors with a stable code → status mapping. */
|
|
19
|
+
export class OrgServiceError extends Error {
|
|
20
|
+
code;
|
|
21
|
+
status;
|
|
22
|
+
constructor(code, status) {
|
|
23
|
+
super(code);
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.status = status;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// ---------- orgs ---------------------------------------------------
|
|
29
|
+
export async function getOrgOrThrow(deps, id) {
|
|
30
|
+
const org = await deps.orgStore.getOrg(id);
|
|
31
|
+
if (!org)
|
|
32
|
+
throw new OrgServiceError("org_not_found", 404);
|
|
33
|
+
return org;
|
|
34
|
+
}
|
|
35
|
+
export async function createOrg(deps, body) {
|
|
36
|
+
try {
|
|
37
|
+
return await deps.orgStore.createOrg({
|
|
38
|
+
...(body.id !== undefined ? { id: body.id } : {}),
|
|
39
|
+
...(body.name !== undefined ? { name: body.name } : {}),
|
|
40
|
+
...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
throw new OrgServiceError(e.message, 400);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export async function updateOrg(deps, id, body) {
|
|
48
|
+
try {
|
|
49
|
+
return await deps.orgStore.updateOrg(id, body);
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
if (e.message === "org_not_found") {
|
|
53
|
+
throw new OrgServiceError("org_not_found", 404);
|
|
54
|
+
}
|
|
55
|
+
throw e;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export async function deleteOrg(deps, id) {
|
|
59
|
+
await getOrgOrThrow(deps, id);
|
|
60
|
+
await deps.orgStore.deleteOrg(id);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Resolve an email or explicit userId to a membership row, upserting at
|
|
64
|
+
* the requested role. Used by both the admin and self-serve surfaces.
|
|
65
|
+
*/
|
|
66
|
+
export async function addMember(deps, orgId, input) {
|
|
67
|
+
let userId = input.userId;
|
|
68
|
+
if (!userId && input.email) {
|
|
69
|
+
const u = await deps.orgStore.findUserByEmail(input.email);
|
|
70
|
+
if (!u)
|
|
71
|
+
throw new OrgServiceError("user_not_found", 404);
|
|
72
|
+
userId = u.id;
|
|
73
|
+
}
|
|
74
|
+
if (!userId)
|
|
75
|
+
throw new OrgServiceError("userId_or_email_required", 400);
|
|
76
|
+
return await deps.orgStore.upsertMember({
|
|
77
|
+
orgId,
|
|
78
|
+
userId,
|
|
79
|
+
...(input.role !== undefined ? { role: input.role } : {}),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Remove a membership. Refuses to remove the org's sole remaining owner
|
|
84
|
+
* (would brick the org). Applies to admin-token callers too —
|
|
85
|
+
* `deleteOrg` is the path for tearing down an org entirely.
|
|
86
|
+
*/
|
|
87
|
+
export async function removeMember(deps, orgId, userId) {
|
|
88
|
+
const members = await deps.orgStore.listMembers(orgId);
|
|
89
|
+
const owners = members.filter((m) => m.role === "owner");
|
|
90
|
+
if (owners.length === 1 && owners[0].userId === userId) {
|
|
91
|
+
throw new OrgServiceError("cannot_remove_last_owner", 400);
|
|
92
|
+
}
|
|
93
|
+
await deps.orgStore.removeMember(orgId, userId);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Stand up a new workspace under an org. The workspace + org binding
|
|
97
|
+
* commit atomically because the KeyStore writes `org_id` in the same
|
|
98
|
+
* INSERT as the workspace row. Issuing the initial API key remains a
|
|
99
|
+
* follow-up call (idempotent — a retry of the workspace POST returns
|
|
100
|
+
* the existing row via ON CONFLICT DO NOTHING).
|
|
101
|
+
*
|
|
102
|
+
* Validates the workspace id shape up front so a bad id fails before
|
|
103
|
+
* touching the store. Returns the workspace with `orgId` stamped on so
|
|
104
|
+
* callers don't need a second lookup.
|
|
105
|
+
*/
|
|
106
|
+
export async function createWorkspaceUnderOrg(deps, orgId, body) {
|
|
107
|
+
if (body.id !== undefined && !isValidWorkspaceId(body.id)) {
|
|
108
|
+
throw new OrgServiceError("invalid_workspace_id", 400);
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const ws = await deps.keyStore.createWorkspace({
|
|
112
|
+
...(body.id !== undefined ? { id: body.id } : {}),
|
|
113
|
+
...(body.name !== undefined ? { name: body.name } : {}),
|
|
114
|
+
...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
|
|
115
|
+
orgId,
|
|
116
|
+
});
|
|
117
|
+
if (body.issueKey === false) {
|
|
118
|
+
return { workspace: { ...ws, orgId } };
|
|
119
|
+
}
|
|
120
|
+
const key = await deps.keyStore.issueKey(ws.id, {
|
|
121
|
+
...(body.keyName !== undefined ? { name: body.keyName } : {}),
|
|
122
|
+
});
|
|
123
|
+
return { workspace: { ...ws, orgId }, key };
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
if (e instanceof OrgServiceError)
|
|
127
|
+
throw e;
|
|
128
|
+
throw new OrgServiceError(e.message, 400);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export async function updateOrgWorkspace(deps, orgId, wsId, body) {
|
|
132
|
+
try {
|
|
133
|
+
const ws = await deps.keyStore.updateWorkspace(wsId, body);
|
|
134
|
+
return { workspace: { ...ws, orgId } };
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
if (e.message === "workspace_not_found") {
|
|
138
|
+
throw new OrgServiceError("workspace_not_found", 404);
|
|
139
|
+
}
|
|
140
|
+
throw e;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
export async function revokeWorkspaceKey(deps, wsId, keyId) {
|
|
144
|
+
try {
|
|
145
|
+
await deps.keyStore.revokeKey(wsId, keyId);
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
if (e.message === "key_not_found") {
|
|
149
|
+
throw new OrgServiceError("key_not_found", 404);
|
|
150
|
+
}
|
|
151
|
+
throw e;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Throw `workspace_not_in_org` (404) if the workspace's `org_id` is
|
|
156
|
+
* not `orgId`. Used by both the admin-token and cookie-auth surfaces
|
|
157
|
+
* to scope per-workspace operations to the URL's org.
|
|
158
|
+
*/
|
|
159
|
+
export async function requireWorkspaceInOrg(deps, orgId, wsId) {
|
|
160
|
+
if (!(await deps.orgStore.workspaceInOrg(orgId, wsId))) {
|
|
161
|
+
throw new OrgServiceError("workspace_not_in_org", 404);
|
|
162
|
+
}
|
|
163
|
+
}
|
package/dist/storage.d.ts
CHANGED
|
@@ -147,5 +147,13 @@ export interface SessionRow {
|
|
|
147
147
|
model?: string;
|
|
148
148
|
trigger_conversation_id?: string;
|
|
149
149
|
trigger_event_id?: string;
|
|
150
|
+
trigger_purpose?: string;
|
|
151
|
+
trigger_resume_hint?: string;
|
|
150
152
|
}
|
|
151
153
|
export declare function foldEvents(row: SessionRow, events: SessionEvent[], now: Date): Session;
|
|
154
|
+
/**
|
|
155
|
+
* Allocate a fresh person id (`p_` + 8 alphanumeric chars). Cryptographically
|
|
156
|
+
* random via `crypto.getRandomValues`; shared between every adapter so id
|
|
157
|
+
* shape and entropy stay aligned.
|
|
158
|
+
*/
|
|
159
|
+
export declare function newPersonId(): string;
|