@hexis-ai/engram-server 0.1.7 → 0.3.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 +1 -1
- package/dist/adapters/memory-key-store.js +1 -1
- package/dist/adapters/postgres-key-store.d.ts +1 -1
- package/dist/adapters/postgres-key-store.js +2 -2
- package/dist/admin.js +11 -5
- package/dist/context.d.ts +26 -0
- package/dist/context.js +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/key-store.d.ts +4 -4
- package/dist/openapi.d.ts +8 -0
- package/dist/openapi.js +296 -0
- package/dist/routes/helpers.d.ts +22 -0
- package/dist/routes/helpers.js +32 -0
- package/dist/routes/persons.d.ts +13 -0
- package/dist/routes/persons.js +67 -0
- package/dist/routes/search.d.ts +9 -0
- package/dist/routes/search.js +40 -0
- package/dist/routes/sessions.d.ts +11 -0
- package/dist/routes/sessions.js +51 -0
- package/dist/schemas.d.ts +100 -0
- package/dist/schemas.js +108 -0
- package/dist/server.d.ts +7 -21
- package/dist/server.js +17 -156
- package/openapi.json +937 -0
- package/package.json +6 -4
|
@@ -20,5 +20,5 @@ export declare class InMemoryKeyStore implements KeyStore {
|
|
|
20
20
|
listKeys(workspaceId: string): Promise<ApiKeyInfo[]>;
|
|
21
21
|
revokeKey(workspaceId: string, keyId: string): Promise<void>;
|
|
22
22
|
resolveKey(rawKey: string): Promise<KeyResolution | null>;
|
|
23
|
-
|
|
23
|
+
registerKey(workspaceId: string, rawKey: string, name?: string): Promise<void>;
|
|
24
24
|
}
|
|
@@ -77,7 +77,7 @@ export class InMemoryKeyStore {
|
|
|
77
77
|
this.keys.set(id, { ...row, lastUsedAt: new Date().toISOString() });
|
|
78
78
|
return { workspaceId: row.workspaceId, keyId: row.id };
|
|
79
79
|
}
|
|
80
|
-
async
|
|
80
|
+
async registerKey(workspaceId, rawKey, name) {
|
|
81
81
|
if (!this.workspaces.has(workspaceId))
|
|
82
82
|
throw new Error("workspace_not_found");
|
|
83
83
|
const hash = hashKey(rawKey);
|
|
@@ -22,5 +22,5 @@ export declare class PostgresKeyStore implements KeyStore {
|
|
|
22
22
|
listKeys(workspaceId: string): Promise<ApiKeyInfo[]>;
|
|
23
23
|
revokeKey(workspaceId: string, keyId: string): Promise<void>;
|
|
24
24
|
resolveKey(rawKey: string): Promise<KeyResolution | null>;
|
|
25
|
-
|
|
25
|
+
registerKey(workspaceId: string, rawKey: string, name?: string): Promise<void>;
|
|
26
26
|
}
|
|
@@ -106,7 +106,7 @@ export class PostgresKeyStore {
|
|
|
106
106
|
`.catch(() => undefined);
|
|
107
107
|
return { workspaceId: row.workspace_id, keyId: row.id };
|
|
108
108
|
}
|
|
109
|
-
async
|
|
109
|
+
async registerKey(workspaceId, rawKey, name) {
|
|
110
110
|
const ws = await this.getWorkspace(workspaceId);
|
|
111
111
|
if (!ws)
|
|
112
112
|
throw new Error("workspace_not_found");
|
|
@@ -114,7 +114,7 @@ export class PostgresKeyStore {
|
|
|
114
114
|
const id = crypto.randomUUID();
|
|
115
115
|
await this.sql `
|
|
116
116
|
INSERT INTO engram_api_keys (id, workspace_id, key_hash, prefix, name)
|
|
117
|
-
VALUES (${id}, ${workspaceId}, ${hash}, ${keyPrefix(rawKey)}, ${name ??
|
|
117
|
+
VALUES (${id}, ${workspaceId}, ${hash}, ${keyPrefix(rawKey)}, ${name ?? null})
|
|
118
118
|
ON CONFLICT (key_hash) DO NOTHING
|
|
119
119
|
`;
|
|
120
120
|
}
|
package/dist/admin.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { isValidWorkspaceId } from "./key-store";
|
|
3
|
+
import { createWorkspaceSchema, issueKeySchema, parseJsonBody } from "./schemas";
|
|
3
4
|
/**
|
|
4
5
|
* Build the admin sub-router. Mount under `/admin/v1`.
|
|
5
6
|
*
|
|
@@ -18,9 +19,9 @@ export function createAdminRouter(opts) {
|
|
|
18
19
|
await next();
|
|
19
20
|
});
|
|
20
21
|
app.post("/workspaces", async (c) => {
|
|
21
|
-
const body =
|
|
22
|
-
if (body
|
|
23
|
-
return
|
|
22
|
+
const body = await parseJsonBody(c, createWorkspaceSchema);
|
|
23
|
+
if (body instanceof Response)
|
|
24
|
+
return body;
|
|
24
25
|
if (body.id !== undefined && !isValidWorkspaceId(body.id)) {
|
|
25
26
|
return c.json({ error: "invalid_workspace_id" }, 400);
|
|
26
27
|
}
|
|
@@ -67,9 +68,14 @@ export function createAdminRouter(opts) {
|
|
|
67
68
|
const ws = await opts.keyStore.getWorkspace(workspaceId);
|
|
68
69
|
if (!ws)
|
|
69
70
|
return c.json({ error: "workspace_not_found" }, 404);
|
|
70
|
-
|
|
71
|
+
// The body is optional here — an empty POST issues a key with no name.
|
|
72
|
+
const raw = await c.req.json().catch(() => ({}));
|
|
73
|
+
const parsed = issueKeySchema.safeParse(raw);
|
|
74
|
+
if (!parsed.success) {
|
|
75
|
+
return c.json({ error: "invalid_body", issues: parsed.error.issues }, 400);
|
|
76
|
+
}
|
|
71
77
|
const key = await opts.keyStore.issueKey(workspaceId, {
|
|
72
|
-
...(
|
|
78
|
+
...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}),
|
|
73
79
|
});
|
|
74
80
|
return c.json(key);
|
|
75
81
|
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { StorageAdapter } from "./storage";
|
|
2
|
+
/**
|
|
3
|
+
* The per-request workspace context. Produced by an `AuthResolver` from a
|
|
4
|
+
* raw API key and stashed on the Hono context so route handlers can reach
|
|
5
|
+
* the tenant-scoped storage adapter without re-resolving the key.
|
|
6
|
+
*/
|
|
7
|
+
export interface WorkspaceContext {
|
|
8
|
+
workspaceId: string;
|
|
9
|
+
/** The storage adapter scoped to this workspace. */
|
|
10
|
+
storage: StorageAdapter;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Resolve an API key (raw `Authorization: Bearer <key>` or `X-Api-Key`
|
|
14
|
+
* token) into a workspace context. Throwing or returning null short-circuits
|
|
15
|
+
* the request to 401.
|
|
16
|
+
*/
|
|
17
|
+
export interface AuthResolver {
|
|
18
|
+
(apiKey: string): Promise<WorkspaceContext | null> | WorkspaceContext | null;
|
|
19
|
+
}
|
|
20
|
+
/** Hono environment shared by `createServer` and every `/v1` route module. */
|
|
21
|
+
export interface Env {
|
|
22
|
+
Variables: {
|
|
23
|
+
ctx: WorkspaceContext;
|
|
24
|
+
request_id: string;
|
|
25
|
+
};
|
|
26
|
+
}
|
package/dist/context.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { createServer, type CreateServerOptions
|
|
1
|
+
export { createServer, type CreateServerOptions } from "./server";
|
|
2
|
+
export { type AuthResolver, type WorkspaceContext } from "./context";
|
|
2
3
|
export { foldEvents, type StorageAdapter, type SessionRow, } from "./storage";
|
|
3
4
|
export { InMemoryAdapter } from "./adapters/memory";
|
|
4
5
|
export { PostgresAdapter, type PostgresAdapterOptions, type SqlClient, } from "./adapters/postgres";
|
|
@@ -6,3 +7,4 @@ export { type KeyStore, type Workspace, type ApiKeyInfo, type IssuedKey, type Ke
|
|
|
6
7
|
export { InMemoryKeyStore } from "./adapters/memory-key-store";
|
|
7
8
|
export { PostgresKeyStore } from "./adapters/postgres-key-store";
|
|
8
9
|
export { createAdminRouter, type AdminOptions } from "./admin";
|
|
10
|
+
export { buildOpenApiDocument } from "./openapi";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { createServer
|
|
1
|
+
export { createServer } from "./server";
|
|
2
|
+
export {} from "./context";
|
|
2
3
|
export { foldEvents, } from "./storage";
|
|
3
4
|
export { InMemoryAdapter } from "./adapters/memory";
|
|
4
5
|
export { PostgresAdapter, } from "./adapters/postgres";
|
|
@@ -6,3 +7,4 @@ export { generateRawKey, hashKey, keyPrefix, isValidWorkspaceId, } from "./key-s
|
|
|
6
7
|
export { InMemoryKeyStore } from "./adapters/memory-key-store";
|
|
7
8
|
export { PostgresKeyStore } from "./adapters/postgres-key-store";
|
|
8
9
|
export { createAdminRouter } from "./admin";
|
|
10
|
+
export { buildOpenApiDocument } from "./openapi";
|
package/dist/key-store.d.ts
CHANGED
|
@@ -50,11 +50,11 @@ export interface KeyStore {
|
|
|
50
50
|
*/
|
|
51
51
|
resolveKey(rawKey: string): Promise<KeyResolution | null>;
|
|
52
52
|
/**
|
|
53
|
-
* Register a
|
|
54
|
-
*
|
|
55
|
-
*
|
|
53
|
+
* Register a caller-supplied raw key under a workspace, instead of minting
|
|
54
|
+
* a random one via `issueKey`. Used by the dev entrypoint to seed a known,
|
|
55
|
+
* fixed key. Idempotent on (workspace_id, key_hash).
|
|
56
56
|
*/
|
|
57
|
-
|
|
57
|
+
registerKey(workspaceId: string, rawKey: string, name?: string): Promise<void>;
|
|
58
58
|
}
|
|
59
59
|
export declare const KEY_PREFIX = "eng_";
|
|
60
60
|
export declare function generateRawKey(): string;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
type Json = Record<string, unknown>;
|
|
2
|
+
/**
|
|
3
|
+
* Build the OpenAPI 3.1 document. Pure and deterministic — `gen-openapi.ts`
|
|
4
|
+
* writes its output to `openapi.json`, and `openapi.test.ts` asserts the
|
|
5
|
+
* committed file still matches.
|
|
6
|
+
*/
|
|
7
|
+
export declare function buildOpenApiDocument(): Json;
|
|
8
|
+
export {};
|
package/dist/openapi.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI 3.1 document for the engram HTTP API, assembled from the same Zod
|
|
3
|
+
* schemas the server validates with (`src/schemas.ts`). The request-body
|
|
4
|
+
* contract therefore cannot drift from what the server actually accepts —
|
|
5
|
+
* a sync test (`test/openapi.test.ts`) fails the build if it does.
|
|
6
|
+
*
|
|
7
|
+
* This is the language-neutral contract artifact. Non-TypeScript engram SDK
|
|
8
|
+
* ports generate their wire types from `packages/server/openapi.json`
|
|
9
|
+
* (produced by `bun run gen:openapi`) instead of hand-mirroring the
|
|
10
|
+
* TypeScript interfaces in `@hexis-ai/engram-sdk`.
|
|
11
|
+
*
|
|
12
|
+
* `info.version` is the API version (the `/v1` surface), not the npm package
|
|
13
|
+
* version — it changes only when the wire contract changes.
|
|
14
|
+
*/
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import { createWorkspaceSchema, eventBatchSchema, issueKeySchema, personCreateSchema, personUpdateSchema, searchRequestSchema, sessionInitSchema, } from "./schemas";
|
|
17
|
+
/** Convert a Zod schema to a JSON Schema object for an OpenAPI component. */
|
|
18
|
+
function toComponent(schema) {
|
|
19
|
+
const js = z.toJSONSchema(schema);
|
|
20
|
+
delete js.$schema;
|
|
21
|
+
return js;
|
|
22
|
+
}
|
|
23
|
+
const ref = (name) => ({ $ref: `#/components/schemas/${name}` });
|
|
24
|
+
const jsonBody = (schemaName, required = true) => ({
|
|
25
|
+
required,
|
|
26
|
+
content: { "application/json": { schema: ref(schemaName) } },
|
|
27
|
+
});
|
|
28
|
+
const pathParam = (name, description) => ({
|
|
29
|
+
name,
|
|
30
|
+
in: "path",
|
|
31
|
+
required: true,
|
|
32
|
+
schema: { type: "string" },
|
|
33
|
+
description,
|
|
34
|
+
});
|
|
35
|
+
const queryParam = (name, description, schema = { type: "string" }) => ({ name, in: "query", required: false, schema, description });
|
|
36
|
+
const res = (description) => ({ description });
|
|
37
|
+
/** Default security: a workspace API key. `/admin/v1` paths override this. */
|
|
38
|
+
const workspaceAuth = [{ workspaceKey: [] }];
|
|
39
|
+
const adminAuth = [{ adminToken: [] }];
|
|
40
|
+
const limitParam = queryParam("limit", "Page size; clamped server-side.", {
|
|
41
|
+
type: "integer",
|
|
42
|
+
minimum: 1,
|
|
43
|
+
});
|
|
44
|
+
const channelParam = queryParam("channel", "Filter by session channel.");
|
|
45
|
+
function buildPaths() {
|
|
46
|
+
return {
|
|
47
|
+
"/v1/me": {
|
|
48
|
+
get: {
|
|
49
|
+
summary: "Identity probe — the workspace the caller's key resolves to.",
|
|
50
|
+
responses: { "200": res("workspace id"), "401": res("unauthorized") },
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
"/v1/sessions": {
|
|
54
|
+
post: {
|
|
55
|
+
summary: "Create a session.",
|
|
56
|
+
requestBody: jsonBody("SessionInit"),
|
|
57
|
+
responses: {
|
|
58
|
+
"200": res("created session id"),
|
|
59
|
+
"400": res("invalid body"),
|
|
60
|
+
"401": res("unauthorized"),
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
get: {
|
|
64
|
+
summary: "List recent sessions plus their persons map.",
|
|
65
|
+
parameters: [limitParam, channelParam],
|
|
66
|
+
responses: { "200": res("session list envelope"), "401": res("unauthorized") },
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
"/v1/sessions/{id}": {
|
|
70
|
+
get: {
|
|
71
|
+
summary: "Fetch one session plus its persons map.",
|
|
72
|
+
parameters: [pathParam("id", "Session id.")],
|
|
73
|
+
responses: {
|
|
74
|
+
"200": res("session envelope"),
|
|
75
|
+
"404": res("session not found"),
|
|
76
|
+
"401": res("unauthorized"),
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
"/v1/sessions/{id}/events": {
|
|
81
|
+
post: {
|
|
82
|
+
summary: "Append events to a session.",
|
|
83
|
+
parameters: [pathParam("id", "Session id.")],
|
|
84
|
+
requestBody: jsonBody("EventBatch"),
|
|
85
|
+
responses: {
|
|
86
|
+
"204": res("appended"),
|
|
87
|
+
"400": res("invalid body"),
|
|
88
|
+
"404": res("session not found"),
|
|
89
|
+
"401": res("unauthorized"),
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
"/v1/search": {
|
|
94
|
+
post: {
|
|
95
|
+
summary: "Score the workspace corpus against a query session.",
|
|
96
|
+
requestBody: jsonBody("SearchRequest"),
|
|
97
|
+
responses: {
|
|
98
|
+
"200": res("scored results plus persons map"),
|
|
99
|
+
"400": res("invalid body"),
|
|
100
|
+
"404": res("query session not found"),
|
|
101
|
+
"401": res("unauthorized"),
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
"/v1/persons": {
|
|
106
|
+
post: {
|
|
107
|
+
summary: "Create a person (server allocates the id).",
|
|
108
|
+
requestBody: jsonBody("PersonCreate"),
|
|
109
|
+
responses: {
|
|
110
|
+
"201": res("created person"),
|
|
111
|
+
"400": res("invalid body"),
|
|
112
|
+
"401": res("unauthorized"),
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
get: {
|
|
116
|
+
summary: "List or free-text search persons.",
|
|
117
|
+
parameters: [limitParam, queryParam("q", "Free-text query over id + display_name.")],
|
|
118
|
+
responses: { "200": res("person list"), "401": res("unauthorized") },
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
"/v1/persons/{id}": {
|
|
122
|
+
put: {
|
|
123
|
+
summary: "Upsert a person at a caller-supplied id.",
|
|
124
|
+
parameters: [pathParam("id", "Person id.")],
|
|
125
|
+
requestBody: jsonBody("PersonCreate"),
|
|
126
|
+
responses: {
|
|
127
|
+
"200": res("upserted person"),
|
|
128
|
+
"400": res("invalid body"),
|
|
129
|
+
"401": res("unauthorized"),
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
patch: {
|
|
133
|
+
summary: "Patch a person's profile fields.",
|
|
134
|
+
parameters: [pathParam("id", "Person id.")],
|
|
135
|
+
requestBody: jsonBody("PersonUpdate"),
|
|
136
|
+
responses: {
|
|
137
|
+
"200": res("updated person"),
|
|
138
|
+
"400": res("invalid body"),
|
|
139
|
+
"404": res("person not found"),
|
|
140
|
+
"401": res("unauthorized"),
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
get: {
|
|
144
|
+
summary: "Fetch one person.",
|
|
145
|
+
parameters: [pathParam("id", "Person id.")],
|
|
146
|
+
responses: {
|
|
147
|
+
"200": res("person"),
|
|
148
|
+
"404": res("person not found"),
|
|
149
|
+
"401": res("unauthorized"),
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
"/v1/persons/{id}/sessions": {
|
|
154
|
+
get: {
|
|
155
|
+
summary: "Sessions this person participates in (or can view).",
|
|
156
|
+
parameters: [
|
|
157
|
+
pathParam("id", "Person id."),
|
|
158
|
+
limitParam,
|
|
159
|
+
channelParam,
|
|
160
|
+
queryParam("scope", "`participant` (default) or `viewable`.", {
|
|
161
|
+
type: "string",
|
|
162
|
+
enum: ["participant", "viewable"],
|
|
163
|
+
}),
|
|
164
|
+
],
|
|
165
|
+
responses: { "200": res("session list envelope"), "401": res("unauthorized") },
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
"/admin/v1/workspaces": {
|
|
169
|
+
post: {
|
|
170
|
+
summary: "Create a workspace (and, by default, an initial key).",
|
|
171
|
+
security: adminAuth,
|
|
172
|
+
requestBody: jsonBody("CreateWorkspace"),
|
|
173
|
+
responses: {
|
|
174
|
+
"200": res("workspace, plus key unless issueKey=false"),
|
|
175
|
+
"400": res("invalid body or workspace id"),
|
|
176
|
+
"401": res("unauthorized"),
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
get: {
|
|
180
|
+
summary: "List all workspaces.",
|
|
181
|
+
security: adminAuth,
|
|
182
|
+
responses: { "200": res("workspace list"), "401": res("unauthorized") },
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
"/admin/v1/workspaces/{id}": {
|
|
186
|
+
get: {
|
|
187
|
+
summary: "Fetch one workspace.",
|
|
188
|
+
security: adminAuth,
|
|
189
|
+
parameters: [pathParam("id", "Workspace id.")],
|
|
190
|
+
responses: {
|
|
191
|
+
"200": res("workspace"),
|
|
192
|
+
"404": res("workspace not found"),
|
|
193
|
+
"401": res("unauthorized"),
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
delete: {
|
|
197
|
+
summary: "Delete a workspace (cascades to keys, sessions, events).",
|
|
198
|
+
security: adminAuth,
|
|
199
|
+
parameters: [pathParam("id", "Workspace id.")],
|
|
200
|
+
responses: {
|
|
201
|
+
"204": res("deleted"),
|
|
202
|
+
"404": res("workspace not found"),
|
|
203
|
+
"401": res("unauthorized"),
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
"/admin/v1/workspaces/{id}/keys": {
|
|
208
|
+
post: {
|
|
209
|
+
summary: "Issue a new API key for a workspace.",
|
|
210
|
+
security: adminAuth,
|
|
211
|
+
parameters: [pathParam("id", "Workspace id.")],
|
|
212
|
+
requestBody: jsonBody("IssueKey", false),
|
|
213
|
+
responses: {
|
|
214
|
+
"200": res("issued key (raw key returned once)"),
|
|
215
|
+
"400": res("invalid body"),
|
|
216
|
+
"404": res("workspace not found"),
|
|
217
|
+
"401": res("unauthorized"),
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
get: {
|
|
221
|
+
summary: "List a workspace's API keys (hashes only).",
|
|
222
|
+
security: adminAuth,
|
|
223
|
+
parameters: [pathParam("id", "Workspace id.")],
|
|
224
|
+
responses: {
|
|
225
|
+
"200": res("key list"),
|
|
226
|
+
"404": res("workspace not found"),
|
|
227
|
+
"401": res("unauthorized"),
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
"/admin/v1/workspaces/{id}/keys/{keyId}": {
|
|
232
|
+
delete: {
|
|
233
|
+
summary: "Revoke an API key.",
|
|
234
|
+
security: adminAuth,
|
|
235
|
+
parameters: [
|
|
236
|
+
pathParam("id", "Workspace id."),
|
|
237
|
+
pathParam("keyId", "Key id."),
|
|
238
|
+
],
|
|
239
|
+
responses: {
|
|
240
|
+
"204": res("revoked (idempotent)"),
|
|
241
|
+
"404": res("key not found"),
|
|
242
|
+
"401": res("unauthorized"),
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Build the OpenAPI 3.1 document. Pure and deterministic — `gen-openapi.ts`
|
|
250
|
+
* writes its output to `openapi.json`, and `openapi.test.ts` asserts the
|
|
251
|
+
* committed file still matches.
|
|
252
|
+
*/
|
|
253
|
+
export function buildOpenApiDocument() {
|
|
254
|
+
return {
|
|
255
|
+
openapi: "3.1.0",
|
|
256
|
+
info: {
|
|
257
|
+
title: "engram-server",
|
|
258
|
+
version: "1.0",
|
|
259
|
+
description: "Cross-session retrieval for AI agents. Request bodies are derived " +
|
|
260
|
+
"from the server's Zod schemas (src/schemas.ts); response schemas " +
|
|
261
|
+
"are described by status only for now and are a planned follow-up.",
|
|
262
|
+
},
|
|
263
|
+
servers: [
|
|
264
|
+
{ url: "/", description: "relative to the deployed engram-server" },
|
|
265
|
+
],
|
|
266
|
+
security: workspaceAuth,
|
|
267
|
+
components: {
|
|
268
|
+
securitySchemes: {
|
|
269
|
+
workspaceKey: {
|
|
270
|
+
type: "http",
|
|
271
|
+
scheme: "bearer",
|
|
272
|
+
description: "Workspace API key (`eng_…`). Also accepted as the `X-Api-Key` header.",
|
|
273
|
+
},
|
|
274
|
+
adminToken: {
|
|
275
|
+
type: "apiKey",
|
|
276
|
+
in: "header",
|
|
277
|
+
name: "X-Admin-Token",
|
|
278
|
+
description: "Platform admin token for `/admin/v1/*`. Also accepted as `Authorization: Bearer`.",
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
schemas: {
|
|
282
|
+
SessionInit: toComponent(sessionInitSchema),
|
|
283
|
+
// `EventBatch` inlines the per-event shape. A standalone `SessionEvent`
|
|
284
|
+
// component (cross-`$ref`'d) is a planned follow-up alongside response
|
|
285
|
+
// schemas — zod's `toJSONSchema` inlines by default.
|
|
286
|
+
EventBatch: toComponent(eventBatchSchema),
|
|
287
|
+
PersonCreate: toComponent(personCreateSchema),
|
|
288
|
+
PersonUpdate: toComponent(personUpdateSchema),
|
|
289
|
+
SearchRequest: toComponent(searchRequestSchema),
|
|
290
|
+
CreateWorkspace: toComponent(createWorkspaceSchema),
|
|
291
|
+
IssueKey: toComponent(issueKeySchema),
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
paths: buildPaths(),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import type { Session } from "@hexis-ai/engram-core";
|
|
3
|
+
import type { PersonMap } from "@hexis-ai/engram-sdk";
|
|
4
|
+
import type { StorageAdapter } from "../storage";
|
|
5
|
+
/** Resolved server config threaded into each `/v1` route module. */
|
|
6
|
+
export interface RouteConfig {
|
|
7
|
+
/** Generates session ids when the client doesn't supply one. */
|
|
8
|
+
newSessionId: () => string;
|
|
9
|
+
/** Default `?limit=` when the client omits it. */
|
|
10
|
+
defaultListLimit: number;
|
|
11
|
+
/** Hard cap on `?limit=` (and the search corpus size). */
|
|
12
|
+
maxListLimit: number;
|
|
13
|
+
}
|
|
14
|
+
/** Parse and clamp a `?limit=` query param into `[1, max]`, falling back to `def`. */
|
|
15
|
+
export declare function clampLimit(c: Context, def: number, max: number): number;
|
|
16
|
+
/**
|
|
17
|
+
* Build a deduped person_id → PersonInfo map covering every person id
|
|
18
|
+
* referenced by `sessions` (participants ∪ viewable_by). Returns {} if
|
|
19
|
+
* there are no session rows. Persons that exist as ids in sessions but
|
|
20
|
+
* have no engram_persons row are simply absent from the map.
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolvePersonMap(storage: StorageAdapter, sessions: Pick<Session, "participants" | "viewable_by">[]): Promise<PersonMap>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Parse and clamp a `?limit=` query param into `[1, max]`, falling back to `def`. */
|
|
2
|
+
export function clampLimit(c, def, max) {
|
|
3
|
+
const raw = c.req.query("limit");
|
|
4
|
+
if (!raw)
|
|
5
|
+
return def;
|
|
6
|
+
const n = parseInt(raw, 10);
|
|
7
|
+
if (isNaN(n) || n < 1)
|
|
8
|
+
return def;
|
|
9
|
+
return Math.min(n, max);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Build a deduped person_id → PersonInfo map covering every person id
|
|
13
|
+
* referenced by `sessions` (participants ∪ viewable_by). Returns {} if
|
|
14
|
+
* there are no session rows. Persons that exist as ids in sessions but
|
|
15
|
+
* have no engram_persons row are simply absent from the map.
|
|
16
|
+
*/
|
|
17
|
+
export async function resolvePersonMap(storage, sessions) {
|
|
18
|
+
const ids = new Set();
|
|
19
|
+
for (const s of sessions) {
|
|
20
|
+
for (const id of s.participants ?? [])
|
|
21
|
+
ids.add(id);
|
|
22
|
+
for (const id of s.viewable_by ?? [])
|
|
23
|
+
ids.add(id);
|
|
24
|
+
}
|
|
25
|
+
if (ids.size === 0)
|
|
26
|
+
return {};
|
|
27
|
+
const list = await storage.getPersons([...ids]);
|
|
28
|
+
const map = {};
|
|
29
|
+
for (const p of list)
|
|
30
|
+
map[p.id] = p;
|
|
31
|
+
return map;
|
|
32
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { Env } from "../context";
|
|
3
|
+
import { type RouteConfig } from "./helpers";
|
|
4
|
+
/**
|
|
5
|
+
* Person routes. Mount under `/v1`:
|
|
6
|
+
* POST /v1/persons create a person (server allocates the id)
|
|
7
|
+
* PUT /v1/persons/:id upsert at a host-supplied id
|
|
8
|
+
* PATCH /v1/persons/:id patch profile fields
|
|
9
|
+
* GET /v1/persons/:id fetch one person
|
|
10
|
+
* GET /v1/persons list / free-text search persons
|
|
11
|
+
* GET /v1/persons/:id/sessions sessions this person participates in / can view
|
|
12
|
+
*/
|
|
13
|
+
export declare function personsRoutes(cfg: RouteConfig): Hono<Env>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { parseJsonBody, personCreateSchema, personUpdateSchema } from "../schemas";
|
|
3
|
+
import { clampLimit, resolvePersonMap } from "./helpers";
|
|
4
|
+
/**
|
|
5
|
+
* Person routes. Mount under `/v1`:
|
|
6
|
+
* POST /v1/persons create a person (server allocates the id)
|
|
7
|
+
* PUT /v1/persons/:id upsert at a host-supplied id
|
|
8
|
+
* PATCH /v1/persons/:id patch profile fields
|
|
9
|
+
* GET /v1/persons/:id fetch one person
|
|
10
|
+
* GET /v1/persons list / free-text search persons
|
|
11
|
+
* GET /v1/persons/:id/sessions sessions this person participates in / can view
|
|
12
|
+
*/
|
|
13
|
+
export function personsRoutes(cfg) {
|
|
14
|
+
const app = new Hono();
|
|
15
|
+
app.post("/persons", async (c) => {
|
|
16
|
+
const body = await parseJsonBody(c, personCreateSchema);
|
|
17
|
+
if (body instanceof Response)
|
|
18
|
+
return body;
|
|
19
|
+
const p = await c.var.ctx.storage.createPerson(body);
|
|
20
|
+
return c.json(p, 201);
|
|
21
|
+
});
|
|
22
|
+
app.put("/persons/:id", async (c) => {
|
|
23
|
+
const id = c.req.param("id");
|
|
24
|
+
const body = await parseJsonBody(c, personCreateSchema);
|
|
25
|
+
if (body instanceof Response)
|
|
26
|
+
return body;
|
|
27
|
+
const p = await c.var.ctx.storage.upsertPerson(id, body);
|
|
28
|
+
return c.json(p);
|
|
29
|
+
});
|
|
30
|
+
app.patch("/persons/:id", async (c) => {
|
|
31
|
+
const id = c.req.param("id");
|
|
32
|
+
const body = await parseJsonBody(c, personUpdateSchema);
|
|
33
|
+
if (body instanceof Response)
|
|
34
|
+
return body;
|
|
35
|
+
const p = await c.var.ctx.storage.updatePerson(id, body);
|
|
36
|
+
if (!p)
|
|
37
|
+
return c.json({ error: "person_not_found" }, 404);
|
|
38
|
+
return c.json(p);
|
|
39
|
+
});
|
|
40
|
+
app.get("/persons/:id", async (c) => {
|
|
41
|
+
const id = c.req.param("id");
|
|
42
|
+
const p = await c.var.ctx.storage.getPerson(id);
|
|
43
|
+
if (!p)
|
|
44
|
+
return c.json({ error: "person_not_found" }, 404);
|
|
45
|
+
return c.json(p);
|
|
46
|
+
});
|
|
47
|
+
app.get("/persons", async (c) => {
|
|
48
|
+
const limit = clampLimit(c, cfg.defaultListLimit, cfg.maxListLimit);
|
|
49
|
+
const q = c.req.query("q") || undefined;
|
|
50
|
+
const persons = await c.var.ctx.storage.listPersons({ limit, q });
|
|
51
|
+
return c.json({ persons });
|
|
52
|
+
});
|
|
53
|
+
app.get("/persons/:id/sessions", async (c) => {
|
|
54
|
+
const id = c.req.param("id");
|
|
55
|
+
const limit = clampLimit(c, cfg.defaultListLimit, cfg.maxListLimit);
|
|
56
|
+
const channel = c.req.query("channel") || undefined;
|
|
57
|
+
const scope = c.req.query("scope") === "viewable" ? "viewable" : "participant";
|
|
58
|
+
const sessions = await c.var.ctx.storage.sessionsForPerson(id, {
|
|
59
|
+
limit,
|
|
60
|
+
channel,
|
|
61
|
+
scope,
|
|
62
|
+
});
|
|
63
|
+
const persons = await resolvePersonMap(c.var.ctx.storage, sessions);
|
|
64
|
+
return c.json({ sessions, persons });
|
|
65
|
+
});
|
|
66
|
+
return app;
|
|
67
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { Env } from "../context";
|
|
3
|
+
import { type RouteConfig } from "./helpers";
|
|
4
|
+
/**
|
|
5
|
+
* Search route. Mount under `/v1`:
|
|
6
|
+
* POST /v1/search score the workspace corpus against a query session
|
|
7
|
+
* (an existing session id, or an inline session)
|
|
8
|
+
*/
|
|
9
|
+
export declare function searchRoutes(cfg: RouteConfig): Hono<Env>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { buildIndex, search } from "@hexis-ai/engram-core";
|
|
3
|
+
import { parseJsonBody, searchRequestSchema } from "../schemas";
|
|
4
|
+
import { resolvePersonMap } from "./helpers";
|
|
5
|
+
/**
|
|
6
|
+
* Search route. Mount under `/v1`:
|
|
7
|
+
* POST /v1/search score the workspace corpus against a query session
|
|
8
|
+
* (an existing session id, or an inline session)
|
|
9
|
+
*/
|
|
10
|
+
export function searchRoutes(cfg) {
|
|
11
|
+
const app = new Hono();
|
|
12
|
+
app.post("/search", async (c) => {
|
|
13
|
+
const body = await parseJsonBody(c, searchRequestSchema);
|
|
14
|
+
if (body instanceof Response)
|
|
15
|
+
return body;
|
|
16
|
+
const corpus = await c.var.ctx.storage.listSessions({
|
|
17
|
+
limit: cfg.maxListLimit,
|
|
18
|
+
});
|
|
19
|
+
let queryQuery;
|
|
20
|
+
if ("sessionId" in body.query) {
|
|
21
|
+
const q = await c.var.ctx.storage.getSession(body.query.sessionId);
|
|
22
|
+
if (!q)
|
|
23
|
+
return c.json({ error: "query_session_not_found" }, 404);
|
|
24
|
+
queryQuery = q;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
queryQuery = body.query.session;
|
|
28
|
+
}
|
|
29
|
+
const index = buildIndex(corpus);
|
|
30
|
+
const opts = (body.options ?? {});
|
|
31
|
+
const queryParticipants = opts.queryParticipants ?? queryQuery.participants ?? [];
|
|
32
|
+
const results = search(queryQuery.steps, index, {
|
|
33
|
+
...opts,
|
|
34
|
+
queryParticipants,
|
|
35
|
+
});
|
|
36
|
+
const persons = await resolvePersonMap(c.var.ctx.storage, results.map((r) => r.session));
|
|
37
|
+
return c.json({ results, persons });
|
|
38
|
+
});
|
|
39
|
+
return app;
|
|
40
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { Env } from "../context";
|
|
3
|
+
import { type RouteConfig } from "./helpers";
|
|
4
|
+
/**
|
|
5
|
+
* Session routes. Mount under `/v1`:
|
|
6
|
+
* POST /v1/sessions create a session
|
|
7
|
+
* POST /v1/sessions/:id/events append events
|
|
8
|
+
* GET /v1/sessions/:id fetch one session + its persons map
|
|
9
|
+
* GET /v1/sessions list recent sessions + persons map
|
|
10
|
+
*/
|
|
11
|
+
export declare function sessionsRoutes(cfg: RouteConfig): Hono<Env>;
|