@orkestrel/toolbox 0.0.24 → 0.0.26

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.
@@ -1,8 +1,9 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _orkestrel_contract = require("@orkestrel/contract");
2
3
  let _orkestrel_terminal = require("@orkestrel/terminal");
3
4
  let _orkestrel_form = require("@orkestrel/form");
4
- let _orkestrel_contract = require("@orkestrel/contract");
5
5
  let _orkestrel_server = require("@orkestrel/server");
6
+ let _src_core = require("../core/index.cjs");
6
7
  // src/server/constants.ts
7
8
  /**
8
9
  * Holds the default `:name`-templated path {@link import('./factories.js').createTerminalRoutes}
@@ -15,6 +16,31 @@ var TERMINAL_ROUTES_PATH = "/terminals/:name";
15
16
  * keeping intermediary proxies from timing out an otherwise-idle stream.
16
17
  */
17
18
  var TERMINAL_KEEPALIVE_MS = 15e3;
19
+ // src/server/shapers.ts
20
+ /**
21
+ * Describes the body a terminal POST route admits — the pending form's `id` and its answers.
22
+ *
23
+ * @remarks
24
+ * The shape closes the envelope. `@orkestrel/form`'s `isFormValues` guard still owns what one answer
25
+ * holds, so this shape does not restate the field value union.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * import { createContract } from '@orkestrel/contract'
30
+ * import { terminalAnswerShape } from '@src/server'
31
+ *
32
+ * const contract = createContract(terminalAnswerShape)
33
+ * contract.is({ id: 'form-1', values: { name: 'Ada' } }) // true
34
+ * contract.audit({ id: 'form-1', values: {}, reply: true }) // [{ reason: 'extra', path: ['reply'] }]
35
+ * ```
36
+ */
37
+ var terminalAnswerShape = (0, _orkestrel_contract.objectShape)({
38
+ id: (0, _orkestrel_contract.stringShape)({
39
+ min: 1,
40
+ description: "The id of the pending form to answer."
41
+ }),
42
+ values: (0, _orkestrel_contract.recordShape)((0, _orkestrel_contract.jsonShape)(), { description: "The form answers keyed by field name." })
43
+ });
18
44
  // src/server/terminals/TerminalConnection.ts
19
45
  /**
20
46
  * Owns one terminal SSE connection's replay, subscriptions, keepalive, and teardown.
@@ -142,6 +168,7 @@ var TerminalBridge = class {
142
168
  #keepalive;
143
169
  #timer;
144
170
  #limit;
171
+ #answer;
145
172
  #accepts;
146
173
  #get;
147
174
  #post;
@@ -149,16 +176,22 @@ var TerminalBridge = class {
149
176
  * Creates a terminal bridge.
150
177
  *
151
178
  * @param manager - Terminal manager bridged onto HTTP
152
- * @param options - Shared route, authorization, keepalive, timer, and body-limit options
179
+ * @param options - Required token, plus shared route, keepalive, timer, and body-limit options
180
+ * @throws {ToolboxError} Thrown with code `TOOL` for a missing or malformed `token`, a `keepalive`
181
+ * that is not a positive finite number, or a `limit` that is not a positive safe integer
153
182
  */
154
183
  constructor(manager, options) {
184
+ const token = options?.token;
185
+ if (!(0, _orkestrel_contract.isNonEmptyString)(token) && !(0, _orkestrel_contract.isFunction)(token)) throw new _src_core.ToolboxError("TOOL", `terminal routes need a token; received ${(0, _orkestrel_contract.preview)(token)}; pass token: 'SECRET' to compare the ${_orkestrel_terminal.HEADER_TOKEN} header, a function to decide each value, or token: () => true to leave the routes open`);
186
+ const keepalive = options.keepalive ?? 15e3;
187
+ if (!(0, _orkestrel_contract.isFiniteNumber)(keepalive) || keepalive <= 0) throw new _src_core.ToolboxError("TOOL", `terminal keepalive must be a positive finite number of milliseconds; received ${(0, _orkestrel_contract.preview)(keepalive)}; omit it for the default ${String(TERMINAL_KEEPALIVE_MS)}`);
155
188
  this.#manager = manager;
156
- this.#path = options?.path ?? "/terminals/:name";
157
- this.#token = options?.token;
158
- this.#keepalive = options?.keepalive ?? 15e3;
159
- this.#timer = options?.timer ?? _orkestrel_terminal.defaultTimer;
160
- const limit = options?.limit;
161
- this.#limit = limit === void 0 || !(0, _orkestrel_contract.isFiniteNumber)(limit) ? _orkestrel_server.DEFAULT_BODY_LIMIT : Math.max(0, Math.floor(limit));
189
+ this.#path = options.path ?? "/terminals/:name";
190
+ this.#token = token;
191
+ this.#keepalive = keepalive;
192
+ this.#timer = options.timer ?? _orkestrel_terminal.defaultTimer;
193
+ this.#limit = (0, _src_core.resolveBound)(options.limit, _orkestrel_server.DEFAULT_BODY_LIMIT, "terminal body limit");
194
+ this.#answer = (0, _orkestrel_contract.createContract)(terminalAnswerShape);
162
195
  this.#accepts = this.#valid.bind(this);
163
196
  this.#get = this.#handleGet.bind(this);
164
197
  this.#post = this.#handlePost.bind(this);
@@ -180,7 +213,6 @@ var TerminalBridge = class {
180
213
  }];
181
214
  }
182
215
  #valid(presented) {
183
- if (this.#token === void 0) return true;
184
216
  try {
185
217
  return typeof this.#token === "function" ? this.#token(presented) : presented === this.#token;
186
218
  } catch {
@@ -202,18 +234,35 @@ var TerminalBridge = class {
202
234
  if (name === void 0 || this.#manager.terminal(name) === void 0) return new Response(null, { status: 404 });
203
235
  const bytes = await this.#readBody(request);
204
236
  if (bytes instanceof Response) return bytes;
205
- if (bytes.byteLength > 0 && bytes.byteLength > this.#limit) return new Response(null, { status: 413 });
206
237
  const body = (0, _orkestrel_contract.parseJSON)(new TextDecoder().decode(bytes));
207
238
  if (body === void 0) return new Response(null, { status: 400 });
208
- if (!(0, _orkestrel_contract.isRecord)(body) || !(0, _orkestrel_contract.isNonEmptyString)(body.id) || !(0, _orkestrel_form.isFormValues)(body.values)) return new Response(null, { status: 422 });
209
- const result = this.#manager.answer(name, body.id, body.values);
239
+ if (!this.#answer.is(body)) return this.#refuse(this.#answer.audit(body));
240
+ const { id, values } = body;
241
+ if (!(0, _orkestrel_form.isFormValues)(values)) return this.#refuse(Object.entries(values).filter(([, value]) => !(0, _orkestrel_form.isFieldValue)(value)).map(([key, value]) => ({
242
+ reason: "type",
243
+ path: ["values", key],
244
+ expected: "union",
245
+ received: (0, _orkestrel_contract.preview)(value)
246
+ })));
247
+ const result = this.#manager.answer(name, id, values);
210
248
  if (result.success) return Response.json(result);
211
249
  if (result.error.reason === "target") return Response.json(result, { status: 404 });
212
250
  return Response.json(result, { status: 422 });
213
251
  }
252
+ #refuse(faults) {
253
+ const message = (0, _src_core.limitRefusal)(`an answer body is { id, values }, where values maps each field name to a string, a finite number, a boolean, or a list of strings; correct each line and send it again:\n${(0, _orkestrel_contract.describeFaults)(faults, this.#answer.schema)}`);
254
+ return Response.json({
255
+ success: false,
256
+ error: {
257
+ reason: "malformed",
258
+ message,
259
+ faults
260
+ }
261
+ }, { status: 422 });
262
+ }
214
263
  async #readBody(request) {
