@catheadowl/ukp 0.1.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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +208 -0
  3. package/bun.lock +35 -0
  4. package/package.json +51 -0
  5. package/src/artifacts.ts +75 -0
  6. package/src/capabilities/nav.ts +734 -0
  7. package/src/capabilities/propose.ts +456 -0
  8. package/src/capabilities/qmd.ts +174 -0
  9. package/src/capabilities/read.ts +1040 -0
  10. package/src/capabilities/remote-client.ts +856 -0
  11. package/src/capabilities/rename-recovery.ts +294 -0
  12. package/src/capabilities/rg.ts +505 -0
  13. package/src/capabilities/search.ts +1105 -0
  14. package/src/capabilities/tls-identity.ts +87 -0
  15. package/src/capabilities/update.ts +385 -0
  16. package/src/cli.ts +425 -0
  17. package/src/commands/diagnose.ts +383 -0
  18. package/src/commands/flags.ts +49 -0
  19. package/src/commands/guide-content/client.txt +36 -0
  20. package/src/commands/guide-content/propose.txt +38 -0
  21. package/src/commands/guide-content/service-qmd.txt +44 -0
  22. package/src/commands/guide-content/service.txt +114 -0
  23. package/src/commands/guide.ts +121 -0
  24. package/src/commands/init.ts +245 -0
  25. package/src/commands/inspect.ts +182 -0
  26. package/src/commands/inventory.ts +570 -0
  27. package/src/commands/kit.ts +269 -0
  28. package/src/commands/nav.ts +280 -0
  29. package/src/commands/propose.ts +257 -0
  30. package/src/commands/read.ts +558 -0
  31. package/src/commands/rg.ts +355 -0
  32. package/src/commands/search.ts +325 -0
  33. package/src/commands/serve.ts +221 -0
  34. package/src/commands/update.ts +99 -0
  35. package/src/config/client.ts +55 -0
  36. package/src/config/external-tool.ts +58 -0
  37. package/src/config/file-native.ts +111 -0
  38. package/src/config/manifest.ts +188 -0
  39. package/src/fslock.ts +95 -0
  40. package/src/path-safety.ts +65 -0
  41. package/src/registry.ts +436 -0
  42. package/src/scope.ts +78 -0
  43. package/src/server.ts +1011 -0
  44. package/tsconfig.json +12 -0
