@zenera/server 0.0.0-stage → 1.2.1

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/README.md CHANGED
@@ -1,3 +1,158 @@
1
- # Temporary Holding Version
1
+ <div align="center">
2
+ <img
3
+ src="https://raw.githubusercontent.com/zenera-ai/ZeneraNeo/main/docs/imgs/zenera-logo-with-media-queries.svg"
4
+ alt="Zenera"
5
+ width="24"
6
+ />
7
+ <div>Powered By Zenera</div>
8
+ <br>
9
+ <img
10
+ src="https://raw.githubusercontent.com/zenera-ai/ZeneraNeo/main/docs/imgs/banner-server-with-media-queries.svg"
11
+ alt="ZENERA SERVER"
12
+ width="918"
13
+ />
2
14
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
15
+ [![npm](https://img.shields.io/npm/v/@zenera/server.svg)](https://www.npmjs.com/package/@zenera/server)
16
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/zenera-ai/ZeneraNeo/blob/main/LICENSE)
17
+ [![Node](https://img.shields.io/badge/node-%E2%89%A524-brightgreen.svg)](https://nodejs.org)
18
+
19
+ [Quickstart](#quickstart) · [Secure setup](#secure-setup) · [Commands](#commands) · [Options](#common-options) · [HTTP API](#http-api)
20
+
21
+ </div>
22
+
23
+ # `@zenera/server`
24
+
25
+ Serve a ZeneraNeo project's chats over HTTP. Each user has private sessions and
26
+ memory; agent commands run in Podman sandboxes, or under bwrap in an agent container.
27
+ Requires Node.js 24+ and the [Zenera CLI](../cli/README.md).
28
+
29
+ ## Quickstart
30
+
31
+ From a configured project:
32
+
33
+ ```sh
34
+ zen server start --insecure --user ann
35
+ ```
36
+
37
+ Open the printed HTTP `/chat` link to list sessions, send messages, follow active
38
+ runs, cancel them, or delete a session. `--user` selects the development JWT's
39
+ subject, not a server-wide user. The default is `local`.
40
+
41
+ **Insecure mode is for local testing only:** anyone who can reach the server can
42
+ impersonate any user. API requests still need a JWT, but signatures are not checked.
43
+ The test page is available only in this mode. Its token stays in memory; after a
44
+ reload, reopen the printed link or enter a development token again.
45
+
46
+ To run the server in the project's agent image:
47
+
48
+ ```sh
49
+ zen server start --container --insecure --user ann
50
+ zen server start --container --insecure --user ann --verbose # full build logs
51
+ ```
52
+
53
+ Podman builds and caches the image. Default build output shows a spinner, elapsed
54
+ time, and the latest line; warnings remain visible. Use `--rebuild` to rebuild.
55
+ Press Ctrl+C to stop gracefully and save active runs.
56
+
57
+ ## Secure setup
58
+
59
+ A trusted caller, usually Zenera-app, authenticates users, checks project access,
60
+ and signs a short-lived JWT for each runtime request. The runtime holds **only
61
+ the public key**.
62
+
63
+ For a standalone caller, generate an Ed25519 pair outside the project directory:
64
+
65
+ ```sh
66
+ zen server keygen --out "$HOME/runtime-auth"
67
+ zen server start --jwt-public-key "$HOME/runtime-auth.public.pem"
68
+ ```
69
+
70
+ `keygen` creates `<prefix>.private.pem` (PKCS#8, owner-only `0600` permissions)
71
+ and `<prefix>.public.pem` (SPKI). The parent directory must already exist.
72
+ Existing files are never overwritten; a failed write removes newly created files.
73
+ Keep the private key with the trusted caller, out of source control and runtime
74
+ containers. Zenera-app already manages its own signing identity; it does not need
75
+ this command.
76
+
77
+ Generating keys does **not** sign requests. Secure callers send an
78
+ `Authorization: Bearer <jwt>` header containing an EdDSA-signed token with:
79
+
80
+ - `iss` / `aud`: defaults `zenera-app` / `zenera-runtime`.
81
+ - `sub`: the authenticated user; `prj`: this server's project ID.
82
+ - `req`: the exact `"<METHOD> <path-and-query>"` of the request.
83
+ - `iat` / `exp`: issue time and expiry; maximum age 2 minutes, 30 seconds clock tolerance.
84
+
85
+ Use HTTPS at the ingress or a protected internal connection. A token cannot
86
+ authorize a different endpoint, but can be replayed for the same request until
87
+ it expires. The server does not perform user login or project-membership checks.
88
+
89
+ ## Commands
90
+
91
+ | Command | Use |
92
+ | ---------------------------------- | --------------------------------------------------------------- |
93
+ | `zen server start` | Start a project's runtime. |
94
+ | `zen server check` | Check whether it can start in the current environment. |
95
+ | `zen server status` | Inspect a running runtime; use `--token <jwt>` for secure mode. |
96
+ | `zen server keygen --out <prefix>` | Generate private/public signing keys. |
97
+ | `zen server token --user <id>` | Print an **unsigned** development JWT for insecure mode. |
98
+
99
+ Test an API without the browser:
100
+
101
+ ```sh
102
+ TOKEN=$(zen server token --user ann)
103
+ curl http://127.0.0.1:3000/api/sessions \
104
+ -H "Authorization: Bearer $TOKEN"
105
+ ```
106
+
107
+ `token` also accepts `--email`, `--role`, `--name`, and `--thread`. It neither
108
+ creates an account nor signs a secure token.
109
+
110
+ ## Common options
111
+
112
+ | Option | Use / default |
113
+ | --------------------------------- | ---------------------------------------------------------------------- |
114
+ | `--project <name-or-dir>` | Select a project; otherwise use the current project. |
115
+ | `--port <n>` / `--host <addr>` | `3000` / `127.0.0.1`. Insecure mode is loopback-only. |
116
+ | `--root <dir>` | Persistent user state; `~/.zenera/neo/servers/<project>` on the host. |
117
+ | `--memory-seed <dir>` | Initial memory for new users; defaults to project memory. |
118
+ | `--no-memory-seed` | Start new users with empty memory. |
119
+ | `--jwt-public-key <file>` | Public PEM for secure mode; alternatively `ZEN_SERVER_JWT_PUBLIC_KEY`. |
120
+ | `--jwt-issuer` / `--jwt-audience` | Override the expected signer / audience. |
121
+ | `--container` | Run the server in the project image; commands use bwrap. |
122
+ | `--verbose` / `--rebuild` | Full container build logs / rebuild images. |
123
+ | `--debug` | Enable run, trace, and memory inspection routes. |
124
+
125
+ Run `zen server --help` for all options. Sessions and memory live under
126
+ `<root>/users/<user>/`; memory is private per user and shared across their sessions.
127
+ New users receive the memory seed captured at startup. Existing user memory is
128
+ never replaced by a restart.
129
+
130
+ Model credentials come from `zen key`, the environment, and the project's `.env`.
131
+ Container mode forwards provider settings, including Vertex and Bifrost, and
132
+ credential files. The gateway must be reachable from the container; host
133
+ `localhost` is not container `localhost`.
134
+ Set `sandbox.keys: false` to withhold credentials from agent-written commands,
135
+ while allowing the engine to use them for model calls.
136
+
137
+ ## HTTP API
138
+
139
+ Session routes operate only on the JWT subject's own sessions.
140
+
141
+ | Route | Use |
142
+ | ---------------------------------------------- | ---------------------------------------------- |
143
+ | `GET /healthz` | Public health probe. |
144
+ | `GET /chat` | Public test page, insecure mode only. |
145
+ | `GET /api/status` / `GET /api/activity` | Runtime status / active runs. |
146
+ | `GET /api/sessions` / `GET /api/sessions/:tid` | List sessions / load history. |
147
+ | `POST /api/sessions/:tid/runs` | Start a turn, streaming AG-UI events over SSE. |
148
+ | `GET /api/sessions/:tid/runs/:runId/events` | Follow or replay a run. |
149
+ | `POST /api/sessions/:tid/runs/:runId/cancel` | Cancel a run. |
150
+ | `DELETE /api/sessions/:tid` | Delete a session and its files. |
151
+
152
+ Runs continue after a client disconnects and can be followed again. Defaults:
153
+ 4 concurrent runs total, 2 per user (`ZEN_RUNTIME_MAX_RUNS`,
154
+ `ZEN_RUNTIME_MAX_USER_RUNS`).
155
+
156
+ For deployment and sandbox details, see [agent-image architecture](../../docs/agent-image.md).
157
+ To rebuild the bundled test page, run `npm run build:chat` from ZeneraLite's root;
158
+ it automatically copies the page into a sibling ZeneraNeo checkout when present.
package/dist/agui.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { type AGUIEvent, type Interrupt, type TokenUsage as AguiUsage, type RunAgentInput } from '@ag-ui/core';
2
+ import type { AgentEvent, AgentState, Payload, TokenUsage } from '@zenera/neo';
3
+ export declare const MAX_TOOL_OUTPUT = 8000;
4
+ export declare function clip(text: string, max?: number): string;
5
+ export type Resolve = (payload: Payload) => Promise<string>;
6
+ /**
7
+ * The calls a suspended run waits on, as AG-UI interrupts. The id is zenera-app's interrupt id
8
+ * when the tool got one (it resumes the run by it), else the call id; `toolCallId` ties it to the
9
+ * tool card; `metadata` is what the tool said about it (server, icon, ...), for that card.
10
+ */
11
+ export declare function interruptsOf(state: AgentState): Interrupt[];
12
+ export interface TranslatorOptions {
13
+ threadId: string;
14
+ runId: string;
15
+ resolve: Resolve;
16
+ newId?: () => string;
17
+ }
18
+ export declare class AguiTranslator {
19
+ #private;
20
+ constructor(opts: TranslatorOptions);
21
+ get done(): boolean;
22
+ /** The run's input goes with it: a replayed run then shows what it answered. */
23
+ started(input?: RunAgentInput): AGUIEvent[];
24
+ translate(ev: AgentEvent): Promise<AGUIEvent[]>;
25
+ /** The end of a run that stopped without a run_finished: an exception or a cancel. */
26
+ fail(err: unknown, code?: string): AGUIEvent[];
27
+ }
28
+ export declare function toAguiUsage(u: TokenUsage): AguiUsage;
29
+ //# sourceMappingURL=agui.d.ts.map
package/dist/agui.js ADDED
@@ -0,0 +1,333 @@
1
+ // AG-UI for a @zenera/neo run. neo streams bare deltas plus a checkpoint per model call, tool
2
+ // call, hand-off and branch; AG-UI wants every text, reasoning and tool-call stream opened and
3
+ // closed around them. One translator per run keeps that bookkeeping, per branch.
4
+ // AguiTranslator - neo AgentEvent -> AG-UI events, in order (await each call before the next)
5
+ // clip - the cap on tool output sent to a browser
6
+ // interruptsOf - what a suspended run waits for, as AG-UI interrupts (RUN_FINISHED outcome)
7
+ import { EventType, } from '@ag-ui/core';
8
+ import { randomUUID } from 'node:crypto';
9
+ export const MAX_TOOL_OUTPUT = 8000;
10
+ export function clip(text, max = MAX_TOOL_OUTPUT) {
11
+ return text.length > max
12
+ ? `${text.slice(0, max)}\n… ${text.length - max} more characters`
13
+ : text;
14
+ }
15
+ /**
16
+ * The calls a suspended run waits on, as AG-UI interrupts. The id is zenera-app's interrupt id
17
+ * when the tool got one (it resumes the run by it), else the call id; `toolCallId` ties it to the
18
+ * tool card; `metadata` is what the tool said about it (server, icon, ...), for that card.
19
+ */
20
+ export function interruptsOf(state) {
21
+ // `suspended` is newer than some @zenera/neo typings this compiles against.
22
+ const suspended = state.suspended ?? [];
23
+ return suspended.map((raw) => {
24
+ const s = raw;
25
+ const data = s.data && typeof s.data === 'object' ? s.data : undefined;
26
+ return {
27
+ id: typeof data?.interruptId === 'string' ? data.interruptId : s.callId,
28
+ reason: s.reason,
29
+ ...(s.message ? { message: s.message } : {}),
30
+ toolCallId: s.callId,
31
+ ...(s.responseSchema ? { responseSchema: s.responseSchema } : {}),
32
+ ...(data ? { metadata: data } : {}),
33
+ };
34
+ });
35
+ }
36
+ export class AguiTranslator {
37
+ #threadId;
38
+ #runId;
39
+ #resolve;
40
+ #newId;
41
+ #lanes = new Map();
42
+ // fork node id -> the tool call that asked for it
43
+ #forks = new Map();
44
+ #agent;
45
+ #done = false;
46
+ constructor(opts) {
47
+ this.#threadId = opts.threadId;
48
+ this.#runId = opts.runId;
49
+ this.#resolve = opts.resolve;
50
+ this.#newId = opts.newId ?? randomUUID;
51
+ }
52
+ get done() {
53
+ return this.#done;
54
+ }
55
+ /** The run's input goes with it: a replayed run then shows what it answered. */
56
+ started(input) {
57
+ return [
58
+ {
59
+ type: EventType.RUN_STARTED,
60
+ threadId: this.#threadId,
61
+ runId: this.#runId,
62
+ ...(input ? { input } : {}),
63
+ },
64
+ ];
65
+ }
66
+ async translate(ev) {
67
+ if (this.#done)
68
+ return [];
69
+ const out = [];
70
+ const lane = this.#lane(ev.branch?.runId);
71
+ const sub = lane.subagentRunId ? { subagentRunId: lane.subagentRunId } : {};
72
+ // A continued session sends no run_created, so the first trunk event opens the step.
73
+ if (!ev.branch && !this.#agent) {
74
+ this.#agent = ev.agent;
75
+ out.push({ type: EventType.STEP_STARTED, stepName: ev.agent });
76
+ }
77
+ switch (ev.type) {
78
+ case 'thinking_delta': {
79
+ if (!ev.delta)
80
+ break;
81
+ this.#endText(lane, out);
82
+ if (!lane.reasoning) {
83
+ lane.reasoning = this.#newId();
84
+ out.push({ type: EventType.REASONING_START, messageId: lane.reasoning, ...sub }, {
85
+ type: EventType.REASONING_MESSAGE_START,
86
+ messageId: lane.reasoning,
87
+ role: 'reasoning',
88
+ ...sub,
89
+ });
90
+ }
91
+ out.push({
92
+ type: EventType.REASONING_MESSAGE_CONTENT,
93
+ messageId: lane.reasoning,
94
+ delta: ev.delta,
95
+ ...sub,
96
+ });
97
+ break;
98
+ }
99
+ case 'text_delta':
100
+ if (!ev.delta)
101
+ break;
102
+ this.#endReasoning(lane, out);
103
+ this.#text(lane, ev.agent, ev.delta, out);
104
+ lane.streamedText = true;
105
+ break;
106
+ case 'tool_call_detected':
107
+ this.#endReasoning(lane, out);
108
+ this.#startTool(lane, ev.callId, ev.name, out);
109
+ break;
110
+ case 'tool_args_delta':
111
+ if (!lane.tools.has(ev.callId))
112
+ this.#startTool(lane, ev.callId, ev.name, out);
113
+ if (ev.delta) {
114
+ out.push({
115
+ type: EventType.TOOL_CALL_ARGS,
116
+ toolCallId: ev.callId,
117
+ delta: ev.delta,
118
+ ...sub,
119
+ });
120
+ }
121
+ break;
122
+ case 'before_llm_call':
123
+ lane.streamedText = false;
124
+ lane.tools.clear();
125
+ break;
126
+ case 'after_llm_call': {
127
+ this.#endReasoning(lane, out);
128
+ // A model that does not stream says everything here, at once.
129
+ if (!lane.streamedText) {
130
+ const text = await this.#resolve(ev.node.text);
131
+ if (text.trim())
132
+ this.#text(lane, ev.agent, text, out);
133
+ }
134
+ this.#endText(lane, out, { model: ev.node.model, usage: ev.node.usage });
135
+ for (const call of ev.node.toolCalls) {
136
+ if (!lane.tools.has(call.callId)) {
137
+ this.#startTool(lane, call.callId, call.name, out);
138
+ const args = await this.#resolve(call.args);
139
+ if (args) {
140
+ out.push({
141
+ type: EventType.TOOL_CALL_ARGS,
142
+ toolCallId: call.callId,
143
+ delta: args,
144
+ ...sub,
145
+ });
146
+ }
147
+ }
148
+ out.push({ type: EventType.TOOL_CALL_END, toolCallId: call.callId, ...sub });
149
+ }
150
+ lane.tools.clear();
151
+ break;
152
+ }
153
+ case 'after_tool_call': {
154
+ const content = clip(await this.#resolve(ev.node.result));
155
+ out.push({
156
+ type: EventType.TOOL_CALL_RESULT,
157
+ messageId: this.#newId(),
158
+ toolCallId: ev.node.callId,
159
+ content,
160
+ role: 'tool',
161
+ ...sub,
162
+ ...(ev.node.isError ? { metadata: { isError: true } } : {}),
163
+ });
164
+ break;
165
+ }
166
+ case 'handoff':
167
+ this.#endReasoning(lane, out);
168
+ this.#endText(lane, out);
169
+ out.push({ type: EventType.STEP_FINISHED, stepName: ev.from, ...sub }, { type: EventType.STEP_STARTED, stepName: ev.to, ...sub });
170
+ if (!ev.branch)
171
+ this.#agent = ev.to;
172
+ break;
173
+ case 'before_fork':
174
+ this.#forks.set(ev.node.id, ev.node.callId);
175
+ break;
176
+ case 'branch_started': {
177
+ const parentToolCallId = this.#forks.get(ev.child.forkId);
178
+ out.push({
179
+ type: EventType.SUBAGENT_STARTED,
180
+ subagentRunId: ev.child.runId,
181
+ name: ev.child.name,
182
+ description: ev.childState.agentName,
183
+ ...(lane.subagentRunId ? { parentSubagentRunId: lane.subagentRunId } : {}),
184
+ ...(parentToolCallId ? { parentToolCallId } : {}),
185
+ });
186
+ break;
187
+ }
188
+ case 'branch_finished': {
189
+ const child = this.#lane(ev.child.runId);
190
+ this.#endReasoning(child, out);
191
+ this.#endText(child, out);
192
+ this.#lanes.delete(ev.child.runId);
193
+ if (ev.status === 'ok') {
194
+ out.push({ type: EventType.SUBAGENT_FINISHED, subagentRunId: ev.child.runId });
195
+ }
196
+ else {
197
+ out.push({
198
+ type: EventType.SUBAGENT_ERROR,
199
+ subagentRunId: ev.child.runId,
200
+ message: ev.childState.error ?? `branch ${ev.child.name} ${ev.status}`,
201
+ code: ev.status,
202
+ });
203
+ }
204
+ break;
205
+ }
206
+ case 'run_finished':
207
+ // A branch's own end is reported by branch_finished on its parent.
208
+ if (!ev.branch)
209
+ out.push(...this.#finish(ev.result));
210
+ break;
211
+ default:
212
+ break;
213
+ }
214
+ return out;
215
+ }
216
+ /** The end of a run that stopped without a run_finished: an exception or a cancel. */
217
+ fail(err, code = 'error') {
218
+ if (this.#done)
219
+ return [];
220
+ const out = this.#closeAll();
221
+ this.#done = true;
222
+ if (this.#agent)
223
+ out.push({ type: EventType.STEP_FINISHED, stepName: this.#agent });
224
+ const message = err instanceof Error ? err.message : String(err);
225
+ out.push({ type: EventType.RUN_ERROR, message, code });
226
+ return out;
227
+ }
228
+ #finish(result) {
229
+ const out = this.#closeAll();
230
+ this.#done = true;
231
+ if (this.#agent)
232
+ out.push({ type: EventType.STEP_FINISHED, stepName: this.#agent });
233
+ const usage = [toAguiUsage(result.usage)];
234
+ if (result.stopReason === 'failed') {
235
+ out.push({
236
+ type: EventType.RUN_ERROR,
237
+ message: result.state.error ?? 'the run failed',
238
+ code: 'failed',
239
+ usage,
240
+ });
241
+ return out;
242
+ }
243
+ out.push({
244
+ type: EventType.RUN_FINISHED,
245
+ threadId: this.#threadId,
246
+ runId: this.#runId,
247
+ outcome: result.stopReason === 'aborted'
248
+ ? { type: 'cancelled' }
249
+ : // 'suspended' is newer than some @zenera/neo typings this compiles against.
250
+ result.stopReason === 'suspended'
251
+ ? { type: 'interrupt', interrupts: interruptsOf(result.state) }
252
+ : { type: 'success' },
253
+ result: { agent: result.agent },
254
+ usage,
255
+ });
256
+ return out;
257
+ }
258
+ #closeAll() {
259
+ const out = [];
260
+ for (const lane of this.#lanes.values()) {
261
+ this.#endReasoning(lane, out);
262
+ this.#endText(lane, out);
263
+ const sub = lane.subagentRunId ? { subagentRunId: lane.subagentRunId } : {};
264
+ for (const callId of lane.tools.keys()) {
265
+ out.push({ type: EventType.TOOL_CALL_END, toolCallId: callId, ...sub });
266
+ }
267
+ lane.tools.clear();
268
+ }
269
+ return out;
270
+ }
271
+ #lane(subagentRunId) {
272
+ const key = subagentRunId ?? '';
273
+ let lane = this.#lanes.get(key);
274
+ if (!lane) {
275
+ lane = { subagentRunId, streamedText: false, tools: new Map() };
276
+ this.#lanes.set(key, lane);
277
+ }
278
+ return lane;
279
+ }
280
+ #text(lane, agent, delta, out) {
281
+ const sub = lane.subagentRunId ? { subagentRunId: lane.subagentRunId } : {};
282
+ if (!lane.text) {
283
+ lane.text = this.#newId();
284
+ out.push({
285
+ type: EventType.TEXT_MESSAGE_START,
286
+ messageId: lane.text,
287
+ role: 'assistant',
288
+ name: agent,
289
+ ...sub,
290
+ });
291
+ }
292
+ out.push({ type: EventType.TEXT_MESSAGE_CONTENT, messageId: lane.text, delta, ...sub });
293
+ }
294
+ #endText(lane, out, call) {
295
+ if (!lane.text)
296
+ return;
297
+ const sub = lane.subagentRunId ? { subagentRunId: lane.subagentRunId } : {};
298
+ out.push({
299
+ type: EventType.TEXT_MESSAGE_END,
300
+ messageId: lane.text,
301
+ ...sub,
302
+ ...(call ? { metadata: { model: call.model, usage: toAguiUsage(call.usage) } } : {}),
303
+ });
304
+ lane.text = undefined;
305
+ }
306
+ #endReasoning(lane, out) {
307
+ if (!lane.reasoning)
308
+ return;
309
+ const sub = lane.subagentRunId ? { subagentRunId: lane.subagentRunId } : {};
310
+ out.push({ type: EventType.REASONING_MESSAGE_END, messageId: lane.reasoning, ...sub }, { type: EventType.REASONING_END, messageId: lane.reasoning, ...sub });
311
+ lane.reasoning = undefined;
312
+ }
313
+ #startTool(lane, callId, name, out) {
314
+ lane.tools.set(callId, { name });
315
+ out.push({
316
+ type: EventType.TOOL_CALL_START,
317
+ toolCallId: callId,
318
+ toolCallName: name,
319
+ ...(lane.text ? { parentMessageId: lane.text } : {}),
320
+ ...(lane.subagentRunId ? { subagentRunId: lane.subagentRunId } : {}),
321
+ });
322
+ }
323
+ }
324
+ export function toAguiUsage(u) {
325
+ return {
326
+ inputTokens: u.inputTokens,
327
+ outputTokens: u.outputTokens,
328
+ totalTokens: u.inputTokens + u.outputTokens,
329
+ reasoningTokens: u.reasoningTokens,
330
+ cachedInputTokens: u.cachedInputTokens,
331
+ };
332
+ }
333
+ //# sourceMappingURL=agui.js.map
package/dist/auth.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { NextFunction, Request, Response } from 'express';
2
+ import { type JWTPayload } from 'jose';
3
+ export interface CallerClaims extends JWTPayload {
4
+ sub: string;
5
+ prj?: string;
6
+ req?: string;
7
+ thr?: string;
8
+ role?: string;
9
+ email?: string;
10
+ name?: string;
11
+ }
12
+ export declare function verifyCaller(req: Request): Promise<CallerClaims>;
13
+ export declare function requireCaller(): (req: Request, res: Response, next: NextFunction) => Promise<void>;
14
+ /** A token an `insecure` server takes: the claims, no signature. */
15
+ export declare function unsignedToken(claims: Record<string, unknown>): string;
16
+ //# sourceMappingURL=auth.d.ts.map
package/dist/auth.js ADDED
@@ -0,0 +1,79 @@
1
+ import { decodeJwt, importSPKI, jwtVerify } from 'jose';
2
+ import { settings } from "./config.js";
3
+ import { HttpError } from "./http.js";
4
+ const OPEN = new Set(['/healthz']);
5
+ let key;
6
+ function publicKey(pem) {
7
+ if (key?.pem !== pem)
8
+ key = { pem, key: importSPKI(pem, 'EdDSA') };
9
+ return key.key;
10
+ }
11
+ function bearer(req) {
12
+ const header = req.get('authorization') ?? '';
13
+ if (!header.startsWith('Bearer '))
14
+ throw new HttpError(401, 'no caller token');
15
+ return header.slice('Bearer '.length).trim();
16
+ }
17
+ export async function verifyCaller(req) {
18
+ const s = settings();
19
+ const token = bearer(req);
20
+ let payload;
21
+ if (s.auth.mode === 'insecure') {
22
+ try {
23
+ payload = decodeJwt(token);
24
+ }
25
+ catch (err) {
26
+ throw new HttpError(401, `not a token: ${err instanceof Error ? err.message : err}`);
27
+ }
28
+ }
29
+ else {
30
+ let verifyKey;
31
+ try {
32
+ verifyKey = await publicKey(s.auth.publicKey);
33
+ }
34
+ catch (err) {
35
+ throw new HttpError(503, `the server's public key is not an Ed25519 key: ${err}`);
36
+ }
37
+ try {
38
+ ({ payload } = await jwtVerify(token, verifyKey, {
39
+ algorithms: ['EdDSA'],
40
+ issuer: s.auth.issuer,
41
+ audience: s.auth.audience,
42
+ requiredClaims: ['sub', 'iat', 'exp'],
43
+ clockTolerance: 30,
44
+ maxTokenAge: '2m',
45
+ }));
46
+ }
47
+ catch (err) {
48
+ throw new HttpError(401, `invalid caller token: ${err instanceof Error ? err.message : err}`);
49
+ }
50
+ if (payload.prj !== s.projectId) {
51
+ throw new HttpError(403, 'the token is for another project');
52
+ }
53
+ // Bound to one request: a token seen in transit is no good for any other.
54
+ if (payload.req !== `${req.method} ${req.originalUrl}`) {
55
+ throw new HttpError(401, 'the token is for another request');
56
+ }
57
+ }
58
+ if (typeof payload.sub !== 'string' || !payload.sub.trim()) {
59
+ throw new HttpError(401, 'the token names no user (sub)');
60
+ }
61
+ return payload;
62
+ }
63
+ export function requireCaller() {
64
+ return async (req, res, next) => {
65
+ if (OPEN.has(req.path)) {
66
+ next();
67
+ return;
68
+ }
69
+ res.locals.caller = await verifyCaller(req);
70
+ next();
71
+ };
72
+ }
73
+ /** A token an `insecure` server takes: the claims, no signature. */
74
+ export function unsignedToken(claims) {
75
+ const part = (v) => Buffer.from(JSON.stringify(v)).toString('base64url');
76
+ const now = Math.floor(Date.now() / 1000);
77
+ return `${part({ alg: 'none', typ: 'JWT' })}.${part({ iat: now, ...claims })}.`;
78
+ }
79
+ //# sourceMappingURL=auth.js.map