215
264
  try {
216
- return await (0, _orkestrel_server.collectRequestBody)(request, Math.max(1, this.#limit));
265
+ return await (0, _orkestrel_server.collectRequestBody)(request, this.#limit);
217
266
  } catch (error) {
218
267
  if ((0, _orkestrel_contract.isInstance)(error, _orkestrel_server.ContentTooLargeError)) return new Response(null, { status: 413 });
219
268
  throw error;
@@ -229,13 +278,18 @@ var TerminalBridge = class {
229
278
  * streams, the same POST url answers, the same `{ id, values }` request body, the same JSON answer
230
279
  * `Result` response body, and the same `x-orkestrel-token` header.
231
280
  *
232
- * Both routes share the configured `:name` path and optional token gate. The GET route replays
281
+ * Both routes share the configured `:name` path and the required token gate. The GET route replays
233
282
  * pending prompts, forwards live pending/expire events, and owns abort/keepalive teardown. The
234
- * POST route bounds the request body before parsing and maps answer outcomes to HTTP statuses.
283
+ * POST route bounds the request body before parsing, refuses a body outside
284
+ * {@link import('./shapers.js').terminalAnswerShape} with its faults, and maps answer outcomes to
285
+ * HTTP statuses.
235
286
  *
236
287
  * @param manager - The terminal manager whose endpoints are bridged
237
- * @param options - Route path, token, keepalive, timer, and body-limit options
288
+ * @param options - The required token, plus route path, keepalive, timer, and body-limit options
238
289
  * @returns The GET route followed by the POST route
290
+ * @throws {import('@src/core').ToolboxError} Thrown with code `TOOL` for a missing or malformed
291
+ * `token`, a `keepalive` that is not a positive finite number, or a `limit` that is not a positive
292
+ * safe integer; `token: () => true` is the explicit open
239
293
  *
240
294
  * @example
241
295
  * ```ts
@@ -253,5 +307,6 @@ function createTerminalRoutes(manager, options) {
253
307
  exports.TERMINAL_KEEPALIVE_MS = TERMINAL_KEEPALIVE_MS;
254
308
  exports.TERMINAL_ROUTES_PATH = TERMINAL_ROUTES_PATH;
255
309
  exports.createTerminalRoutes = createTerminalRoutes;
310
+ exports.terminalAnswerShape = terminalAnswerShape;
256
311
 
257
312
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":["HEADER_TOKEN","serializePending","serializeExpire","defaultTimer","isFiniteNumber","DEFAULT_BODY_LIMIT","HEADER_TOKEN","createStream","parseJSON","isRecord","isNonEmptyString","isFormValues","collectRequestBody","isInstance","ContentTooLargeError"],"sources":["../../../src/server/constants.ts","../../../src/server/terminals/TerminalConnection.ts","../../../src/server/terminals/TerminalBridge.ts","../../../src/server/factories.ts"],"sourcesContent":["// Server-package constants — UPPER_SNAKE, `Object.freeze`d where structural, every member\n// exported.\n\n/**\n * Holds the default `:name`-templated path {@link import('./factories.js').createTerminalRoutes}\n * mounts its GET (SSE) and POST (answer) routes under, `/terminals/:name`.\n */\nexport const TERMINAL_ROUTES_PATH = '/terminals/:name'\n\n/**\n * Holds the default SSE keepalive interval {@link import('./factories.js').createTerminalRoutes}\n * arms per open connection, `15_000` ms — a `: ` comment ping a conforming SSE parser ignores,\n * keeping intermediary proxies from timing out an otherwise-idle stream.\n */\nexport const TERMINAL_KEEPALIVE_MS = 15_000\n","import type {\n\tPendingForm,\n\tTerminalManagerInterface,\n\tTimerCancelFunction,\n\tTimerHandler,\n\tWireEvent,\n} from '@orkestrel/terminal'\nimport type { SSEStreamInterface } from '@orkestrel/server'\nimport { HEADER_TOKEN, serializeExpire, serializePending } from '@orkestrel/terminal'\n\n/**\n * Owns one terminal SSE connection's replay, subscriptions, keepalive, and teardown.\n *\n * Not exported from the `@orkestrel/toolbox/server` barrel — reach this behaviour through\n * `createTerminalRoutes`.\n */\nexport class TerminalConnection {\n\treadonly #manager: TerminalManagerInterface\n\treadonly #name: string\n\treadonly #request: Request\n\treadonly #stream: SSEStreamInterface\n\treadonly #accepts: (presented: string | undefined) => boolean\n\treadonly #timer: TimerHandler\n\treadonly #keepalive: number\n\treadonly #presented: string | undefined\n\t#cancel: TimerCancelFunction | undefined\n\treadonly #destroyHandler: () => void\n\treadonly #pendingHandler: (form: PendingForm) => void\n\treadonly #expireHandler: (to: string, id: string) => void\n\treadonly #tickHandler: () => void\n\n\t/**\n\t * Creates a terminal stream connection.\n\t *\n\t * @param manager - Terminal manager supplying pending forms and lifecycle events\n\t * @param name - Terminal endpoint streamed by this connection\n\t * @param request - Request whose abort signal owns the connection lifetime\n\t * @param stream - Open SSE stream\n\t * @param accepts - Presented-token validator\n\t * @param timer - Keepalive timer implementation\n\t * @param keepalive - Keepalive interval in milliseconds\n\t */\n\tconstructor(\n\t\tmanager: TerminalManagerInterface,\n\t\tname: string,\n\t\trequest: Request,\n\t\tstream: SSEStreamInterface,\n\t\taccepts: (presented: string | undefined) => boolean,\n\t\ttimer: TimerHandler,\n\t\tkeepalive: number,\n\t) {\n\t\tthis.#manager = manager\n\t\tthis.#name = name\n\t\tthis.#request = request\n\t\tthis.#stream = stream\n\t\tthis.#accepts = accepts\n\t\tthis.#timer = timer\n\t\tthis.#keepalive = keepalive\n\t\tthis.#presented = request.headers.get(HEADER_TOKEN) ?? undefined\n\t\tthis.#destroyHandler = this.#destroy.bind(this)\n\t\tthis.#pendingHandler = this.#pending.bind(this)\n\t\tthis.#expireHandler = this.#expire.bind(this)\n\t\tthis.#tickHandler = this.#tick.bind(this)\n\t}\n\n\t/**\n\t * Opens the connection by replaying pending forms, subscribing, and arming keepalive handling.\n\t *\n\t * @returns The SSE response\n\t */\n\topen(): Response {\n\t\tif (this.#stream.closed || this.#request.signal.aborted) {\n\t\t\tthis.#destroy()\n\t\t\treturn this.#stream.response\n\t\t}\n\t\tif (this.#cancel !== undefined) return this.#stream.response\n\t\tfor (const form of this.#manager.pending(this.#name)) {\n\t\t\tthis.#write(serializePending(form))\n\t\t}\n\t\tthis.#manager.emitter.on('pending', this.#pendingHandler)\n\t\tthis.#manager.emitter.on('expire', this.#expireHandler)\n\t\tthis.#cancel = this.#timer(this.#tickHandler, this.#keepalive)\n\t\tthis.#request.signal.addEventListener('abort', this.#destroyHandler)\n\t\treturn this.#stream.response\n\t}\n\n\t#write(wire: WireEvent): void {\n\t\tthis.#stream.write({\n\t\t\tevent: wire.event,\n\t\t\tdata: wire.data,\n\t\t\t...(wire.id === undefined ? {} : { id: wire.id }),\n\t\t})\n\t}\n\n\t#pending(form: PendingForm): void {\n\t\tif (form.to !== this.#name) return\n\t\tif (this.#stream.closed) {\n\t\t\tthis.#destroy()\n\t\t\treturn\n\t\t}\n\t\tthis.#write(serializePending(form))\n\t}\n\n\t#expire(to: string, id: string): void {\n\t\tif (to !== this.#name) return\n\t\tif (this.#stream.closed) {\n\t\t\tthis.#destroy()\n\t\t\treturn\n\t\t}\n\t\tthis.#write(serializeExpire(id))\n\t}\n\n\t#tick(): void {\n\t\tif (this.#stream.closed || !this.#accepted()) {\n\t\t\tthis.#destroy()\n\t\t\treturn\n\t\t}\n\t\tthis.#stream.comment('')\n\t\tthis.#cancel = this.#timer(this.#tickHandler, this.#keepalive)\n\t}\n\n\t#accepted(): boolean {\n\t\ttry {\n\t\t\treturn this.#accepts(this.#presented)\n\t\t} catch {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t#destroy(): void {\n\t\tconst cancel = this.#cancel\n\t\tthis.#cancel = undefined\n\t\tcancel?.()\n\t\tthis.#manager.emitter.off('pending', this.#pendingHandler)\n\t\tthis.#manager.emitter.off('expire', this.#expireHandler)\n\t\tthis.#request.signal.removeEventListener('abort', this.#destroyHandler)\n\t\tthis.#stream.end()\n\t}\n}\n","import type { TerminalManagerInterface, TimerHandler } from '@orkestrel/terminal'\nimport type {\n\tTerminalRoute,\n\tTerminalRouteContext,\n\tTerminalRoutesOptions,\n\tTerminalToken,\n} from '../types.js'\nimport { defaultTimer, HEADER_TOKEN } from '@orkestrel/terminal'\nimport { isFormValues } from '@orkestrel/form'\nimport {\n\tisFiniteNumber,\n\tisInstance,\n\tisNonEmptyString,\n\tisRecord,\n\tparseJSON,\n} from '@orkestrel/contract'\nimport {\n\tcollectRequestBody,\n\tContentTooLargeError,\n\tcreateStream,\n\tDEFAULT_BODY_LIMIT,\n} from '@orkestrel/server'\nimport { TERMINAL_KEEPALIVE_MS, TERMINAL_ROUTES_PATH } from '../constants.js'\nimport { TerminalConnection } from './TerminalConnection.js'\n\n/**\n * Bridges a terminal manager onto the wire — owns the shared route options, the token gate, and\n * the bound GET stream and POST answer handlers.\n *\n * Not exported from the `@orkestrel/toolbox/server` barrel — reach this behaviour through\n * `createTerminalRoutes`.\n */\nexport class TerminalBridge {\n\treadonly #manager: TerminalManagerInterface\n\treadonly #path: string\n\treadonly #token: TerminalToken | undefined\n\treadonly #keepalive: number\n\treadonly #timer: TimerHandler\n\treadonly #limit: number\n\treadonly #accepts: (presented: string | undefined) => boolean\n\treadonly #get: TerminalRoute['handler']\n\treadonly #post: TerminalRoute['handler']\n\n\t/**\n\t * Creates a terminal bridge.\n\t *\n\t * @param manager - Terminal manager bridged onto HTTP\n\t * @param options - Shared route, authorization, keepalive, timer, and body-limit options\n\t */\n\tconstructor(manager: TerminalManagerInterface, options?: TerminalRoutesOptions) {\n\t\tthis.#manager = manager\n\t\tthis.#path = options?.path ?? TERMINAL_ROUTES_PATH\n\t\tthis.#token = options?.token\n\t\tthis.#keepalive = options?.keepalive ?? TERMINAL_KEEPALIVE_MS\n\t\tthis.#timer = options?.timer ?? defaultTimer\n\t\tconst limit = options?.limit\n\t\tthis.#limit =\n\t\t\tlimit === undefined || !isFiniteNumber(limit)\n\t\t\t\t? DEFAULT_BODY_LIMIT\n\t\t\t\t: Math.max(0, Math.floor(limit))\n\t\tthis.#accepts = this.#valid.bind(this)\n\t\tthis.#get = this.#handleGet.bind(this)\n\t\tthis.#post = this.#handlePost.bind(this)\n\t}\n\n\t/**\n\t * Projects the bound GET and POST route records.\n\t *\n\t * @returns The GET stream route followed by the POST answer route\n\t */\n\troutes(): readonly TerminalRoute[] {\n\t\treturn [\n\t\t\t{ method: 'GET', path: this.#path, handler: this.#get },\n\t\t\t{ method: 'POST', path: this.#path, handler: this.#post },\n\t\t]\n\t}\n\n\t#valid(presented: string | undefined): boolean {\n\t\tif (this.#token === undefined) return true\n\t\ttry {\n\t\t\treturn typeof this.#token === 'function' ? this.#token(presented) : presented === this.#token\n\t\t} catch {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t#authorized(request: Request): boolean {\n\t\treturn this.#valid(request.headers.get(HEADER_TOKEN) ?? undefined)\n\t}\n\n\t#handleGet(request: Request, context: TerminalRouteContext): Response {\n\t\tif (!this.#authorized(request)) return new Response(null, { status: 401 })\n\t\tconst name = context.params.name\n\t\tif (name === undefined || this.#manager.terminal(name) === undefined) {\n\t\t\treturn new Response(null, { status: 404 })\n\t\t}\n\t\tconst connection = new TerminalConnection(\n\t\t\tthis.#manager,\n\t\t\tname,\n\t\t\trequest,\n\t\t\tcreateStream(),\n\t\t\tthis.#accepts,\n\t\t\tthis.#timer,\n\t\t\tthis.#keepalive,\n\t\t)\n\t\treturn connection.open()\n\t}\n\n\tasync #handlePost(request: Request, context: TerminalRouteContext): Promise<Response> {\n\t\tif (!this.#authorized(request)) return new Response(null, { status: 401 })\n\t\tconst name = context.params.name\n\t\tif (name === undefined || this.#manager.terminal(name) === undefined) {\n\t\t\treturn new Response(null, { status: 404 })\n\t\t}\n\t\tconst bytes = await this.#readBody(request)\n\t\tif (bytes instanceof Response) return bytes\n\t\tif (bytes.byteLength > 0 && bytes.byteLength > this.#limit) {\n\t\t\treturn new Response(null, { status: 413 })\n\t\t}\n\n\t\tconst body = parseJSON(new TextDecoder().decode(bytes))\n\t\tif (body === undefined) {\n\t\t\treturn new Response(null, { status: 400 })\n\t\t}\n\t\tif (!isRecord(body) || !isNonEmptyString(body.id) || !isFormValues(body.values)) {\n\t\t\treturn new Response(null, { status: 422 })\n\t\t}\n\n\t\tconst result = this.#manager.answer(name, body.id, body.values)\n\t\tif (result.success) return Response.json(result)\n\t\tif (result.error.reason === 'target') return Response.json(result, { status: 404 })\n\t\treturn Response.json(result, { status: 422 })\n\t}\n\n\tasync #readBody(request: Request): Promise<Uint8Array | Response> {\n\t\ttry {\n\t\t\treturn await collectRequestBody(request, Math.max(1, this.#limit))\n\t\t} catch (error) {\n\t\t\tif (isInstance(error, ContentTooLargeError)) return new Response(null, { status: 413 })\n\t\t\tthrow error\n\t\t}\n\t}\n}\n","import type { TerminalManagerInterface } from '@orkestrel/terminal'\nimport type { TerminalRoute, TerminalRoutesOptions } from './types.js'\nimport { TerminalBridge } from './terminals/TerminalBridge.js'\n\n/**\n * Builds the GET SSE stream and POST answer routes that bridge a terminal manager onto the wire.\n *\n * @remarks\n * The pair stays byte-compatible with `@orkestrel/terminal`'s own `PromptClient`: the same GET url\n * streams, the same POST url answers, the same `{ id, values }` request body, the same JSON answer\n * `Result` response body, and the same `x-orkestrel-token` header.\n *\n * Both routes share the configured `:name` path and optional token gate. The GET route replays\n * pending prompts, forwards live pending/expire events, and owns abort/keepalive teardown. The\n * POST route bounds the request body before parsing and maps answer outcomes to HTTP statuses.\n *\n * @param manager - The terminal manager whose endpoints are bridged\n * @param options - Route path, token, keepalive, timer, and body-limit options\n * @returns The GET route followed by the POST route\n *\n * @example\n * ```ts\n * import { createTerminalRoutes } from '@src/server'\n * import { createTerminalManager } from '@orkestrel/terminal'\n *\n * const manager = createTerminalManager()\n * manager.add('assistant')\n * const routes = createTerminalRoutes(manager, { token: 'secret' })\n * ```\n */\nexport function createTerminalRoutes(\n\tmanager: TerminalManagerInterface,\n\toptions?: TerminalRoutesOptions,\n): readonly TerminalRoute[] {\n\treturn new TerminalBridge(manager, options).routes()\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.cjs","names":["objectShape","stringShape","recordShape","jsonShape","HEADER_TOKEN","serializePending","serializeExpire","isNonEmptyString","isFunction","ToolboxError","preview","HEADER_TOKEN","isFiniteNumber","defaultTimer","resolveBound","DEFAULT_BODY_LIMIT","createContract","createStream","parseJSON","isFormValues","isFieldValue","limitRefusal","describeFaults","collectRequestBody","isInstance","ContentTooLargeError"],"sources":["../../../src/server/constants.ts","../../../src/server/shapers.ts","../../../src/server/terminals/TerminalConnection.ts","../../../src/server/terminals/TerminalBridge.ts","../../../src/server/factories.ts"],"sourcesContent":["// Server-package constants — UPPER_SNAKE, `Object.freeze`d where structural, every member\n// exported.\n\n/**\n * Holds the default `:name`-templated path {@link import('./factories.js').createTerminalRoutes}\n * mounts its GET (SSE) and POST (answer) routes under, `/terminals/:name`.\n */\nexport const TERMINAL_ROUTES_PATH = '/terminals/:name'\n\n/**\n * Holds the default SSE keepalive interval {@link import('./factories.js').createTerminalRoutes}\n * arms per open connection, `15_000` ms — a `: ` comment ping a conforming SSE parser ignores,\n * keeping intermediary proxies from timing out an otherwise-idle stream.\n */\nexport const TERMINAL_KEEPALIVE_MS = 15_000\n","import { jsonShape, objectShape, recordShape, stringShape } from '@orkestrel/contract'\n\n// Server-package shapes — the wire body `createTerminalRoutes` admits, compiled once per bridge.\n\n/**\n * Describes the body a terminal POST route admits — the pending form's `id` and its answers.\n *\n * @remarks\n * The shape closes the envelope. `@orkestrel/form`'s `isFormValues` guard still owns what one answer\n * holds, so this shape does not restate the field value union.\n *\n * @example\n * ```ts\n * import { createContract } from '@orkestrel/contract'\n * import { terminalAnswerShape } from '@src/server'\n *\n * const contract = createContract(terminalAnswerShape)\n * contract.is({ id: 'form-1', values: { name: 'Ada' } }) // true\n * contract.audit({ id: 'form-1', values: {}, reply: true }) // [{ reason: 'extra', path: ['reply'] }]\n * ```\n */\nexport const terminalAnswerShape = objectShape({\n\tid: stringShape({ min: 1, description: 'The id of the pending form to answer.' }),\n\tvalues: recordShape(jsonShape(), { description: 'The form answers keyed by field name.' }),\n})\n","import type {\n\tPendingForm,\n\tTerminalManagerInterface,\n\tTimerCancelFunction,\n\tTimerHandler,\n\tWireEvent,\n} from '@orkestrel/terminal'\nimport type { SSEStreamInterface } from '@orkestrel/server'\nimport { HEADER_TOKEN, serializeExpire, serializePending } from '@orkestrel/terminal'\n\n/**\n * Owns one terminal SSE connection's replay, subscriptions, keepalive, and teardown.\n *\n * Not exported from the `@orkestrel/toolbox/server` barrel — reach this behaviour through\n * `createTerminalRoutes`.\n */\nexport class TerminalConnection {\n\treadonly #manager: TerminalManagerInterface\n\treadonly #name: string\n\treadonly #request: Request\n\treadonly #stream: SSEStreamInterface\n\treadonly #accepts: (presented: string | undefined) => boolean\n\treadonly #timer: TimerHandler\n\treadonly #keepalive: number\n\treadonly #presented: string | undefined\n\t#cancel: TimerCancelFunction | undefined\n\treadonly #destroyHandler: () => void\n\treadonly #pendingHandler: (form: PendingForm) => void\n\treadonly #expireHandler: (to: string, id: string) => void\n\treadonly #tickHandler: () => void\n\n\t/**\n\t * Creates a terminal stream connection.\n\t *\n\t * @param manager - Terminal manager supplying pending forms and lifecycle events\n\t * @param name - Terminal endpoint streamed by this connection\n\t * @param request - Request whose abort signal owns the connection lifetime\n\t * @param stream - Open SSE stream\n\t * @param accepts - Presented-token validator\n\t * @param timer - Keepalive timer implementation\n\t * @param keepalive - Keepalive interval in milliseconds\n\t */\n\tconstructor(\n\t\tmanager: TerminalManagerInterface,\n\t\tname: string,\n\t\trequest: Request,\n\t\tstream: SSEStreamInterface,\n\t\taccepts: (presented: string | undefined) => boolean,\n\t\ttimer: TimerHandler,\n\t\tkeepalive: number,\n\t) {\n\t\tthis.#manager = manager\n\t\tthis.#name = name\n\t\tthis.#request = request\n\t\tthis.#stream = stream\n\t\tthis.#accepts = accepts\n\t\tthis.#timer = timer\n\t\tthis.#keepalive = keepalive\n\t\tthis.#presented = request.headers.get(HEADER_TOKEN) ?? undefined\n\t\tthis.#destroyHandler = this.#destroy.bind(this)\n\t\tthis.#pendingHandler = this.#pending.bind(this)\n\t\tthis.#expireHandler = this.#expire.bind(this)\n\t\tthis.#tickHandler = this.#tick.bind(this)\n\t}\n\n\t/**\n\t * Opens the connection by replaying pending forms, subscribing, and arming keepalive handling.\n\t *\n\t * @returns The SSE response\n\t */\n\topen(): Response {\n\t\tif (this.#stream.closed || this.#request.signal.aborted) {\n\t\t\tthis.#destroy()\n\t\t\treturn this.#stream.response\n\t\t}\n\t\tif (this.#cancel !== undefined) return this.#stream.response\n\t\tfor (const form of this.#manager.pending(this.#name)) {\n\t\t\tthis.#write(serializePending(form))\n\t\t}\n\t\tthis.#manager.emitter.on('pending', this.#pendingHandler)\n\t\tthis.#manager.emitter.on('expire', this.#expireHandler)\n\t\tthis.#cancel = this.#timer(this.#tickHandler, this.#keepalive)\n\t\tthis.#request.signal.addEventListener('abort', this.#destroyHandler)\n\t\treturn this.#stream.response\n\t}\n\n\t#write(wire: WireEvent): void {\n\t\tthis.#stream.write({\n\t\t\tevent: wire.event,\n\t\t\tdata: wire.data,\n\t\t\t...(wire.id === undefined ? {} : { id: wire.id }),\n\t\t})\n\t}\n\n\t#pending(form: PendingForm): void {\n\t\tif (form.to !== this.#name) return\n\t\tif (this.#stream.closed) {\n\t\t\tthis.#destroy()\n\t\t\treturn\n\t\t}\n\t\tthis.#write(serializePending(form))\n\t}\n\n\t#expire(to: string, id: string): void {\n\t\tif (to !== this.#name) return\n\t\tif (this.#stream.closed) {\n\t\t\tthis.#destroy()\n\t\t\treturn\n\t\t}\n\t\tthis.#write(serializeExpire(id))\n\t}\n\n\t#tick(): void {\n\t\tif (this.#stream.closed || !this.#accepted()) {\n\t\t\tthis.#destroy()\n\t\t\treturn\n\t\t}\n\t\tthis.#stream.comment('')\n\t\tthis.#cancel = this.#timer(this.#tickHandler, this.#keepalive)\n\t}\n\n\t#accepted(): boolean {\n\t\ttry {\n\t\t\treturn this.#accepts(this.#presented)\n\t\t} catch {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t#destroy(): void {\n\t\tconst cancel = this.#cancel\n\t\tthis.#cancel = undefined\n\t\tcancel?.()\n\t\tthis.#manager.emitter.off('pending', this.#pendingHandler)\n\t\tthis.#manager.emitter.off('expire', this.#expireHandler)\n\t\tthis.#request.signal.removeEventListener('abort', this.#destroyHandler)\n\t\tthis.#stream.end()\n\t}\n}\n","import type { AuditFault, ContractInterface, Infer } from '@orkestrel/contract'\nimport type { TerminalManagerInterface, TimerHandler } from '@orkestrel/terminal'\nimport type {\n\tTerminalRoute,\n\tTerminalRouteContext,\n\tTerminalRoutesOptions,\n\tTerminalToken,\n} from '../types.js'\nimport { defaultTimer, HEADER_TOKEN } from '@orkestrel/terminal'\nimport { isFieldValue, isFormValues } from '@orkestrel/form'\nimport {\n\tcreateContract,\n\tisFiniteNumber,\n\tisFunction,\n\tisInstance,\n\tisNonEmptyString,\n\tparseJSON,\n\tpreview,\n} from '@orkestrel/contract'\nimport {\n\tcollectRequestBody,\n\tContentTooLargeError,\n\tcreateStream,\n\tDEFAULT_BODY_LIMIT,\n} from '@orkestrel/server'\nimport { describeFaults } from '@orkestrel/contract'\nimport { limitRefusal, resolveBound, ToolboxError } from '@src/core'\nimport { TERMINAL_KEEPALIVE_MS, TERMINAL_ROUTES_PATH } from '../constants.js'\nimport { terminalAnswerShape } from '../shapers.js'\nimport { TerminalConnection } from './TerminalConnection.js'\n\n/**\n * Bridges a terminal manager onto the wire — owns the shared route options, the token gate, and\n * the bound GET stream and POST answer handlers.\n *\n * Not exported from the `@orkestrel/toolbox/server` barrel — reach this behaviour through\n * `createTerminalRoutes`.\n */\nexport class TerminalBridge {\n\treadonly #manager: TerminalManagerInterface\n\treadonly #path: string\n\treadonly #token: TerminalToken\n\treadonly #keepalive: number\n\treadonly #timer: TimerHandler\n\treadonly #limit: number\n\treadonly #answer: ContractInterface<Infer<typeof terminalAnswerShape>>\n\treadonly #accepts: (presented: string | undefined) => boolean\n\treadonly #get: TerminalRoute['handler']\n\treadonly #post: TerminalRoute['handler']\n\n\t/**\n\t * Creates a terminal bridge.\n\t *\n\t * @param manager - Terminal manager bridged onto HTTP\n\t * @param options - Required token, plus shared route, keepalive, timer, and body-limit options\n\t * @throws {ToolboxError} Thrown with code `TOOL` for a missing or malformed `token`, a `keepalive`\n\t * that is not a positive finite number, or a `limit` that is not a positive safe integer\n\t */\n\tconstructor(manager: TerminalManagerInterface, options: TerminalRoutesOptions) {\n\t\t// An untyped caller can omit the options, which is the same refusal as omitting the token.\n\t\tconst token = options?.token\n\t\tif (!isNonEmptyString(token) && !isFunction(token)) {\n\t\t\tthrow new ToolboxError(\n\t\t\t\t'TOOL',\n\t\t\t\t`terminal routes need a token; received ${preview(token)}; pass token: 'SECRET' to compare the ${HEADER_TOKEN} header, a function to decide each value, or token: () => true to leave the routes open`,\n\t\t\t)\n\t\t}\n\t\tconst keepalive = options.keepalive ?? TERMINAL_KEEPALIVE_MS\n\t\tif (!isFiniteNumber(keepalive) || keepalive <= 0) {\n\t\t\tthrow new ToolboxError(\n\t\t\t\t'TOOL',\n\t\t\t\t`terminal keepalive must be a positive finite number of milliseconds; received ${preview(keepalive)}; omit it for the default ${String(TERMINAL_KEEPALIVE_MS)}`,\n\t\t\t)\n\t\t}\n\t\tthis.#manager = manager\n\t\tthis.#path = options.path ?? TERMINAL_ROUTES_PATH\n\t\tthis.#token = token\n\t\tthis.#keepalive = keepalive\n\t\tthis.#timer = options.timer ?? defaultTimer\n\t\tthis.#limit = resolveBound(options.limit, DEFAULT_BODY_LIMIT, 'terminal body limit')\n\t\tthis.#answer = createContract(terminalAnswerShape)\n\t\tthis.#accepts = this.#valid.bind(this)\n\t\tthis.#get = this.#handleGet.bind(this)\n\t\tthis.#post = this.#handlePost.bind(this)\n\t}\n\n\t/**\n\t * Projects the bound GET and POST route records.\n\t *\n\t * @returns The GET stream route followed by the POST answer route\n\t */\n\troutes(): readonly TerminalRoute[] {\n\t\treturn [\n\t\t\t{ method: 'GET', path: this.#path, handler: this.#get },\n\t\t\t{ method: 'POST', path: this.#path, handler: this.#post },\n\t\t]\n\t}\n\n\t#valid(presented: string | undefined): boolean {\n\t\ttry {\n\t\t\treturn typeof this.#token === 'function' ? this.#token(presented) : presented === this.#token\n\t\t} catch {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t#authorized(request: Request): boolean {\n\t\treturn this.#valid(request.headers.get(HEADER_TOKEN) ?? undefined)\n\t}\n\n\t#handleGet(request: Request, context: TerminalRouteContext): Response {\n\t\tif (!this.#authorized(request)) return new Response(null, { status: 401 })\n\t\tconst name = context.params.name\n\t\tif (name === undefined || this.#manager.terminal(name) === undefined) {\n\t\t\treturn new Response(null, { status: 404 })\n\t\t}\n\t\tconst connection = new TerminalConnection(\n\t\t\tthis.#manager,\n\t\t\tname,\n\t\t\trequest,\n\t\t\tcreateStream(),\n\t\t\tthis.#accepts,\n\t\t\tthis.#timer,\n\t\t\tthis.#keepalive,\n\t\t)\n\t\treturn connection.open()\n\t}\n\n\tasync #handlePost(request: Request, context: TerminalRouteContext): Promise<Response> {\n\t\tif (!this.#authorized(request)) return new Response(null, { status: 401 })\n\t\tconst name = context.params.name\n\t\tif (name === undefined || this.#manager.terminal(name) === undefined) {\n\t\t\treturn new Response(null, { status: 404 })\n\t\t}\n\t\tconst bytes = await this.#readBody(request)\n\t\tif (bytes instanceof Response) return bytes\n\n\t\tconst body = parseJSON(new TextDecoder().decode(bytes))\n\t\tif (body === undefined) {\n\t\t\treturn new Response(null, { status: 400 })\n\t\t}\n\t\tif (!this.#answer.is(body)) return this.#refuse(this.#answer.audit(body))\n\t\tconst { id, values } = body\n\t\tif (!isFormValues(values)) {\n\t\t\treturn this.#refuse(\n\t\t\t\tObject.entries(values)\n\t\t\t\t\t.filter(([, value]) => !isFieldValue(value))\n\t\t\t\t\t.map(([key, value]) => ({\n\t\t\t\t\t\treason: 'type',\n\t\t\t\t\t\tpath: ['values', key],\n\t\t\t\t\t\texpected: 'union',\n\t\t\t\t\t\treceived: preview(value),\n\t\t\t\t\t})),\n\t\t\t)\n\t\t}\n\n\t\tconst result = this.#manager.answer(name, id, values)\n\t\tif (result.success) return Response.json(result)\n\t\tif (result.error.reason === 'target') return Response.json(result, { status: 404 })\n\t\treturn Response.json(result, { status: 422 })\n\t}\n\n\t// A malformed body answers in the manager's result envelope, so a client reads one body shape.\n\t#refuse(faults: readonly AuditFault[]): Response {\n\t\tconst message = limitRefusal(\n\t\t\t`an answer body is { id, values }, where values maps each field name to a string, a finite number, a boolean, or a list of strings; correct each line and send it again:\\n${describeFaults(faults, this.#answer.schema)}`,\n\t\t)\n\t\treturn Response.json(\n\t\t\t{ success: false, error: { reason: 'malformed', message, faults } },\n\t\t\t{ status: 422 },\n\t\t)\n\t}\n\n\tasync #readBody(request: Request): Promise<Uint8Array | Response> {\n\t\ttry {\n\t\t\treturn await collectRequestBody(request, this.#limit)\n\t\t} catch (error) {\n\t\t\tif (isInstance(error, ContentTooLargeError)) return new Response(null, { status: 413 })\n\t\t\tthrow error\n\t\t}\n\t}\n}\n","import type { TerminalManagerInterface } from '@orkestrel/terminal'\nimport type { TerminalRoute, TerminalRoutesOptions } from './types.js'\nimport { TerminalBridge } from './terminals/TerminalBridge.js'\n\n/**\n * Builds the GET SSE stream and POST answer routes that bridge a terminal manager onto the wire.\n *\n * @remarks\n * The pair stays byte-compatible with `@orkestrel/terminal`'s own `PromptClient`: the same GET url\n * streams, the same POST url answers, the same `{ id, values }` request body, the same JSON answer\n * `Result` response body, and the same `x-orkestrel-token` header.\n *\n * Both routes share the configured `:name` path and the required token gate. The GET route replays\n * pending prompts, forwards live pending/expire events, and owns abort/keepalive teardown. The\n * POST route bounds the request body before parsing, refuses a body outside\n * {@link import('./shapers.js').terminalAnswerShape} with its faults, and maps answer outcomes to\n * HTTP statuses.\n *\n * @param manager - The terminal manager whose endpoints are bridged\n * @param options - The required token, plus route path, keepalive, timer, and body-limit options\n * @returns The GET route followed by the POST route\n * @throws {import('@src/core').ToolboxError} Thrown with code `TOOL` for a missing or malformed\n * `token`, a `keepalive` that is not a positive finite number, or a `limit` that is not a positive\n * safe integer; `token: () => true` is the explicit open\n *\n * @example\n * ```ts\n * import { createTerminalRoutes } from '@src/server'\n * import { createTerminalManager } from '@orkestrel/terminal'\n *\n * const manager = createTerminalManager()\n * manager.add('assistant')\n * const routes = createTerminalRoutes(manager, { token: 'secret' })\n * ```\n */\nexport function createTerminalRoutes(\n\tmanager: TerminalManagerInterface,\n\toptions: TerminalRoutesOptions,\n): readonly TerminalRoute[] {\n\treturn new TerminalBridge(manager, options).routes()\n}\n"],"mappings":";;;;;;;;;;;AAOA,IAAa,uBAAuB;;;;;;AAOpC,IAAa,wBAAwB;;;;;;;;;;;;;;;;;;;ACOrC,IAAa,uBAAA,GAAsBA,oBAAAA,YAAAA,CAAY;CAC9C,KAAA,GAAIC,oBAAAA,YAAAA,CAAY;EAAE,KAAK;EAAG,aAAa;CAAwC,CAAC;CAChF,SAAA,GAAQC,oBAAAA,YAAAA,EAAAA,GAAYC,oBAAAA,UAAAA,CAAU,GAAG,EAAE,aAAa,wCAAwC,CAAC;AAC1F,CAAC;;;;;;;;ACRD,IAAa,qBAAb,MAAgC;CAC/B;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;;;;;;;;;;;;CAaA,YACC,SACA,MACA,SACA,QACA,SACA,OACA,WACC;EACD,KAAK,WAAW;EAChB,KAAK,QAAQ;EACb,KAAK,WAAW;EAChB,KAAK,UAAU;EACf,KAAK,WAAW;EAChB,KAAK,SAAS;EACd,KAAK,aAAa;EAClB,KAAK,aAAa,QAAQ,QAAQ,IAAIC,oBAAAA,YAAY,KAAK,KAAA;EACvD,KAAK,kBAAkB,KAAK,SAAS,KAAK,IAAI;EAC9C,KAAK,kBAAkB,KAAK,SAAS,KAAK,IAAI;EAC9C,KAAK,iBAAiB,KAAK,QAAQ,KAAK,IAAI;EAC5C,KAAK,eAAe,KAAK,MAAM,KAAK,IAAI;CACzC;;;;;;CAOA,OAAiB;EAChB,IAAI,KAAK,QAAQ,UAAU,KAAK,SAAS,OAAO,SAAS;GACxD,KAAK,SAAS;GACd,OAAO,KAAK,QAAQ;EACrB;EACA,IAAI,KAAK,YAAY,KAAA,GAAW,OAAO,KAAK,QAAQ;EACpD,KAAK,MAAM,QAAQ,KAAK,SAAS,QAAQ,KAAK,KAAK,GAClD,KAAK,QAAA,GAAOC,oBAAAA,iBAAAA,CAAiB,IAAI,CAAC;EAEnC,KAAK,SAAS,QAAQ,GAAG,WAAW,KAAK,eAAe;EACxD,KAAK,SAAS,QAAQ,GAAG,UAAU,KAAK,cAAc;EACtD,KAAK,UAAU,KAAK,OAAO,KAAK,cAAc,KAAK,UAAU;EAC7D,KAAK,SAAS,OAAO,iBAAiB,SAAS,KAAK,eAAe;EACnE,OAAO,KAAK,QAAQ;CACrB;CAEA,OAAO,MAAuB;EAC7B,KAAK,QAAQ,MAAM;GAClB,OAAO,KAAK;GACZ,MAAM,KAAK;GACX,GAAI,KAAK,OAAO,KAAA,IAAY,CAAC,IAAI,EAAE,IAAI,KAAK,GAAG;EAChD,CAAC;CACF;CAEA,SAAS,MAAyB;EACjC,IAAI,KAAK,OAAO,KAAK,OAAO;EAC5B,IAAI,KAAK,QAAQ,QAAQ;GACxB,KAAK,SAAS;GACd;EACD;EACA,KAAK,QAAA,GAAOA,oBAAAA,iBAAAA,CAAiB,IAAI,CAAC;CACnC;CAEA,QAAQ,IAAY,IAAkB;EACrC,IAAI,OAAO,KAAK,OAAO;EACvB,IAAI,KAAK,QAAQ,QAAQ;GACxB,KAAK,SAAS;GACd;EACD;EACA,KAAK,QAAA,GAAOC,oBAAAA,gBAAAA,CAAgB,EAAE,CAAC;CAChC;CAEA,QAAc;EACb,IAAI,KAAK,QAAQ,UAAU,CAAC,KAAK,UAAU,GAAG;GAC7C,KAAK,SAAS;GACd;EACD;EACA,KAAK,QAAQ,QAAQ,EAAE;EACvB,KAAK,UAAU,KAAK,OAAO,KAAK,cAAc,KAAK,UAAU;CAC9D;CAEA,YAAqB;EACpB,IAAI;GACH,OAAO,KAAK,SAAS,KAAK,UAAU;EACrC,QAAQ;GACP,OAAO;EACR;CACD;CAEA,WAAiB;EAChB,MAAM,SAAS,KAAK;EACpB,KAAK,UAAU,KAAA;EACf,SAAS;EACT,KAAK,SAAS,QAAQ,IAAI,WAAW,KAAK,eAAe;EACzD,KAAK,SAAS,QAAQ,IAAI,UAAU,KAAK,cAAc;EACvD,KAAK,SAAS,OAAO,oBAAoB,SAAS,KAAK,eAAe;EACtE,KAAK,QAAQ,IAAI;CAClB;AACD;;;;;;;;;ACpGA,IAAa,iBAAb,MAA4B;CAC3B;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;;;;;;;;;CAUA,YAAY,SAAmC,SAAgC;EAE9E,MAAM,QAAQ,SAAS;EACvB,IAAI,EAAA,GAACC,oBAAAA,iBAAAA,CAAiB,KAAK,KAAK,EAAA,GAACC,oBAAAA,WAAAA,CAAW,KAAK,GAChD,MAAM,IAAIC,UAAAA,aACT,QACA,2CAAA,GAA0CC,oBAAAA,QAAAA,CAAQ,KAAK,EAAE,wCAAwCC,oBAAAA,aAAa,wFAC/G;EAED,MAAM,YAAY,QAAQ,aAAA;EAC1B,IAAI,EAAA,GAACC,oBAAAA,eAAAA,CAAe,SAAS,KAAK,aAAa,GAC9C,MAAM,IAAIH,UAAAA,aACT,QACA,kFAAA,GAAiFC,oBAAAA,QAAAA,CAAQ,SAAS,EAAE,4BAA4B,OAAO,qBAAqB,GAC7J;EAED,KAAK,WAAW;EAChB,KAAK,QAAQ,QAAQ,QAAA;EACrB,KAAK,SAAS;EACd,KAAK,aAAa;EAClB,KAAK,SAAS,QAAQ,SAASG,oBAAAA;EAC/B,KAAK,UAAA,GAASC,UAAAA,aAAAA,CAAa,QAAQ,OAAOC,kBAAAA,oBAAoB,qBAAqB;EACnF,KAAK,WAAA,GAAUC,oBAAAA,eAAAA,CAAe,mBAAmB;EACjD,KAAK,WAAW,KAAK,OAAO,KAAK,IAAI;EACrC,KAAK,OAAO,KAAK,WAAW,KAAK,IAAI;EACrC,KAAK,QAAQ,KAAK,YAAY,KAAK,IAAI;CACxC;;;;;;CAOA,SAAmC;EAClC,OAAO,CACN;GAAE,QAAQ;GAAO,MAAM,KAAK;GAAO,SAAS,KAAK;EAAK,GACtD;GAAE,QAAQ;GAAQ,MAAM,KAAK;GAAO,SAAS,KAAK;EAAM,CACzD;CACD;CAEA,OAAO,WAAwC;EAC9C,IAAI;GACH,OAAO,OAAO,KAAK,WAAW,aAAa,KAAK,OAAO,SAAS,IAAI,cAAc,KAAK;EACxF,QAAQ;GACP,OAAO;EACR;CACD;CAEA,YAAY,SAA2B;EACtC,OAAO,KAAK,OAAO,QAAQ,QAAQ,IAAIL,oBAAAA,YAAY,KAAK,KAAA,CAAS;CAClE;CAEA,WAAW,SAAkB,SAAyC;EACrE,IAAI,CAAC,KAAK,YAAY,OAAO,GAAG,OAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,IAAI,CAAC;EACzE,MAAM,OAAO,QAAQ,OAAO;EAC5B,IAAI,SAAS,KAAA,KAAa,KAAK,SAAS,SAAS,IAAI,MAAM,KAAA,GAC1D,OAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,IAAI,CAAC;EAW1C,OAAO,IATgB,mBACtB,KAAK,UACL,MACA,UAAA,GACAM,kBAAAA,aAAAA,CAAa,GACb,KAAK,UACL,KAAK,QACL,KAAK,UAEC,CAAA,CAAW,KAAK;CACxB;CAEA,MAAM,YAAY,SAAkB,SAAkD;EACrF,IAAI,CAAC,KAAK,YAAY,OAAO,GAAG,OAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,IAAI,CAAC;EACzE,MAAM,OAAO,QAAQ,OAAO;EAC5B,IAAI,SAAS,KAAA,KAAa,KAAK,SAAS,SAAS,IAAI,MAAM,KAAA,GAC1D,OAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,IAAI,CAAC;EAE1C,MAAM,QAAQ,MAAM,KAAK,UAAU,OAAO;EAC1C,IAAI,iBAAiB,UAAU,OAAO;EAEtC,MAAM,QAAA,GAAOC,oBAAAA,UAAAA,CAAU,IAAI,YAAY,CAAC,CAAC,OAAO,KAAK,CAAC;EACtD,IAAI,SAAS,KAAA,GACZ,OAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,IAAI,CAAC;EAE1C,IAAI,CAAC,KAAK,QAAQ,GAAG,IAAI,GAAG,OAAO,KAAK,QAAQ,KAAK,QAAQ,MAAM,IAAI,CAAC;EACxE,MAAM,EAAE,IAAI,WAAW;EACvB,IAAI,EAAA,GAACC,gBAAAA,aAAAA,CAAa,MAAM,GACvB,OAAO,KAAK,QACX,OAAO,QAAQ,MAAM,CAAC,CACpB,QAAQ,GAAG,WAAW,EAAA,GAACC,gBAAAA,aAAAA,CAAa,KAAK,CAAC,CAAC,CAC3C,KAAK,CAAC,KAAK,YAAY;GACvB,QAAQ;GACR,MAAM,CAAC,UAAU,GAAG;GACpB,UAAU;GACV,WAAA,GAAUV,oBAAAA,QAAAA,CAAQ,KAAK;EACxB,EAAE,CACJ;EAGD,MAAM,SAAS,KAAK,SAAS,OAAO,MAAM,IAAI,MAAM;EACpD,IAAI,OAAO,SAAS,OAAO,SAAS,KAAK,MAAM;EAC/C,IAAI,OAAO,MAAM,WAAW,UAAU,OAAO,SAAS,KAAK,QAAQ,EAAE,QAAQ,IAAI,CAAC;EAClF,OAAO,SAAS,KAAK,QAAQ,EAAE,QAAQ,IAAI,CAAC;CAC7C;CAGA,QAAQ,QAAyC;EAChD,MAAM,WAAA,GAAUW,UAAAA,aAAAA,CACf,6KAAA,GAA4KC,oBAAAA,eAAAA,CAAe,QAAQ,KAAK,QAAQ,MAAM,GACvN;EACA,OAAO,SAAS,KACf;GAAE,SAAS;GAAO,OAAO;IAAE,QAAQ;IAAa;IAAS;GAAO;EAAE,GAClE,EAAE,QAAQ,IAAI,CACf;CACD;CAEA,MAAM,UAAU,SAAkD;EACjE,IAAI;GACH,OAAO,OAAA,GAAMC,kBAAAA,mBAAAA,CAAmB,SAAS,KAAK,MAAM;EACrD,SAAS,OAAO;GACf,KAAA,GAAIC,oBAAAA,WAAAA,CAAW,OAAOC,kBAAAA,oBAAoB,GAAG,OAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,IAAI,CAAC;GACtF,MAAM;EACP;CACD;AACD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AClJA,SAAgB,qBACf,SACA,SAC2B;CAC3B,OAAO,IAAI,eAAe,SAAS,OAAO,CAAC,CAAC,OAAO;AACpD"}
@@ -1,3 +1,6 @@
1
+ import { JSONShape } from '@orkestrel/contract';
2
+ import { ObjectShape } from '@orkestrel/contract';
3
+ import { StringShape } from '@orkestrel/contract';
1
4
  import type { TerminalManagerInterface } from '@orkestrel/terminal';
2
5
  import type { TimerHandler } from '@orkestrel/terminal';
3
6
 
@@ -9,13 +12,18 @@ import type { TimerHandler } from '@orkestrel/terminal';
9
12
  * streams, the same POST url answers, the same `{ id, values }` request body, the same JSON answer
10
13
  * `Result` response body, and the same `x-orkestrel-token` header.
11
14
  *
12
- * Both routes share the configured `:name` path and optional token gate. The GET route replays
15
+ * Both routes share the configured `:name` path and the required token gate. The GET route replays
13
16
  * pending prompts, forwards live pending/expire events, and owns abort/keepalive teardown. The
14
- * POST route bounds the request body before parsing and maps answer outcomes to HTTP statuses.
17
+ * POST route bounds the request body before parsing, refuses a body outside
18
+ * {@link import('./shapers.js').terminalAnswerShape} with its faults, and maps answer outcomes to
19
+ * HTTP statuses.
15
20
  *
16
21
  * @param manager - The terminal manager whose endpoints are bridged
17
- * @param options - Route path, token, keepalive, timer, and body-limit options
22
+ * @param options - The required token, plus route path, keepalive, timer, and body-limit options
18
23
  * @returns The GET route followed by the POST route
24
+ * @throws {import('@orkestrel/toolbox').ToolboxError} Thrown with code `TOOL` for a missing or malformed
25
+ * `token`, a `keepalive` that is not a positive finite number, or a `limit` that is not a positive
26
+ * safe integer; `token: () => true` is the explicit open
19
27
  *
20
28
  * @example
21
29
  * ```ts
@@ -27,7 +35,7 @@ import type { TimerHandler } from '@orkestrel/terminal';
27
35
  * const routes = createTerminalRoutes(manager, { token: 'secret' })
28
36
  * ```
29
37
  */
30
- export declare function createTerminalRoutes(manager: TerminalManagerInterface, options?: TerminalRoutesOptions): readonly TerminalRoute[];
38
+ export declare function createTerminalRoutes(manager: TerminalManagerInterface, options: TerminalRoutesOptions): readonly TerminalRoute[];
31
39
 
32
40
  /**
33
41
  * Holds the default SSE keepalive interval {@link import('./factories.js').createTerminalRoutes}
@@ -42,6 +50,28 @@ export declare const TERMINAL_KEEPALIVE_MS = 15000;
42
50
  */
43
51
  export declare const TERMINAL_ROUTES_PATH = "/terminals/:name";
44
52
 
53
+ /**
54
+ * Describes the body a terminal POST route admits — the pending form's `id` and its answers.
55
+ *
56
+ * @remarks
57
+ * The shape closes the envelope. `@orkestrel/form`'s `isFormValues` guard still owns what one answer
58
+ * holds, so this shape does not restate the field value union.
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * import { createContract } from '@orkestrel/contract'
63
+ * import { terminalAnswerShape } from '@src/server'
64
+ *
65
+ * const contract = createContract(terminalAnswerShape)
66
+ * contract.is({ id: 'form-1', values: { name: 'Ada' } }) // true
67
+ * contract.audit({ id: 'form-1', values: {}, reply: true }) // [{ reason: 'extra', path: ['reply'] }]
68
+ * ```
69
+ */
70
+ export declare const terminalAnswerShape: ObjectShape< {
71
+ id: StringShape;
72
+ values: ObjectShape<Record<never, never>, JSONShape>;
73
+ }, false>;
74
+
45
75
  /**
46
76
  * Represents one structural route record {@link import('./factories.js').createTerminalRoutes}
47
77
  * returns — a plain `{ method, path, handler }` shape carrying no dependency on
@@ -79,32 +109,35 @@ export declare type TerminalRouteMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DE
79
109
  * Validated at GET connect, on every POST, and re-validated on every keepalive tick of a live
80
110
  * SSE stream — a stream whose presented token stops validating (rotated, expired, revoked) is
81
111
  * torn down (the abort/self-heal teardown path, no `shutdown` frame) rather than left open
82
- * forever; the client reconnects and re-authenticates. Omitted ⇒ no auth check. Because
112
+ * forever; the client reconnects and re-authenticates. Required: omitting it, or passing an empty
113
+ * string or a value that is neither a string nor a function, throws at construction, so routes
114
+ * never open by accident; `token: () => true` is the explicit open. Because
83
115
  * re-validation only happens on the keepalive tick, the revocation window equals the keepalive
84
116
  * interval — a token rejected/expired between ticks keeps streaming until the next one. A
85
117
  * validator function that throws is treated as rejection (fail-closed) at every call site.
86
- * - `keepalive` — the SSE comment-ping interval in milliseconds; defaults to
87
- * {@link import('./constants.js').TERMINAL_KEEPALIVE_MS}.
118
+ * - `keepalive` — the SSE comment-ping interval in milliseconds, a positive finite number; defaults
119
+ * to {@link import('./constants.js').TERMINAL_KEEPALIVE_MS}. Any other value throws at construction.
88
120
  * - `timer` — the injected {@link TimerHandler} driving the keepalive interval (default the host
89
121
  * `setTimeout`/`clearTimeout`), so a test drives the keepalive deterministically.
90
122
  * - `limit` — the maximum POST answer body size in bytes, streamed and enforced before JSON
91
123
  * parsing (ignoring any `Content-Length` header, so a lying header can never bypass the cap);
92
- * a body exceeding it is rejected `413` and `manager.answer` is never called. Defaults to
93
- * `@orkestrel/server`'s own `DEFAULT_BODY_LIMIT` (1 MiB).
124
+ * a body exceeding it is rejected `413` and `manager.answer` is never called. A positive safe
125
+ * integer; any other value throws at construction. Defaults to `@orkestrel/server`'s own
126
+ * `DEFAULT_BODY_LIMIT` (1 MiB).
94
127
  */
95
128
  export declare interface TerminalRoutesOptions {
96
129
  readonly path?: string;
97
- readonly token?: TerminalToken;
130
+ readonly token: TerminalToken;
98
131
  readonly keepalive?: number;
99
132
  readonly timer?: TimerHandler;
100
133
  readonly limit?: number;
101
134
  }
102
135
 
103
136
  /**
104
- * Represents the `token` gate {@link TerminalRoutesOptions} may configure — a plain string compared
137
+ * Represents the `token` gate {@link TerminalRoutesOptions} requires — a plain string compared
105
138
  * for equality against the `x-orkestrel-token` header, or a validator function the consumer fully
106
139
  * controls, enabling the expiry and rotation a fixed string cannot express (a JWT `exp` check, a
107
- * revocation-list lookup, anything time-varying). `undefined` disables the auth check entirely.
140
+ * revocation-list lookup, anything time-varying). The explicit open is the validator `() => true`.
108
141
  */
109
142
  export declare type TerminalToken = string | ((value: string | undefined) => boolean);
110
143
 
@@ -1,3 +1,6 @@
1
+ import { JSONShape } from '@orkestrel/contract';
2
+ import { ObjectShape } from '@orkestrel/contract';
3
+ import { StringShape } from '@orkestrel/contract';
1
4
  import type { TerminalManagerInterface } from '@orkestrel/terminal';
2
5
  import type { TimerHandler } from '@orkestrel/terminal';
3
6
 
@@ -9,13 +12,18 @@ import type { TimerHandler } from '@orkestrel/terminal';
9
12
  * streams, the same POST url answers, the same `{ id, values }` request body, the same JSON answer
10
13
  * `Result` response body, and the same `x-orkestrel-token` header.
11
14
  *
12
- * Both routes share the configured `:name` path and optional token gate. The GET route replays
15
+ * Both routes share the configured `:name` path and the required token gate. The GET route replays
13
16
  * pending prompts, forwards live pending/expire events, and owns abort/keepalive teardown. The
14
- * POST route bounds the request body before parsing and maps answer outcomes to HTTP statuses.
17
+ * POST route bounds the request body before parsing, refuses a body outside
18
+ * {@link import('./shapers.js').terminalAnswerShape} with its faults, and maps answer outcomes to
19
+ * HTTP statuses.
15
20
  *
16
21
  * @param manager - The terminal manager whose endpoints are bridged
17
- * @param options - Route path, token, keepalive, timer, and body-limit options
22
+ * @param options - The required token, plus route path, keepalive, timer, and body-limit options
18
23
  * @returns The GET route followed by the POST route
24
+ * @throws {import('@orkestrel/toolbox').ToolboxError} Thrown with code `TOOL` for a missing or malformed
25
+ * `token`, a `keepalive` that is not a positive finite number, or a `limit` that is not a positive
26
+ * safe integer; `token: () => true` is the explicit open
19
27
  *
20
28
  * @example
21
29
  * ```ts
@@ -27,7 +35,7 @@ import type { TimerHandler } from '@orkestrel/terminal';
27
35
  * const routes = createTerminalRoutes(manager, { token: 'secret' })
28
36
  * ```
29
37
  */
30
- export declare function createTerminalRoutes(manager: TerminalManagerInterface, options?: TerminalRoutesOptions): readonly TerminalRoute[];
38
+ export declare function createTerminalRoutes(manager: TerminalManagerInterface, options: TerminalRoutesOptions): readonly TerminalRoute[];
31
39
 
32
40
  /**
33
41
  * Holds the default SSE keepalive interval {@link import('./factories.js').createTerminalRoutes}
@@ -42,6 +50,28 @@ export declare const TERMINAL_KEEPALIVE_MS = 15000;
42
50
  */
43
51
  export declare const TERMINAL_ROUTES_PATH = "/terminals/:name";
44
52
 
53
+ /**
54
+ * Describes the body a terminal POST route admits — the pending form's `id` and its answers.
55
+ *
56
+ * @remarks
57
+ * The shape closes the envelope. `@orkestrel/form`'s `isFormValues` guard still owns what one answer
58
+ * holds, so this shape does not restate the field value union.
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * import { createContract } from '@orkestrel/contract'
63
+ * import { terminalAnswerShape } from '@src/server'
64
+ *
65
+ * const contract = createContract(terminalAnswerShape)
66
+ * contract.is({ id: 'form-1', values: { name: 'Ada' } }) // true
67
+ * contract.audit({ id: 'form-1', values: {}, reply: true }) // [{ reason: 'extra', path: ['reply'] }]
68
+ * ```
69
+ */
70
+ export declare const terminalAnswerShape: ObjectShape< {
71
+ id: StringShape;
72
+ values: ObjectShape<Record<never, never>, JSONShape>;
73
+ }, false>;
74
+
45
75
  /**
46
76
  * Represents one structural route record {@link import('./factories.js').createTerminalRoutes}
47
77
  * returns — a plain `{ method, path, handler }` shape carrying no dependency on
@@ -79,32 +109,35 @@ export declare type TerminalRouteMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DE
79
109
  * Validated at GET connect, on every POST, and re-validated on every keepalive tick of a live
80
110
  * SSE stream — a stream whose presented token stops validating (rotated, expired, revoked) is
81
111
  * torn down (the abort/self-heal teardown path, no `shutdown` frame) rather than left open
82
- * forever; the client reconnects and re-authenticates. Omitted ⇒ no auth check. Because
112
+ * forever; the client reconnects and re-authenticates. Required: omitting it, or passing an empty
113
+ * string or a value that is neither a string nor a function, throws at construction, so routes
114
+ * never open by accident; `token: () => true` is the explicit open. Because
83
115
  * re-validation only happens on the keepalive tick, the revocation window equals the keepalive
84
116
  * interval — a token rejected/expired between ticks keeps streaming until the next one. A
85
117
  * validator function that throws is treated as rejection (fail-closed) at every call site.
86
- * - `keepalive` — the SSE comment-ping interval in milliseconds; defaults to
87
- * {@link import('./constants.js').TERMINAL_KEEPALIVE_MS}.
118
+ * - `keepalive` — the SSE comment-ping interval in milliseconds, a positive finite number; defaults
119
+ * to {@link import('./constants.js').TERMINAL_KEEPALIVE_MS}. Any other value throws at construction.
88
120
  * - `timer` — the injected {@link TimerHandler} driving the keepalive interval (default the host
89
121
  * `setTimeout`/`clearTimeout`), so a test drives the keepalive deterministically.
90
122
  * - `limit` — the maximum POST answer body size in bytes, streamed and enforced before JSON
91
123
  * parsing (ignoring any `Content-Length` header, so a lying header can never bypass the cap);
92
- * a body exceeding it is rejected `413` and `manager.answer` is never called. Defaults to
93
- * `@orkestrel/server`'s own `DEFAULT_BODY_LIMIT` (1 MiB).
124
+ * a body exceeding it is rejected `413` and `manager.answer` is never called. A positive safe
125
+ * integer; any other value throws at construction. Defaults to `@orkestrel/server`'s own
126
+ * `DEFAULT_BODY_LIMIT` (1 MiB).
94
127
  */
95
128
  export declare interface TerminalRoutesOptions {
96
129
  readonly path?: string;
97
- readonly token?: TerminalToken;
130
+ readonly token: TerminalToken;
98
131
  readonly keepalive?: number;
99
132
  readonly timer?: TimerHandler;
100
133
  readonly limit?: number;
101
134
  }
102
135
 
103
136
  /**
104
- * Represents the `token` gate {@link TerminalRoutesOptions} may configure — a plain string compared
137
+ * Represents the `token` gate {@link TerminalRoutesOptions} requires — a plain string compared
105
138
  * for equality against the `x-orkestrel-token` header, or a validator function the consumer fully
106
139
  * controls, enabling the expiry and rotation a fixed string cannot express (a JWT `exp` check, a
107
- * revocation-list lookup, anything time-varying). `undefined` disables the auth check entirely.
140
+ * revocation-list lookup, anything time-varying). The explicit open is the validator `() => true`.
108
141
  */
109
142
  export declare type TerminalToken = string | ((value: string | undefined) => boolean);
110
143
 
@@ -1,7 +1,8 @@
1
+ import { createContract, describeFaults, isFiniteNumber, isFunction, isInstance, isNonEmptyString, jsonShape, objectShape, parseJSON, preview, recordShape, stringShape } from "@orkestrel/contract";
1
2
  import { HEADER_TOKEN, defaultTimer, serializeExpire, serializePending } from "@orkestrel/terminal";
2
- import { isFormValues } from "@orkestrel/form";
3
- import { isFiniteNumber, isInstance, isNonEmptyString, isRecord, parseJSON } from "@orkestrel/contract";
3
+ import { isFieldValue, isFormValues } from "@orkestrel/form";
4
4
  import { ContentTooLargeError, DEFAULT_BODY_LIMIT, collectRequestBody, createStream } from "@orkestrel/server";
5
+ import { ToolboxError, limitRefusal, resolveBound } from "../core/index.js";
5
6
  // src/server/constants.ts
6
7
  /**
7
8
  * Holds the default `:name`-templated path {@link import('./factories.js').createTerminalRoutes}
@@ -14,6 +15,31 @@ var TERMINAL_ROUTES_PATH = "/terminals/:name";
14
15
  * keeping intermediary proxies from timing out an otherwise-idle stream.
15
16
  */
16
17
  var TERMINAL_KEEPALIVE_MS = 15e3;
18
+ // src/server/shapers.ts
19
+ /**
20
+ * Describes the body a terminal POST route admits — the pending form's `id` and its answers.
21
+ *
22
+ * @remarks
23
+ * The shape closes the envelope. `@orkestrel/form`'s `isFormValues` guard still owns what one answer
24
+ * holds, so this shape does not restate the field value union.
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * import { createContract } from '@orkestrel/contract'
29
+ * import { terminalAnswerShape } from '@src/server'
30
+ *
31
+ * const contract = createContract(terminalAnswerShape)
32
+ * contract.is({ id: 'form-1', values: { name: 'Ada' } }) // true
33
+ * contract.audit({ id: 'form-1', values: {}, reply: true }) // [{ reason: 'extra', path: ['reply'] }]
34
+ * ```
35
+ */
36
+ var terminalAnswerShape = objectShape({
37
+ id: stringShape({
38
+ min: 1,
39
+ description: "The id of the pending form to answer."
40
+ }),
41
+ values: recordShape(jsonShape(), { description: "The form answers keyed by field name." })
42
+ });
17
43
  // src/server/terminals/TerminalConnection.ts
18
44
  /**
19
45
  * Owns one terminal SSE connection's replay, subscriptions, keepalive, and teardown.
@@ -141,6 +167,7 @@ var TerminalBridge = class {
141
167
  #keepalive;
142
168
  #timer;
143
169
  #limit;
170
+ #answer;
144
171
  #accepts;
145
172
  #get;
146
173
  #post;
@@ -148,16 +175,22 @@ var TerminalBridge = class {
148
175
  * Creates a terminal bridge.
149
176
  *
150
177
  * @param manager - Terminal manager bridged onto HTTP
151
- * @param options - Shared route, authorization, keepalive, timer, and body-limit options
178
+ * @param options - Required token, plus shared route, keepalive, timer, and body-limit options
179
+ * @throws {ToolboxError} Thrown with code `TOOL` for a missing or malformed `token`, a `keepalive`
180
+ * that is not a positive finite number, or a `limit` that is not a positive safe integer
152
181
  */
153
182
  constructor(manager, options) {
183
+ const token = options?.token;
184
+ if (!isNonEmptyString(token) && !isFunction(token)) throw new ToolboxError("TOOL", `terminal routes need a token; received ${preview(token)}; pass token: 'SECRET' to compare the ${HEADER_TOKEN} header, a function to decide each value, or token: () => true to leave the routes open`);
185
+ const keepalive = options.keepalive ?? 15e3;
186
+ if (!isFiniteNumber(keepalive) || keepalive <= 0) throw new ToolboxError("TOOL", `terminal keepalive must be a positive finite number of milliseconds; received ${preview(keepalive)}; omit it for the default ${String(TERMINAL_KEEPALIVE_MS)}`);
154
187
  this.#manager = manager;
155
- this.#path = options?.path ?? "/terminals/:name";
156
- this.#token = options?.token;
157
- this.#keepalive = options?.keepalive ?? 15e3;
158
- this.#timer = options?.timer ?? defaultTimer;
159
- const limit = options?.limit;
160
- this.#limit = limit === void 0 || !isFiniteNumber(limit) ? DEFAULT_BODY_LIMIT : Math.max(0, Math.floor(limit));
188
+ this.#path = options.path ?? "/terminals/:name";
189
+ this.#token = token;
190
+ this.#keepalive = keepalive;
191
+ this.#timer = options.timer ?? defaultTimer;
192
+ this.#limit = resolveBound(options.limit, DEFAULT_BODY_LIMIT, "terminal body limit");
193
+ this.#answer = createContract(terminalAnswerShape);
161
194
  this.#accepts = this.#valid.bind(this);
162
195
  this.#get = this.#handleGet.bind(this);
163
196
  this.#post = this.#handlePost.bind(this);
@@ -179,7 +212,6 @@ var TerminalBridge = class {
179
212
  }];
180
213
  }
181
214
  #valid(presented) {
182
- if (this.#token === void 0) return true;
183
215
  try {
184
216
  return typeof this.#token === "function" ? this.#token(presented) : presented === this.#token;
185
217
  } catch {
@@ -201,18 +233,35 @@ var TerminalBridge = class {
201
233
  if (name === void 0 || this.#manager.terminal(name) === void 0) return new Response(null, { status: 404 });
202
234
  const bytes = await this.#readBody(request);
203
235
  if (bytes instanceof Response) return bytes;
204
- if (bytes.byteLength > 0 && bytes.byteLength > this.#limit) return new Response(null, { status: 413 });
205
236
  const body = parseJSON(new TextDecoder().decode(bytes));
206
237
  if (body === void 0) return new Response(null, { status: 400 });
207
- if (!isRecord(body) || !isNonEmptyString(body.id) || !isFormValues(body.values)) return new Response(null, { status: 422 });
208
- const result = this.#manager.answer(name, body.id, body.values);
238
+ if (!this.#answer.is(body)) return this.#refuse(this.#answer.audit(body));
239
+ const { id, values } = body;
240
+ if (!isFormValues(values)) return this.#refuse(Object.entries(values).filter(([, value]) => !isFieldValue(value)).map(([key, value]) => ({
241
+ reason: "type",
242
+ path: ["values", key],
243
+ expected: "union",
244
+ received: preview(value)
245
+ })));
246
+ const result = this.#manager.answer(name, id, values);
209
247
  if (result.success) return Response.json(result);
210
248
  if (result.error.reason === "target") return Response.json(result, { status: 404 });
211
249
  return Response.json(result, { status: 422 });
212
250
  }
251
+ #refuse(faults) {
252
+ const message = limitRefusal(`an answer body is { id, values }, where values maps each field name to a string, a finite number, a boolean, or a list of strings; correct each line and send it again:\n${describeFaults(faults, this.#answer.schema)}`);
253
+ return Response.json({
254
+ success: false,
255
+ error: {
256
+ reason: "malformed",
257
+ message,
258
+ faults
259
+ }
260
+ }, { status: 422 });
261
+ }
213
262
  async #readBody(request) {
214
263
  try {
215
- return await collectRequestBody(request, Math.max(1, this.#limit));
264
+ return await collectRequestBody(request, this.#limit);
216
265
  } catch (error) {
217
266
  if (isInstance(error, ContentTooLargeError)) return new Response(null, { status: 413 });
218
267
  throw error;
@@ -228,13 +277,18 @@ var TerminalBridge = class {
228
277
  * streams, the same POST url answers, the same `{ id, values }` request body, the same JSON answer
229
278
  * `Result` response body, and the same `x-orkestrel-token` header.
230
279
  *
231
- * Both routes share the configured `:name` path and optional token gate. The GET route replays
280
+ * Both routes share the configured `:name` path and the required token gate. The GET route replays
232
281
  * pending prompts, forwards live pending/expire events, and owns abort/keepalive teardown. The
233
- * POST route bounds the request body before parsing and maps answer outcomes to HTTP statuses.
282
+ * POST route bounds the request body before parsing, refuses a body outside
283
+ * {@link import('./shapers.js').terminalAnswerShape} with its faults, and maps answer outcomes to
284
+ * HTTP statuses.
234
285
  *
235
286
  * @param manager - The terminal manager whose endpoints are bridged
236
- * @param options - Route path, token, keepalive, timer, and body-limit options
287
+ * @param options - The required token, plus route path, keepalive, timer, and body-limit options
237
288
  * @returns The GET route followed by the POST route
289
+ * @throws {import('@src/core').ToolboxError} Thrown with code `TOOL` for a missing or malformed
290
+ * `token`, a `keepalive` that is not a positive finite number, or a `limit` that is not a positive
291
+ * safe integer; `token: () => true` is the explicit open
238
292
  *
239
293
  * @example
240
294
  * ```ts
@@ -249,6 +303,6 @@ var TerminalBridge = class {
249
303
  function createTerminalRoutes(manager, options) {
250
304
  return new TerminalBridge(manager, options).routes();
251
305
  }
252
- export { TERMINAL_KEEPALIVE_MS, TERMINAL_ROUTES_PATH, createTerminalRoutes };
306
+ export { TERMINAL_KEEPALIVE_MS, TERMINAL_ROUTES_PATH, createTerminalRoutes, terminalAnswerShape };
253
307
 
254
308
  //# sourceMappingURL=index.js.map