package/src/server.ts ADDED
@@ -0,0 +1,1011 @@
1
+ import { dirname, join } from "node:path";
2
+ import { readFileSync, writeFileSync } from "node:fs";
3
+ import { ENDPOINT_NAME, loadManifest, type LoadedManifest } from "./config/manifest.ts";
4
+ import { localPathOf, readRegistry } from "./registry.ts";
5
+ import { FILE_NATIVE_CAPABILITIES, resolveFileNativeCapability } from "./config/file-native.ts";
6
+ import {
7
+ PROPOSE_SLUG,
8
+ ProposeBusyError,
9
+ ProposeProviderError,
10
+ proposeEnvelope,
11
+ proposeUpsert,
12
+ } from "./capabilities/propose.ts";
13
+ import { certSanOf, ensureSelfSignedTlsFiles, spkiPinOf } from "./capabilities/tls-identity.ts";
14
+ import {
15
+ buildInlineReferences,
16
+ projectSearchEnvelope,
17
+ runSearch,
18
+ type SearchEnvelope,
19
+ } from "./capabilities/search.ts";
20
+ import { projectReadEnvelope, runRead, type ReadRequest } from "./capabilities/read.ts";
21
+ import { parseReadArgs } from "./commands/read.ts";
22
+ import { parseNavArgs } from "./commands/nav.ts";
23
+ import {
24
+ runNav,
25
+ projectNavEnvelope,
26
+ NavUsageError,
27
+ type NavRequest,
28
+ } from "./capabilities/nav.ts";
29
+ import {
30
+ runRg,
31
+ projectRgEnvelope,
32
+ RG_DEFAULT_LIMIT,
33
+ RG_MAX_LIMIT,
34
+ validateRgPassthrough,
35
+ RgUsageError,
36
+ type ParsedRg,
37
+ } from "./capabilities/rg.ts";
38
+ import { KitUsageError } from "./commands/kit.ts";
39
+
40
+ /** ukp-remote wire v1 server core (ADR-REM-001/002/003, ukp_remote W1/W7):
41
+ * two serving shapes over one wire. Single-endpoint mode exposes ONE
42
+ * registered endpoint; door mode (`endpointName` absent, ADR-REM-004) serves
43
+ * every local binding in the registry, routed by name at `/e/<name>/…` with
44
+ * a `scope:"host"` door document — the registry is read fresh per request so
45
+ * the door grows without restart. Both shapes expose a discovery document
46
+ * (manifest projection + protocol version + instance identity) plus the
47
+ * read-side capability routes (search/read since W1, nav/rg since W6); the
48
+ * per-endpoint documents are byte-identical across modes, so client-side
49
+ * TOFU and name-assertion paths are shared. The capability layer runs
50
+ * unchanged behind the routes (provider/transport axes stay orthogonal):
51
+ * search via `runSearch` with a single-endpoint scope, read via `runRead`,
52
+ * nav via `runNav`, rg via `runRg`. There is no session, no streaming, and
53
+ * no server-side artifact: responses inline the reference data (RQ-07) and
54
+ * generate a serve-scoped run id. */
55
+
56
+ export const PROTOCOL_NAME = "ukp-remote";
57
+ export const PROTOCOL_VERSION = "1";
58
+ export const DISCOVERY_PATH = "/.well-known/ukp.json";
59
+
60
+ /** PUT /v1/propose/{id} body cap (ADR-REM-005 §5): proposals are markdown
61
+ * long-form text; 1 MiB is generous headroom and bounds serve memory. */
62
+ export const PROPOSE_BODY_LIMIT_BYTES = 1024 * 1024;
63
+
64
+ export interface ServeConfig {
65
+ /** Absent = host door mode (ADR-REM-004 / O-2): serve EVERY local binding
66
+ * in the registry over one listener, routed by name at `/e/<name>/…`, with
67
+ * a `scope:"host"` door document at the well-known path. Present = the
68
+ * original single-endpoint mode (N=1 retreat stays legal long-term). */
69
+ endpointName?: string;
70
+ currentDirectory: string;
71
+ registryPath: string;
72
+ qmdCommand?: readonly string[];
73
+ host?: string;
74
+ /** 0 (or undefined → 8570) — 0 binds an ephemeral port for tests. */
75
+ port?: number;
76
+ /** When set, /v1/* requires `Authorization: Bearer <token>` matching ANY
77
+ * listed token (RQ-16 multi-token: `UKP_SERVE_TOKEN=a,b,c`); the discovery
78
+ * document stays public (ADR-REM-003: security is declared, the card is
79
+ * readable without it). In door mode the same gate covers all
80
+ * `/e/<name>/v1/*` routes (door-level auth, O-2). */
81
+ tokens?: readonly string[];
82
+ /** TLS transport (W5' / D-079): `self-signed` generates and persists an
83
+ * identity under the Service folder (`.ukp/tls/`) — in door mode under the
84
+ * registry's directory, since a door has no single Service folder;
85
+ * `certificates` serves operator-provided PEM files (Let's Encrypt IP
86
+ * certs, mkcert, private CA). `opensslCommand` is a test injection point
87
+ * for the self-signing. */
88
+ tls?:
89
+ | { mode: "self-signed"; opensslCommand?: readonly string[] }
90
+ | { mode: "certificates"; certPath: string; keyPath: string };
91
+ }
92
+
93
+ export interface DiscoveryDocument {
94
+ protocol: typeof PROTOCOL_NAME;
95
+ protocol_version: typeof PROTOCOL_VERSION;
96
+ instance_uid: string;
97
+ name: string;
98
+ description?: string;
99
+ capabilities: Record<string, { provider: string; derived?: boolean }>;
100
+ security: { schemes: string[] };
101
+ }
102
+
103
+ /** Door discovery document (ADR-REM-004 / O-1): shares the well-known path
104
+ * with endpoint documents and self-describes via `scope:"host"` — clients
105
+ * discriminate on that field (absence = endpoint document, byte-identical to
106
+ * the pre-door era). No door-level instance_uid: the trust anchors are the
107
+ * per-endpoint pins inside `endpoints[]`. */
108
+ export interface DoorEndpointSummary {
109
+ name: string;
110
+ instance_uid: string;
111
+ capabilities: Record<string, { provider: string; derived?: boolean }>;
112
+ }
113
+
114
+ export interface DoorDocument {
115
+ protocol: typeof PROTOCOL_NAME;
116
+ protocol_version: typeof PROTOCOL_VERSION;
117
+ scope: "host";
118
+ endpoints: DoorEndpointSummary[];
119
+ security: { schemes: string[] };
120
+ }
121
+
122
+ export interface ServeInfo {
123
+ /** `"door"` when serving the whole registry (endpointName absent). */
124
+ mode: "endpoint" | "door";
125
+ /** Single-endpoint mode fields (present iff mode === "endpoint"). */
126
+ endpoint?: string;
127
+ folder?: string;
128
+ instanceUid?: string;
129
+ /** Door mode fields (present iff mode === "door"): the startup snapshot of
130
+ * servable endpoint names (the door itself grows without restart), plus
131
+ * the snapshot of write-capable (propose-declaring) names — the banner's
132
+ * "you opened a write face" line (ADR-REM-005 verdict C). */
133
+ door?: { endpoints: string[]; write: string[] };
134
+ url: string;
135
+ host: string;
136
+ port: number;
137
+ authRequired: boolean;
138
+ /** TLS identity summary (W5' / D-079) when serving over HTTPS; the pin is
139
+ * what remote clients TOFU-pin at registration. */
140
+ tls?: {
141
+ pin: string;
142
+ san: string;
143
+ source: "generated" | "persisted" | "operator";
144
+ };
145
+ }
146
+
147
+ export class ServeSetupError extends Error {
148
+ constructor(message: string) {
149
+ super(message);
150
+ this.name = "ServeSetupError";
151
+ }
152
+ }
153
+
154
+ /** Instance identity (ADR-REM-003 / RQ-05): a UUIDv4 generated at first
155
+ * serve and persisted beside the manifest. Clients pin it TOFU-style at
156
+ * binding time; equality detects service replacement. Explicit deletion is
157
+ * the only way it changes. */
158
+ function readInstanceUid(serviceFolder: string): string {
159
+ const path = join(serviceFolder, ".ukp", "instance-uid");
160
+ try {
161
+ const existing = readFileSync(path, "utf8").trim();
162
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(existing)) {
163
+ return existing;
164
+ }
165
+ } catch {
166
+ // first serve: generate below
167
+ }
168
+ const uid = crypto.randomUUID();
169
+ writeFileSync(path, `${uid}\n`, { encoding: "utf8", mode: 0o600 });
170
+ return uid;
171
+ }
172
+
173
+ /** Capability projection shared by endpoint and door documents: derived
174
+ * file-native defaults (read/nav) are always present; declared capabilities
175
+ * override or extend them (a declared nav replaces the derived entry). */
176
+ function projectCapabilities(loaded: LoadedManifest): DiscoveryDocument["capabilities"] {
177
+ const capabilities: DiscoveryDocument["capabilities"] = {};
178
+ for (const [name, spec] of Object.entries(FILE_NATIVE_CAPABILITIES)) {
179
+ if (spec.derived) capabilities[name] = { provider: "file", derived: true };
180
+ }
181
+ for (const name of Object.keys(loaded.manifest.capabilities).sort()) {
182
+ const declared = loaded.manifest.capabilities[name]!;
183
+ capabilities[name] = { provider: declared.provider ?? "file" };
184
+ }
185
+ return capabilities;
186
+ }
187
+
188
+ /** Discovery document = manifest projection + transport metadata. */
189
+ export function buildDiscoveryDocument(
190
+ loaded: LoadedManifest,
191
+ instanceUid: string,
192
+ bearerRequired: boolean,
193
+ ): DiscoveryDocument {
194
+ return {
195
+ protocol: PROTOCOL_NAME,
196
+ protocol_version: PROTOCOL_VERSION,
197
+ instance_uid: instanceUid,
198
+ name: loaded.effectiveName,
199
+ ...(loaded.manifest.description !== undefined ? { description: loaded.manifest.description } : {}),
200
+ capabilities: projectCapabilities(loaded),
201
+ security: { schemes: bearerRequired ? ["bearer"] : [] },
202
+ };
203
+ }
204
+
205
+ /** Servable endpoints of a door (O-2): every LOCAL binding in the registry,
206
+ * read fresh by the caller per request (new endpoints appear without a
207
+ * restart). Remote bindings are not servable and never make the roster. */
208
+ function listDoorEndpoints(registryPath: string): Array<{ name: string; folder: string }> {
209
+ return readRegistry(registryPath)
210
+ .filter((binding) => binding.kind !== "remote")
211
+ .map((binding) => ({ name: binding.name, folder: binding.path! }));
212
+ }
213
+
214
+ /** Startup snapshot of write-capable (propose-declaring) door endpoints —
215
+ * the banner line that makes the opened write face visible (ADR-REM-005
216
+ * verdict C). Unreadable/name-drifted Services drop out silently here;
217
+ * they are already reported by the roster/discovery paths. */
218
+ function listWriteEndpoints(registryPath: string): string[] {
219
+ return listDoorEndpoints(registryPath).flatMap((endpoint) => {
220
+ try {
221
+ const loaded = loadManifest(endpoint.folder);
222
+ return loaded.effectiveName === endpoint.name
223
+ && resolveFileNativeCapability(loaded.manifest, "propose") !== undefined
224
+ ? [endpoint.name]
225
+ : [];
226
+ } catch {
227
+ return [];
228
+ }
229
+ });
230
+ }
231
+
232
+ /** Door document (O-1): per-endpoint projection of the same fields an
233
+ * endpoint document carries (name, instance_uid, capabilities). Endpoints
234
+ * whose manifest is unreadable or whose name drifted from the binding
235
+ * (RQ-14) are omitted from the roster with a serve-side stderr note — a
236
+ * broken Service must not take the whole door document down. */
237
+ function buildDoorDocument(
238
+ endpoints: ReadonlyArray<{ name: string; folder: string }>,
239
+ bearerRequired: boolean,
240
+ ): DoorDocument {
241
+ const summaries: DoorEndpointSummary[] = [];
242
+ for (const endpoint of endpoints) {
243
+ try {
244
+ const loaded = loadManifest(endpoint.folder);
245
+ if (loaded.effectiveName !== endpoint.name) {
246
+ console.error(
247
+ `ukp serve: door endpoint '${endpoint.name}' skipped: binding resolves to a Service declaring '${loaded.effectiveName}'`,
248
+ );
249
+ continue;
250
+ }
251
+ summaries.push({
252
+ name: endpoint.name,
253
+ instance_uid: readInstanceUid(endpoint.folder),
254
+ capabilities: projectCapabilities(loaded),
255
+ });
256
+ } catch (error) {
257
+ const reason = error instanceof Error ? error.message : String(error);
258
+ console.error(`ukp serve: door endpoint '${endpoint.name}' skipped: ${reason.split("\n")[0]}`);
259
+ }
260
+ }
261
+ return {
262
+ protocol: PROTOCOL_NAME,
263
+ protocol_version: PROTOCOL_VERSION,
264
+ scope: "host",
265
+ endpoints: summaries,
266
+ security: { schemes: bearerRequired ? ["bearer"] : [] },
267
+ };
268
+ }
269
+
270
+ function jsonResponse(body: unknown, status = 200): Response {
271
+ return new Response(`${JSON.stringify(body, null, 2)}\n`, {
272
+ status,
273
+ headers: { "content-type": "application/json; charset=utf-8" },
274
+ });
275
+ }
276
+
277
+ function errorBody(className: string, message: string): { error: { class: string; message: string } } {
278
+ return { error: { class: className, message } };
279
+ }
280
+
281
+ /** Failure classification → HTTP status (ADR-REM-002 §6): the envelope's
282
+ * error class is data; the status carries the same verdict for
283
+ * non-envelope-aware middleware. */
284
+ function readHttpStatus(errorClass: string): number {
285
+ if (errorClass === "resource-missing") return 404;
286
+ if (errorClass === "provider-unavailable" || errorClass === "provider-timeout") return 503;
287
+ if (errorClass === "usage-error") return 400;
288
+ return 500;
289
+ }
290
+
291
+ /** Same verdict carrier for nav's failure vocabulary (W6): a missed route
292
+ * root is a 404, addressing a non-directory is a client fault, and the
293
+ * identity/config classes say "this service cannot answer right now". */
294
+ function navHttpStatus(errorClass: string): number {
295
+ if (errorClass === "route-root-not-found") return 404;
296
+ if (errorClass === "route-root-not-directory") return 400;
297
+ if (
298
+ errorClass === "provider-unsupported"
299
+ || errorClass === "no-endpoint"
300
+ || errorClass === "endpoint-name-mismatch"
301
+ ) {
302
+ return 503;
303
+ }
304
+ return 500;
305
+ }
306
+
307
+ function parseSearchBody(raw: unknown): { query: string; limit: number } | { error: string } {
308
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
309
+ return { error: "request body must be a JSON object: {\"query\": string, \"limit\"?: integer}" };
310
+ }
311
+ const { query, limit } = raw as { query?: unknown; limit?: unknown };
312
+ if (typeof query !== "string" || query.trim() === "") {
313
+ return { error: "field 'query' must be a non-empty string" };
314
+ }
315
+ if (limit === undefined) return { query, limit: 20 };
316
+ if (typeof limit !== "number" || !Number.isSafeInteger(limit) || limit < 1 || limit > 1000) {
317
+ return { error: "field 'limit' must be an integer between 1 and 1000" };
318
+ }
319
+ return { query, limit };
320
+ }
321
+
322
+ /** Wire read params → ReadRequest via the command-layer parser (single
323
+ * source for pin form, line-window syntax, and ukp:// decoding — commands/
324
+ * read.ts). Serve fixes the endpoint: a `uri` carrying a different endpoint
325
+ * is an identity mismatch (RQ-14 symmetry), not a miss. */
326
+ function parseReadParams(
327
+ endpointName: string,
328
+ ref: string | null,
329
+ uri: string | null,
330
+ lines: string | null,
331
+ pin: string | null,
332
+ ): { request: ReadRequest } | { error: string; class?: string } {
333
+ if ((ref === null) === (uri === null)) {
334
+ return { error: "exactly one of 'ref' (endpoint-relative) or 'uri' (ukp://…) is required" };
335
+ }
336
+ const args = uri !== null
337
+ ? [
338
+ uri,
339
+ ...(lines !== null ? ["--lines", lines] : []),
340
+ ...(pin !== null ? ["--pin", pin] : []),
341
+ ]
342
+ : [
343
+ "--endpoint",
344
+ endpointName,
345
+ ref!,
346
+ ...(lines !== null ? ["--lines", lines] : []),
347
+ ...(pin !== null ? ["--pin", pin] : []),
348
+ ];
349
+ let request: ReadRequest;
350
+ try {
351
+ request = parseReadArgs(args);
352
+ } catch (error) {
353
+ if (error instanceof KitUsageError) return { error: error.message };
354
+ throw error;
355
+ }
356
+ if (request.endpoint !== undefined && request.endpoint !== endpointName) {
357
+ return {
358
+ error: `uri endpoint '${request.endpoint}' does not match served endpoint '${endpointName}'`,
359
+ class: "identity-mismatch",
360
+ };
361
+ }
362
+ return { request };
363
+ }
364
+
365
+ /** Wire nav params → NavRequest via the command-layer parser (same
366
+ * single-source stance as read: depth form/range and route-path lexical
367
+ * validation live in one place). Serve fixes the endpoint. */
368
+ function parseNavParams(
369
+ endpointName: string,
370
+ path: string | null,
371
+ depth: string | null,
372
+ ): { request: NavRequest } | { error: string } {
373
+ const args = [
374
+ "--endpoint",
375
+ endpointName,
376
+ ...(path !== null ? [path] : []),
377
+ ...(depth !== null ? ["--depth", depth] : []),
378
+ ];
379
+ try {
380
+ return { request: parseNavArgs(args) };
381
+ } catch (error) {
382
+ if (error instanceof KitUsageError || error instanceof NavUsageError) {
383
+ return { error: error.message };
384
+ }
385
+ throw error;
386
+ }
387
+ }
388
+
389
+ /** Wire rg params → ParsedRg with serve-fixed scope. Params are flat (no
390
+ * argv reconstruction needed): query/limit/glob/type/i/count map onto
391
+ * ParsedRg directly and passthrough re-validates against the same
392
+ * allowlist (ADR-RG-002) before any endpoint work starts. */
393
+ function parseRgParams(
394
+ endpointName: string,
395
+ url: URL,
396
+ ): { parsed: ParsedRg } | { error: string } {
397
+ const query = url.searchParams.get("query");
398
+ if (query === null || query.length === 0) {
399
+ return { error: "'query' is required and must be a non-empty pattern" };
400
+ }
401
+ let limit = RG_DEFAULT_LIMIT;
402
+ const rawLimit = url.searchParams.get("limit");
403
+ if (rawLimit !== null) {
404
+ if (!/^[0-9]+$/.test(rawLimit)) return { error: "'limit' must be a decimal integer" };
405
+ limit = Number(rawLimit);
406
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > RG_MAX_LIMIT) {
407
+ return { error: `'limit' must be between 1 and ${RG_MAX_LIMIT}` };
408
+ }
409
+ }
410
+ const passthrough = url.searchParams.getAll("passthrough");
411
+ try {
412
+ validateRgPassthrough(passthrough);
413
+ } catch (error) {
414
+ if (error instanceof RgUsageError) return { error: error.message };
415
+ throw error;
416
+ }
417
+ // Boolean params are strictly "1"/"0" — same strictness as query/limit, so
418
+ // a hand-crafted `i=true` is a visible usage error, not a silent mode miss.
419
+ const booleanOf = (name: string): boolean | { error: string } => {
420
+ const raw = url.searchParams.get(name);
421
+ if (raw === null) return false;
422
+ if (raw === "1") return true;
423
+ if (raw === "0") return false;
424
+ return { error: `'${name}' must be 1 or 0` };
425
+ };
426
+ const ignoreCase = booleanOf("i");
427
+ if (typeof ignoreCase === "object") return ignoreCase;
428
+ const count = booleanOf("count");
429
+ if (typeof count === "object") return count;
430
+ const glob = url.searchParams.get("glob");
431
+ const type = url.searchParams.get("type");
432
+ return {
433
+ parsed: {
434
+ request: { query, limit },
435
+ options: {
436
+ explicitEndpoints: [endpointName],
437
+ global: false,
438
+ ...(glob !== null ? { glob } : {}),
439
+ ...(type !== null ? { type } : {}),
440
+ ...(ignoreCase ? { ignoreCase: true } : {}),
441
+ ...(count ? { count: true } : {}),
442
+ passthrough,
443
+ },
444
+ warnings: [],
445
+ },
446
+ };
447
+ }
448
+
449
+ export interface StartedServe {
450
+ server: ReturnType<typeof Bun.serve>;
451
+ info: ServeInfo;
452
+ }
453
+
454
+ /** TLS material resolution (W5' / D-079), shared by both serve modes: a
455
+ * self-signed identity is generated/persisted under `identityDir` (the
456
+ * Service folder's `.ukp/tls/` in endpoint mode, the registry directory in
457
+ * door mode — a door has no single Service folder), explicit certificates
458
+ * are read from the operator's paths. Bun.serve takes PEM contents
459
+ * (path-string handling is platform-dependent). */
460
+ function resolveTlsMaterial(
461
+ config: ServeConfig,
462
+ identityDir: string,
463
+ ): { cert: string; key: string; pin: string; san: string; source: "generated" | "persisted" | "operator" } | undefined {
464
+ if (config.tls === undefined) return undefined;
465
+ const tls = config.tls;
466
+ const paths = tls.mode === "self-signed"
467
+ ? (() => {
468
+ const identity = ensureSelfSignedTlsFiles(
469
+ identityDir,
470
+ tls.mode === "self-signed" && tls.opensslCommand !== undefined ? { opensslCommand: tls.opensslCommand } : {},
471
+ );
472
+ return {
473
+ certPath: identity.certPath,
474
+ keyPath: identity.keyPath,
475
+ source: identity.created ? ("generated" as const) : ("persisted" as const),
476
+ };
477
+ })()
478
+ : { certPath: tls.certPath, keyPath: tls.keyPath, source: "operator" as const };
479
+ try {
480
+ const cert = readFileSync(paths.certPath, "utf8");
481
+ return {
482
+ cert,
483
+ key: readFileSync(paths.keyPath, "utf8"),
484
+ pin: spkiPinOf(cert),
485
+ san: certSanOf(cert),
486
+ source: paths.source,
487
+ };
488
+ } catch (error) {
489
+ const reason = error instanceof Error ? error.message : String(error);
490
+ throw new ServeSetupError(`TLS material is unusable (${paths.certPath}): ${reason}`);
491
+ }
492
+ }
493
+
494
+ function bearerGate(
495
+ request: Request,
496
+ tokens: readonly string[] | undefined,
497
+ ): Response | undefined {
498
+ if ((tokens?.length ?? 0) === 0) return undefined;
499
+ const authorization = request.headers.get("authorization");
500
+ if (!tokens!.some((token) => authorization === `Bearer ${token}`)) {
501
+ return jsonResponse(
502
+ errorBody("auth-failure", "missing or invalid bearer token (Authorization: Bearer <token>)"),
503
+ 401,
504
+ );
505
+ }
506
+ return undefined;
507
+ }
508
+
509
+ /** Per-request endpoint resolution for door routing (O-2): the registry is
510
+ * read fresh, the name segment has already passed ENDPOINT_NAME (injection
511
+ * unreachable), unknown and non-servable names get the roster 404, and the
512
+ * RQ-14 binding↔manifest identity check runs per request like the
513
+ * single-mode discovery fetch does. */
514
+ function resolveDoorRoute(
515
+ registryPath: string,
516
+ name: string,
517
+ ): { folder: string } | { response: Response } {
518
+ let binding;
519
+ try {
520
+ binding = readRegistry(registryPath).find((entry) => entry.name === name);
521
+ } catch (error) {
522
+ const reason = error instanceof Error ? error.message : String(error);
523
+ return { response: jsonResponse(errorBody("provider-unavailable", `Host Registry is not readable: ${reason}`), 503) };
524
+ }
525
+ if (binding === undefined || binding.kind === "remote") {
526
+ const roster = listDoorEndpoints(registryPath).map((endpoint) => endpoint.name).join(", ");
527
+ return {
528
+ response: jsonResponse(
529
+ errorBody("not-found", `no such endpoint '${name}' on this host door (available: ${roster})`),
530
+ 404,
531
+ ),
532
+ };
533
+ }
534
+ let folder: string;
535
+ try {
536
+ folder = localPathOf(binding);
537
+ } catch (error) {
538
+ const reason = error instanceof Error ? error.message : String(error);
539
+ return { response: jsonResponse(errorBody("provider-unavailable", reason), 503) };
540
+ }
541
+ try {
542
+ const loaded = loadManifest(folder);
543
+ if (loaded.effectiveName !== name) {
544
+ return {
545
+ response: jsonResponse(
546
+ errorBody(
547
+ "identity-mismatch",
548
+ `binding '${name}' resolves to a Service declaring '${loaded.effectiveName}'`,
549
+ ),
550
+ 503,
551
+ ),
552
+ };
553
+ }
554
+ } catch (error) {
555
+ const reason = error instanceof Error ? error.message : String(error);
556
+ return { response: jsonResponse(errorBody("provider-unavailable", `Service Manifest is not readable: ${reason}`), 503) };
557
+ }
558
+ return { folder };
559
+ }
560
+
561
+ /** Door-mode routing table (ADR-REM-004 / O-2). The door document and the
562
+ * per-endpoint documents stay public (ADR-REM-003 declaration/capability
563
+ * divide); every `/e/<name>/v1/*` route passes the door-level bearer gate. */
564
+ function doorNotFound(name: string | undefined, registryPath: string): Response {
565
+ if (name !== undefined) {
566
+ const roster = listDoorEndpoints(registryPath).map((endpoint) => endpoint.name).join(", ");
567
+ return jsonResponse(
568
+ errorBody("not-found", `no such endpoint '${name}' on this host door (available: ${roster})`),
569
+ 404,
570
+ );
571
+ }
572
+ return jsonResponse(
573
+ errorBody(
574
+ "not-found",
575
+ `no such route (ukp-remote v1 host door: GET ${DISCOVERY_PATH}, GET /e/<name>${DISCOVERY_PATH}, POST /e/<name>/v1/search, GET /e/<name>/v1/read, GET /e/<name>/v1/nav, GET /e/<name>/v1/rg, PUT /e/<name>/v1/propose/<id>)`,
576
+ ),
577
+ 404,
578
+ );
579
+ }
580
+
581
+ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnType<typeof resolveTlsMaterial>): StartedServe {
582
+ const handler = async (request: Request): Promise<Response> => {
583
+ const url = new URL(request.url);
584
+
585
+ if (url.pathname === DISCOVERY_PATH) {
586
+ if (request.method !== "GET") {
587
+ return jsonResponse(errorBody("method-not-allowed", `the discovery document is a GET resource`), 405);
588
+ }
589
+ try {
590
+ return jsonResponse(buildDoorDocument(listDoorEndpoints(config.registryPath), (config.tokens?.length ?? 0) > 0));
591
+ } catch (error) {
592
+ const reason = error instanceof Error ? error.message : String(error);
593
+ return jsonResponse(errorBody("provider-unavailable", `Host Registry is not readable: ${reason}`), 503);
594
+ }
595
+ }
596
+
597
+ // /e/<name>/… — the name segment is validated against ENDPOINT_NAME
598
+ // before any filesystem work; anything else cannot name an endpoint.
599
+ const routeMatch = url.pathname.match(/^\/e\/([^/]+)(\/.*)?$/);
600
+ if (routeMatch !== null) {
601
+ const name = routeMatch[1]!;
602
+ const rest = routeMatch[2] ?? "/";
603
+ if (!ENDPOINT_NAME.test(name)) {
604
+ return doorNotFound(name, config.registryPath);
605
+ }
606
+ if (rest === DISCOVERY_PATH) {
607
+ if (request.method !== "GET") {
608
+ return jsonResponse(errorBody("method-not-allowed", `the discovery document is a GET resource`), 405);
609
+ }
610
+ const resolved = resolveDoorRoute(config.registryPath, name);
611
+ if ("response" in resolved) return resolved.response;
612
+ try {
613
+ const loaded = loadManifest(resolved.folder);
614
+ return jsonResponse(
615
+ buildDiscoveryDocument(loaded, readInstanceUid(resolved.folder), (config.tokens?.length ?? 0) > 0),
616
+ );
617
+ } catch (error) {
618
+ const reason = error instanceof Error ? error.message : String(error);
619
+ return jsonResponse(errorBody("provider-unavailable", `Service Manifest is not readable: ${reason}`), 503);
620
+ }
621
+ }
622
+ if (rest.startsWith("/v1/propose/")) {
623
+ const denied = bearerGate(request, config.tokens);
624
+ if (denied !== undefined) return denied;
625
+ const id = rest.slice("/v1/propose/".length);
626
+ if (id.includes("/")) return doorNotFound(undefined, config.registryPath);
627
+ const resolved = resolveDoorRoute(config.registryPath, name);
628
+ if ("response" in resolved) return resolved.response;
629
+ return await handlePropose(request, config, name, resolved.folder, id);
630
+ }
631
+ if (rest === "/v1/search" || rest === "/v1/read" || rest === "/v1/nav" || rest === "/v1/rg") {
632
+ const denied = bearerGate(request, config.tokens);
633
+ if (denied !== undefined) return denied;
634
+ const resolved = resolveDoorRoute(config.registryPath, name);
635
+ if ("response" in resolved) return resolved.response;
636
+ if (rest === "/v1/search") {
637
+ return await handleSearch(request, config, name, resolved.folder);
638
+ }
639
+ if (rest === "/v1/read") {
640
+ return handleRead(url, request, config, name);
641
+ }
642
+ if (rest === "/v1/nav") {
643
+ return handleNav(url, request, config, name);
644
+ }
645
+ return handleRg(url, request, config, name);
646
+ }
647
+ return doorNotFound(undefined, config.registryPath);
648
+ }
649
+
650
+ return doorNotFound(undefined, config.registryPath);
651
+ };
652
+
653
+ const server = Bun.serve({
654
+ hostname: host,
655
+ port: config.port ?? 8570,
656
+ ...(tlsMaterial !== undefined ? { tls: { cert: tlsMaterial.cert, key: tlsMaterial.key } } : {}),
657
+ fetch: handler,
658
+ });
659
+
660
+ const info: ServeInfo = {
661
+ mode: "door",
662
+ door: {
663
+ endpoints: listDoorEndpoints(config.registryPath).map((endpoint) => endpoint.name),
664
+ write: listWriteEndpoints(config.registryPath),
665
+ },
666
+ url: `${tlsMaterial !== undefined ? "https" : "http"}://${host}:${server.port ?? (config.port ?? 8570)}`,
667
+ host,
668
+ port: server.port ?? (config.port ?? 8570),
669
+ authRequired: (config.tokens?.length ?? 0) > 0,
670
+ ...(tlsMaterial !== undefined
671
+ ? { tls: { pin: tlsMaterial.pin, san: tlsMaterial.san, source: tlsMaterial.source } }
672
+ : {}),
673
+ };
674
+ return { server, info };
675
+ }
676
+
677
+ export function startUkpServer(config: ServeConfig): StartedServe {
678
+ const host = config.host ?? "127.0.0.1";
679
+
680
+ if (config.endpointName === undefined) {
681
+ // Door mode: no single binding to resolve; the registry is read per
682
+ // request (new endpoints appear without restart, O-2).
683
+ const tlsMaterial = resolveTlsMaterial(config, join(dirname(config.registryPath), "tls"));
684
+ return startDoorServer(config, host, tlsMaterial);
685
+ }
686
+
687
+ const registry = readRegistry(config.registryPath);
688
+ const binding = registry.find((entry) => entry.name === config.endpointName);
689
+ if (binding === undefined) {
690
+ throw new ServeSetupError(
691
+ `endpoint '${config.endpointName}' is not registered (Host Registry: ${config.registryPath}); run 'ukp list' to inspect registrations`,
692
+ );
693
+ }
694
+ // serve exposes a local Service folder; remote bindings are refused here.
695
+ const serviceFolder = localPathOf(binding);
696
+ // RQ-14 symmetry: the served binding name must equal the manifest's
697
+ // effective name, mirroring the local `effectiveName === binding.name`
698
+ // assertion — checked at startup and on every discovery fetch.
699
+ const startupManifest = loadManifest(serviceFolder);
700
+ if (startupManifest.effectiveName !== binding.name) {
701
+ throw new ServeSetupError(
702
+ `identity mismatch: binding '${binding.name}' resolves to a Service declaring '${startupManifest.effectiveName}'`,
703
+ );
704
+ }
705
+
706
+ const tlsMaterial = resolveTlsMaterial(config, join(serviceFolder, ".ukp", "tls"));
707
+
708
+ const handler = async (request: Request): Promise<Response> => {
709
+ const url = new URL(request.url);
710
+
711
+ if (url.pathname === DISCOVERY_PATH) {
712
+ if (request.method !== "GET") {
713
+ return jsonResponse(errorBody("method-not-allowed", `the discovery document is a GET resource`), 405);
714
+ }
715
+ try {
716
+ // Fresh manifest per fetch: same per-invocation freshness as the
717
+ // local file-based loader (no server-side caching in v1).
718
+ const loaded = loadManifest(serviceFolder);
719
+ if (loaded.effectiveName !== binding.name) {
720
+ return jsonResponse(
721
+ errorBody(
722
+ "identity-mismatch",
723
+ `binding '${binding.name}' now resolves to a Service declaring '${loaded.effectiveName}'`,
724
+ ),
725
+ 503,
726
+ );
727
+ }
728
+ return jsonResponse(
729
+ buildDiscoveryDocument(loaded, readInstanceUid(serviceFolder), (config.tokens?.length ?? 0) > 0),
730
+ );
731
+ } catch (error) {
732
+ return jsonResponse(
733
+ errorBody("provider-unavailable", `Service Manifest is not readable: ${error instanceof Error ? error.message : String(error)}`),
734
+ 503,
735
+ );
736
+ }
737
+ }
738
+
739
+ if (url.pathname.startsWith("/v1/propose/")) {
740
+ const denied = bearerGate(request, config.tokens);
741
+ if (denied !== undefined) return denied;
742
+ const id = url.pathname.slice("/v1/propose/".length);
743
+ if (id.includes("/")) {
744
+ return jsonResponse(
745
+ errorBody(
746
+ "not-found",
747
+ `no such route '${url.pathname}' (ukp-remote v1: GET ${DISCOVERY_PATH}, POST /v1/search, GET /v1/read, GET /v1/nav, GET /v1/rg, PUT /v1/propose/<id>)`,
748
+ ),
749
+ 404,
750
+ );
751
+ }
752
+ return await handlePropose(request, config, binding.name, serviceFolder, id);
753
+ }
754
+
755
+ if (
756
+ url.pathname === "/v1/search"
757
+ || url.pathname === "/v1/read"
758
+ || url.pathname === "/v1/nav"
759
+ || url.pathname === "/v1/rg"
760
+ ) {
761
+ const denied = bearerGate(request, config.tokens);
762
+ if (denied !== undefined) return denied;
763
+ if (url.pathname === "/v1/search") {
764
+ return await handleSearch(request, config, binding.name, serviceFolder);
765
+ }
766
+ if (url.pathname === "/v1/read") {
767
+ return handleRead(url, request, config, binding.name);
768
+ }
769
+ if (url.pathname === "/v1/nav") {
770
+ return handleNav(url, request, config, binding.name);
771
+ }
772
+ return handleRg(url, request, config, binding.name);
773
+ }
774
+
775
+ return jsonResponse(
776
+ errorBody(
777
+ "not-found",
778
+ `no such route '${url.pathname}' (ukp-remote v1: GET ${DISCOVERY_PATH}, POST /v1/search, GET /v1/read, GET /v1/nav, GET /v1/rg, PUT /v1/propose/<id>)`,
779
+ ),
780
+ 404,
781
+ );
782
+ };
783
+
784
+ const server = Bun.serve({
785
+ hostname: host,
786
+ port: config.port ?? 8570,
787
+ ...(tlsMaterial !== undefined ? { tls: { cert: tlsMaterial.cert, key: tlsMaterial.key } } : {}),
788
+ fetch: handler,
789
+ });
790
+
791
+ const info: ServeInfo = {
792
+ mode: "endpoint",
793
+ endpoint: binding.name,
794
+ folder: serviceFolder,
795
+ instanceUid: readInstanceUid(serviceFolder),
796
+ url: `${tlsMaterial !== undefined ? "https" : "http"}://${host}:${server.port ?? (config.port ?? 8570)}`,
797
+ host,
798
+ port: server.port ?? (config.port ?? 8570),
799
+ authRequired: (config.tokens?.length ?? 0) > 0,
800
+ ...(tlsMaterial !== undefined
801
+ ? { tls: { pin: tlsMaterial.pin, san: tlsMaterial.san, source: tlsMaterial.source } }
802
+ : {}),
803
+ };
804
+ return { server, info };
805
+ }
806
+
807
+ async function handleSearch(
808
+ request: Request,
809
+ config: ServeConfig,
810
+ endpointName: string,
811
+ endpointFolder: string,
812
+ ): Promise<Response> {
813
+ if (request.method !== "POST") {
814
+ return jsonResponse(errorBody("method-not-allowed", "/v1/search is a POST route"), 405);
815
+ }
816
+ let raw: unknown;
817
+ try {
818
+ raw = await request.json();
819
+ } catch {
820
+ return jsonResponse(errorBody("usage-error", "request body must be valid JSON"), 400);
821
+ }
822
+ const parsedBody = parseSearchBody(raw);
823
+ if ("error" in parsedBody) {
824
+ return jsonResponse(errorBody("usage-error", parsedBody.error), 400);
825
+ }
826
+
827
+ const result = runSearch(
828
+ {
829
+ request: { query: parsedBody.query, limit: parsedBody.limit },
830
+ options: { explicitEndpoints: [endpointName], global: false, recursive: false, json: false },
831
+ warnings: [],
832
+ },
833
+ {
834
+ currentDirectory: config.currentDirectory,
835
+ registryPath: config.registryPath,
836
+ qmdCommand: config.qmdCommand,
837
+ },
838
+ );
839
+ const envelope: SearchEnvelope = {
840
+ ...projectSearchEnvelope(result),
841
+ // No artifact run server-side (RQ-07): the run id is serve-scoped.
842
+ run_id: `serve-${crypto.randomUUID()}`,
843
+ };
844
+ const first = result.endpoints[0];
845
+ const references = first !== undefined && first.folder !== undefined && typeof first.providerOutput === "string"
846
+ ? buildInlineReferences(endpointName, first.folder, first.providerOutput)
847
+ : undefined;
848
+ // Provider-native results array (ADR-REM-002 W2 amendment): the client's
849
+ // human renderer needs result-unit material (title/snippet) that the
850
+ // references mapping does not carry — this is providerOutput's wire form.
851
+ let results: unknown[] | undefined;
852
+ if (typeof first?.providerOutput === "string") {
853
+ try {
854
+ const parsed = JSON.parse(first.providerOutput);
855
+ if (Array.isArray(parsed)) results = parsed;
856
+ } catch {
857
+ // Non-JSON provider output (fallback render path locally) — omit.
858
+ }
859
+ }
860
+ return jsonResponse({
861
+ ...envelope,
862
+ ...(references !== undefined ? { references } : {}),
863
+ ...(results !== undefined ? { results } : {}),
864
+ });
865
+ }
866
+
867
+ function handleRead(url: URL, request: Request, config: ServeConfig, endpointName: string): Response {
868
+ if (request.method !== "GET") {
869
+ return jsonResponse(errorBody("method-not-allowed", "/v1/read is a GET route"), 405);
870
+ }
871
+ const parsed = parseReadParams(
872
+ endpointName,
873
+ url.searchParams.get("ref"),
874
+ url.searchParams.get("uri"),
875
+ url.searchParams.get("lines"),
876
+ url.searchParams.get("pin"),
877
+ );
878
+ if ("error" in parsed) {
879
+ return jsonResponse(errorBody(parsed.class ?? "usage-error", parsed.error), 400);
880
+ }
881
+ const outcome = runRead(parsed.request, {
882
+ currentDirectory: config.currentDirectory,
883
+ registryPath: config.registryPath,
884
+ qmdCommand: config.qmdCommand,
885
+ });
886
+ const envelope = projectReadEnvelope(parsed.request, outcome);
887
+ if (outcome.ok) {
888
+ return jsonResponse({ ...envelope, content: outcome.result.content });
889
+ }
890
+ return jsonResponse(envelope, readHttpStatus(envelope.error?.class ?? "error"));
891
+ }
892
+
893
+ /** GET /v1/nav?path=&depth= → the nav envelope IS the response (W6): the
894
+ * client renders it through the same renderNavHuman/renderNavJson path as a
895
+ * local run. Failures use the transport error shape with nav's classes. */
896
+ function handleNav(url: URL, request: Request, config: ServeConfig, endpointName: string): Response {
897
+ if (request.method !== "GET") {
898
+ return jsonResponse(errorBody("method-not-allowed", "/v1/nav is a GET route"), 405);
899
+ }
900
+ const parsed = parseNavParams(endpointName, url.searchParams.get("path"), url.searchParams.get("depth"));
901
+ if ("error" in parsed) {
902
+ return jsonResponse(errorBody("usage-error", parsed.error), 400);
903
+ }
904
+ const outcome = runNav(parsed.request, {
905
+ currentDirectory: config.currentDirectory,
906
+ registryPath: config.registryPath,
907
+ });
908
+ if (outcome.ok) {
909
+ return jsonResponse(projectNavEnvelope(outcome.result));
910
+ }
911
+ return jsonResponse(
912
+ errorBody(outcome.failure.errorClass, outcome.failure.message),
913
+ navHttpStatus(outcome.failure.errorClass),
914
+ );
915
+ }
916
+
917
+ /** GET /v1/rg?query=… → the single-endpoint rg envelope (W6). Failures are
918
+ * per-endpoint data inside the envelope (D-036), so the route always
919
+ * answers 200 — same stance as /v1/search. */
920
+ function handleRg(url: URL, request: Request, config: ServeConfig, endpointName: string): Response {
921
+ if (request.method !== "GET") {
922
+ return jsonResponse(errorBody("method-not-allowed", "/v1/rg is a GET route"), 405);
923
+ }
924
+ const parsed = parseRgParams(endpointName, url);
925
+ if ("error" in parsed) {
926
+ return jsonResponse(errorBody("usage-error", parsed.error), 400);
927
+ }
928
+ const result = runRg(parsed.parsed, {
929
+ currentDirectory: config.currentDirectory,
930
+ registryPath: config.registryPath,
931
+ });
932
+ return jsonResponse(projectRgEnvelope(result, parsed.parsed.options.count === true));
933
+ }
934
+
935
+ /** PUT /v1/propose/{id} (W8 / ADR-REM-005): the write face. Body = the
936
+ * proposal text itself (UTF-8); the response IS the ukp.propose.v1
937
+ * envelope, byte-equivalent to the CLI --json rendering. Wire guards: slug
938
+ * re-validation (422), body cap (413), strict UTF-8 (415); the write-face
939
+ * scope is the manifest declaration (RQ-23) — an endpoint not declaring
940
+ * propose has no writable route (503 capability-undeclared). */
941
+ async function handlePropose(
942
+ request: Request,
943
+ _config: ServeConfig,
944
+ endpointName: string,
945
+ endpointFolder: string,
946
+ id: string,
947
+ ): Promise<Response> {
948
+ if (request.method !== "PUT") {
949
+ return jsonResponse(errorBody("method-not-allowed", "/v1/propose/<id> is a PUT route"), 405);
950
+ }
951
+ if (!PROPOSE_SLUG.test(id)) {
952
+ return jsonResponse(
953
+ errorBody("usage-error", `invalid proposal id '${id}': expected 1-63 lowercase ASCII slug characters ([a-z0-9-])`),
954
+ 422,
955
+ );
956
+ }
957
+ // Size gate before reading (declared length) and after (truth) — the
958
+ // content-length header can be absent or a lie; the post-read check is
959
+ // the authoritative bound (the transport-level buffer ceiling is
960
+ // Bun.serve's default request-body cap, not this limit).
961
+ const declaredLength = Number(request.headers.get("content-length") ?? "0");
962
+ if (Number.isFinite(declaredLength) && declaredLength > PROPOSE_BODY_LIMIT_BYTES) {
963
+ return jsonResponse(errorBody("payload-too-large", `proposal body exceeds ${PROPOSE_BODY_LIMIT_BYTES} bytes`), 413);
964
+ }
965
+ let buffer: ArrayBuffer;
966
+ try {
967
+ buffer = await request.arrayBuffer();
968
+ } catch (error) {
969
+ const reason = error instanceof Error ? error.message : String(error);
970
+ return jsonResponse(errorBody("usage-error", `cannot read proposal body: ${reason}`), 400);
971
+ }
972
+ if (buffer.byteLength > PROPOSE_BODY_LIMIT_BYTES) {
973
+ return jsonResponse(errorBody("payload-too-large", `proposal body exceeds ${PROPOSE_BODY_LIMIT_BYTES} bytes`), 413);
974
+ }
975
+ let content: string;
976
+ try {
977
+ content = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
978
+ } catch {
979
+ return jsonResponse(errorBody("unsupported-content-type", "proposal body must be valid UTF-8 text"), 415);
980
+ }
981
+
982
+ let loaded: LoadedManifest;
983
+ try {
984
+ loaded = loadManifest(endpointFolder);
985
+ } catch (error) {
986
+ const reason = error instanceof Error ? error.message : String(error);
987
+ return jsonResponse(errorBody("provider-unavailable", `Service Manifest is not readable: ${reason}`), 503);
988
+ }
989
+ const resolved = resolveFileNativeCapability(loaded.manifest, "propose");
990
+ if (!resolved) {
991
+ return jsonResponse(
992
+ errorBody("capability-undeclared", `endpoint '${endpointName}' does not declare the propose capability`),
993
+ 503,
994
+ );
995
+ }
996
+
997
+ try {
998
+ const result = proposeUpsert(endpointFolder, resolved.capability, id, content);
999
+ return jsonResponse(proposeEnvelope(endpointName, result));
1000
+ } catch (error) {
1001
+ if (error instanceof ProposeBusyError || error instanceof ProposeProviderError) {
1002
+ // Same-family transient/provider failures on the write path (lock
1003
+ // contention, folder config, stored-document corruption): the
1004
+ // message is factual data ("retry shortly" for busy) and maps to
1005
+ // the provider-failed transport class (RQ-09).
1006
+ return jsonResponse(errorBody("provider-failed", error.message), 503);
1007
+ }
1008
+ const reason = error instanceof Error ? error.message : String(error);
1009
+ return jsonResponse(errorBody("provider-failed", `proposal submission failed: ${reason}`), 500);
1010
+ }
1011
+ }