@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.
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/bun.lock +35 -0
- package/package.json +51 -0
- package/src/artifacts.ts +75 -0
- package/src/capabilities/nav.ts +734 -0
- package/src/capabilities/propose.ts +456 -0
- package/src/capabilities/qmd.ts +174 -0
- package/src/capabilities/read.ts +1040 -0
- package/src/capabilities/remote-client.ts +856 -0
- package/src/capabilities/rename-recovery.ts +294 -0
- package/src/capabilities/rg.ts +505 -0
- package/src/capabilities/search.ts +1105 -0
- package/src/capabilities/tls-identity.ts +87 -0
- package/src/capabilities/update.ts +385 -0
- package/src/cli.ts +425 -0
- package/src/commands/diagnose.ts +383 -0
- package/src/commands/flags.ts +49 -0
- package/src/commands/guide-content/client.txt +36 -0
- package/src/commands/guide-content/propose.txt +38 -0
- package/src/commands/guide-content/service-qmd.txt +44 -0
- package/src/commands/guide-content/service.txt +114 -0
- package/src/commands/guide.ts +121 -0
- package/src/commands/init.ts +245 -0
- package/src/commands/inspect.ts +182 -0
- package/src/commands/inventory.ts +570 -0
- package/src/commands/kit.ts +269 -0
- package/src/commands/nav.ts +280 -0
- package/src/commands/propose.ts +257 -0
- package/src/commands/read.ts +558 -0
- package/src/commands/rg.ts +355 -0
- package/src/commands/search.ts +325 -0
- package/src/commands/serve.ts +221 -0
- package/src/commands/update.ts +99 -0
- package/src/config/client.ts +55 -0
- package/src/config/external-tool.ts +58 -0
- package/src/config/file-native.ts +111 -0
- package/src/config/manifest.ts +188 -0
- package/src/fslock.ts +95 -0
- package/src/path-safety.ts +65 -0
- package/src/registry.ts +436 -0
- package/src/scope.ts +78 -0
- package/src/server.ts +1011 -0
- 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
|
+
}
|