@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
@@ -0,0 +1,856 @@
1
+ import { connect as netConnect, isIP } from "node:net";
2
+ import { connect as tlsConnect } from "node:tls";
3
+ import { X509Certificate } from "node:crypto";
4
+ import {
5
+ DISCOVERY_PATH,
6
+ PROTOCOL_NAME,
7
+ type DiscoveryDocument,
8
+ type DoorDocument,
9
+ type DoorEndpointSummary,
10
+ } from "../server.ts";
11
+ import {
12
+ assertRemoteUrlAllowed,
13
+ parseRemoteUrl,
14
+ refreshRemoteTlsCert,
15
+ type RegistryBinding,
16
+ type RemoteUrlParts,
17
+ } from "../registry.ts";
18
+ import { spkiPinOf } from "./tls-identity.ts";
19
+ import type { SearchEndpointOutcome } from "./search.ts";
20
+ import type { NavEnvelope } from "./nav.ts";
21
+ import type { ProposeResult, ProposeStatus } from "./propose.ts";
22
+ import { EXTERNAL_PROVIDER } from "../config/external-tool.ts";
23
+ import type { RgEndpointOutcome, RgMatch, RgCountEntry } from "./rg.ts";
24
+
25
+ /** Remote transport for the client side (ukp-remote wire v1, ADR-REM-002/003;
26
+ * ukp_remote W2). All calls fetch fresh (no cross-invocation cache), carry the
27
+ * endpoint token when configured, and map transport failures onto the
28
+ * existing capability failure vocabulary. */
29
+
30
+ export class RemoteTransportError extends Error {
31
+ constructor(message: string) {
32
+ super(message);
33
+ this.name = "RemoteTransportError";
34
+ }
35
+ }
36
+
37
+ /** `UKP_ENDPOINT_<NAME>_TOKEN` (name upper-cased, `-` → `_`): D-077 / ADR-REM-003. */
38
+ export function remoteTokenFor(endpointName: string): string | undefined {
39
+ return process.env[`UKP_ENDPOINT_${endpointName.toUpperCase().replace(/-/g, "_")}_TOKEN`];
40
+ }
41
+
42
+ /** Credential resolution (D-078): env wins, the stored binding token is the
43
+ * fallback — injected credentials (CI/agent env) never get shadowed by the
44
+ * file, while the file keeps interactive use zero-ceremony. */
45
+ export function resolveRemoteToken(binding: RegistryBinding): string | undefined {
46
+ return remoteTokenFor(binding.name) ?? binding.token;
47
+ }
48
+
49
+ /** `UKP_REMOTE_TIMEOUT_MS` mirrors `UKP_PROVIDER_TIMEOUT_MS` (60s default). */
50
+ function remoteTimeoutMs(): number {
51
+ const raw = process.env.UKP_REMOTE_TIMEOUT_MS;
52
+ if (raw === undefined || raw.trim() === "") return 60_000;
53
+ const value = Number(raw);
54
+ return Number.isSafeInteger(value) && value >= 1000 ? value : 60_000;
55
+ }
56
+
57
+ /** TOFU mismatch policy (RQ-17): `warn` (default) appends a warning and
58
+ * continues; `block` refuses the endpoint until it is explicitly
59
+ * re-registered. */
60
+ function tofuMode(): "warn" | "block" {
61
+ return process.env.UKP_TOFU === "block" ? "block" : "warn";
62
+ }
63
+
64
+ export interface RemoteTransportHandle {
65
+ /** Wire base the client actually fetches (tunnel endpoint or the url itself). */
66
+ base: string;
67
+ /** Releases per-invocation resources (kills an ephemeral tunnel); noop for
68
+ * direct connections. */
69
+ close: () => void;
70
+ /** TLS anchor for https bases (W5' / D-079): present when the binding
71
+ * carries a pinned certificate; all fetches on this handle verify against
72
+ * it. Mutated in place by the renewal re-anchor. */
73
+ tls?: RemoteTlsAnchor;
74
+ }
75
+
76
+ function freePort(): Promise<number> {
77
+ return new Promise((resolve, reject) => {
78
+ const listener = Bun.listen({
79
+ hostname: "127.0.0.1",
80
+ port: 0,
81
+ socket: { data: () => {}, open: () => {}, close: () => {}, drain: () => {}, error: () => {} },
82
+ });
83
+ const port = listener.port;
84
+ listener.stop(true);
85
+ if (port > 0) resolve(port);
86
+ else reject(new RemoteTransportError("unable to allocate a local tunnel port"));
87
+ });
88
+ }
89
+
90
+ function portAccepts(hostname: string, port: number, timeoutMs: number): Promise<boolean> {
91
+ const deadline = Date.now() + timeoutMs;
92
+ return new Promise((resolve) => {
93
+ const attempt = () => {
94
+ const socket = netConnect({ host: hostname, port }, () => {
95
+ socket.destroy();
96
+ resolve(true);
97
+ });
98
+ socket.on("error", () => {
99
+ socket.destroy();
100
+ if (Date.now() > deadline) resolve(false);
101
+ else setTimeout(attempt, 120);
102
+ });
103
+ };
104
+ attempt();
105
+ });
106
+ }
107
+
108
+ /** TLS trust state for one invocation (W5' / D-079): `ca` is the pinned
109
+ * certificate PEM handed to fetch as the trust anchor; `pin` is the RFC 7469
110
+ * SPKI pin used to distinguish "server renewed its certificate, same key"
111
+ * (re-anchor and continue) from "identity changed" (hard block). */
112
+ export interface RemoteTlsAnchor {
113
+ ca: string;
114
+ pin?: string;
115
+ /** Endpoint label for the identity-changed error; absent at registration
116
+ * (the name is not known yet — it comes from the discovery document). */
117
+ name?: string;
118
+ /** When set, a successful re-anchor persists the new PEM into the binding. */
119
+ registryPath?: string;
120
+ }
121
+
122
+ export interface RemoteTlsProbe {
123
+ /** Verified against the system trust store + hostname (public-CA path). */
124
+ authorized: boolean;
125
+ certPem: string;
126
+ spkiPin: string;
127
+ }
128
+
129
+ /** Registration-time TLS probe (W5'): for https urls, capture the peer
130
+ * certificate before the first fetch. authorized=true means a public CA
131
+ * chain validates (no pinning); false means self-signed/private — the client
132
+ * TOFU-pins the certificate as trust anchor plus its SPKI as identity.
133
+ * Non-https urls (ssh:// tunnel, loopback http) return undefined: transport
134
+ * security comes from SSH or locality, not TLS. */
135
+ export async function probeRemoteTls(url: string): Promise<RemoteTlsProbe | undefined> {
136
+ let parsed: URL;
137
+ try {
138
+ parsed = new URL(url);
139
+ } catch {
140
+ throw new RemoteTransportError(`remote endpoint url is not a valid absolute URL: ${url}`);
141
+ }
142
+ if (parsed.protocol !== "https:") return undefined;
143
+ const port = parsed.port === "" ? 443 : Number(parsed.port);
144
+ const host = parsed.hostname;
145
+ return await new Promise<RemoteTlsProbe>((resolve, reject) => {
146
+ const socket = tlsConnect(
147
+ {
148
+ host,
149
+ port,
150
+ // SNI must be a name, not an IP (node:tls rejects IP servernames);
151
+ // IP-addressed certificates verify through the SAN, not SNI.
152
+ ...(isIP(host) === 0 ? { servername: host } : {}),
153
+ rejectUnauthorized: false,
154
+ },
155
+ () => {
156
+ const peer = socket.getPeerCertificate();
157
+ socket.destroy();
158
+ if (peer === undefined || peer.raw === undefined || peer.raw.length === 0) {
159
+ reject(new RemoteTransportError(`remote endpoint presented no TLS certificate: ${url}`));
160
+ return;
161
+ }
162
+ resolve({
163
+ authorized: socket.authorized === true,
164
+ certPem: new X509Certificate(peer.raw).toString(),
165
+ spkiPin: spkiPinOf(peer.raw),
166
+ });
167
+ },
168
+ );
169
+ socket.setTimeout(10_000, () => {
170
+ socket.destroy();
171
+ reject(new RemoteTransportError(`TLS probe timed out: ${url}`));
172
+ });
173
+ socket.on("error", (error) => {
174
+ reject(new RemoteTransportError(`TLS probe failed: ${url} (${error instanceof Error ? error.message : String(error)})`));
175
+ });
176
+ });
177
+ }
178
+
179
+ /** Door-endpoint wire prefix (ADR-REM-004 / O-3): a url with a path segment
180
+ * (`ssh://ali/notes`) addresses one endpoint THROUGH the door at origin —
181
+ * every wire route hangs off `/e/<name>`, so the path becomes this prefix
182
+ * on the transport base and downstream `${base}/v1/…` calls stay unchanged. */
183
+ function wirePrefix(endpointName: string | undefined): string {
184
+ return endpointName === undefined ? "" : `/e/${endpointName}`;
185
+ }
186
+
187
+ function tlsAnchorOf(binding: RegistryBinding, options: { registryPath?: string }): RemoteTlsAnchor | undefined {
188
+ return binding.url !== undefined && binding.url.startsWith("https://") && binding.tls_cert !== undefined
189
+ ? {
190
+ ca: binding.tls_cert,
191
+ ...(binding.tls_pin !== undefined ? { pin: binding.tls_pin } : {}),
192
+ name: binding.name,
193
+ ...(options.registryPath !== undefined ? { registryPath: options.registryPath } : {}),
194
+ }
195
+ : undefined;
196
+ }
197
+
198
+ function directTransportHandle(
199
+ binding: RegistryBinding,
200
+ parts: RemoteUrlParts,
201
+ options: { registryPath?: string },
202
+ ): RemoteTransportHandle {
203
+ return {
204
+ base: parts.origin + wirePrefix(parts.endpointName),
205
+ close: () => {},
206
+ ...(tlsAnchorOf(binding, options) !== undefined ? { tls: tlsAnchorOf(binding, options)! } : {}),
207
+ };
208
+ }
209
+
210
+ /** One ephemeral ssh local forward (D-078): free local port → the remote
211
+ * host's loopback on `port`; encryption + host auth come from the user's
212
+ * SSH config/keys. Shared by the one-shot transport and the per-origin
213
+ * pool; `sshCommand` is a test injection point for the ssh binary. */
214
+ async function openSshTunnel(
215
+ parts: { user?: string; host: string; port: number },
216
+ options: { sshCommand?: readonly string[] },
217
+ endpointLabel: string,
218
+ ): Promise<{ proc: ReturnType<typeof Bun.spawn>; base: string }> {
219
+ const localPort = await freePort();
220
+ const target = parts.user !== undefined ? `${parts.user}@${parts.host}` : parts.host;
221
+ const argv = [
222
+ ...(options.sshCommand ?? ["ssh"]),
223
+ "-N",
224
+ "-o", "ExitOnForwardFailure=yes",
225
+ "-o", "BatchMode=yes",
226
+ "-L", `127.0.0.1:${localPort}:127.0.0.1:${parts.port}`,
227
+ target,
228
+ ];
229
+ const proc = Bun.spawn(argv, { stdout: "ignore", stderr: "ignore", stdin: "ignore" });
230
+ const ready = await portAccepts("127.0.0.1", localPort, 15_000);
231
+ if (!ready) {
232
+ proc.kill();
233
+ throw new RemoteTransportError(
234
+ `ssh tunnel to '${target}:${parts.port}' (endpoint '${endpointLabel}') did not become ready; check the host alias and key auth (BatchMode)`,
235
+ );
236
+ }
237
+ return { proc, base: `http://127.0.0.1:${localPort}` };
238
+ }
239
+
240
+ /** Ensure a usable wire base for one invocation (D-078 transparent ssh):
241
+ * http/https urls are used directly; `ssh://[user@]host[:port][/endpoint]`
242
+ * opens an ephemeral local forward over SSH and returns the tunnel endpoint
243
+ * (plus the `/e/<name>` prefix when the url selects a door endpoint). https
244
+ * bindings with a pinned certificate carry their TLS anchor on the handle.
245
+ * `sshCommand` is a test injection point for the ssh binary invocation;
246
+ * `registryPath` lets the renewal re-anchor persist. */
247
+ export async function openRemoteTransport(
248
+ binding: RegistryBinding,
249
+ options: { sshCommand?: readonly string[]; registryPath?: string } = {},
250
+ ): Promise<RemoteTransportHandle> {
251
+ if (binding.kind !== "remote" || binding.url === undefined) {
252
+ throw new RemoteTransportError(`endpoint '${binding.name}' is not a remote binding`);
253
+ }
254
+ assertRemoteUrlAllowed(binding.url);
255
+ const parts = parseRemoteUrl(binding.url);
256
+ if (parts === undefined) {
257
+ throw new RemoteTransportError(`remote endpoint url is not admissible: ${binding.url}`);
258
+ }
259
+ if (parts.scheme !== "ssh") {
260
+ return directTransportHandle(binding, parts, options);
261
+ }
262
+ const tunnel = await openSshTunnel(parts, options, binding.name);
263
+ return {
264
+ base: tunnel.base + wirePrefix(parts.endpointName),
265
+ close: () => tunnel.proc.kill(),
266
+ };
267
+ }
268
+
269
+ /** Per-invocation transport pool (W7 / O-5): same-origin ssh urls share ONE
270
+ * tunnel for the pool's lifetime — a 3-endpoint door's `ukp list` is one
271
+ * tunnel, not four (per-row + door fetch). Pure client-internal: handles it
272
+ * hands out have a noop `close`; the caller closes the pool when the
273
+ * invocation ends. Direct (https / loopback http) urls need no pooling and
274
+ * get the same one-shot handles as `openRemoteTransport`. */
275
+ export interface RemoteTransportPool {
276
+ acquire(binding: RegistryBinding): Promise<RemoteTransportHandle>;
277
+ close(): void;
278
+ }
279
+
280
+ export function createTransportPool(
281
+ options: { sshCommand?: readonly string[]; registryPath?: string } = {},
282
+ ): RemoteTransportPool {
283
+ const tunnels = new Map<string, { proc: ReturnType<typeof Bun.spawn>; base: string }>();
284
+ return {
285
+ async acquire(binding) {
286
+ if (binding.kind !== "remote" || binding.url === undefined) {
287
+ throw new RemoteTransportError(`endpoint '${binding.name}' is not a remote binding`);
288
+ }
289
+ assertRemoteUrlAllowed(binding.url);
290
+ const parts = parseRemoteUrl(binding.url);
291
+ if (parts === undefined) {
292
+ throw new RemoteTransportError(`remote endpoint url is not admissible: ${binding.url}`);
293
+ }
294
+ if (parts.scheme !== "ssh") {
295
+ return directTransportHandle(binding, parts, options);
296
+ }
297
+ let tunnel = tunnels.get(parts.origin);
298
+ if (tunnel === undefined) {
299
+ tunnel = await openSshTunnel(parts, options, binding.name);
300
+ tunnels.set(parts.origin, tunnel);
301
+ }
302
+ return {
303
+ base: tunnel.base + wirePrefix(parts.endpointName),
304
+ close: () => {},
305
+ };
306
+ },
307
+ close() {
308
+ for (const { proc } of tunnels.values()) proc.kill();
309
+ tunnels.clear();
310
+ },
311
+ };
312
+ }
313
+
314
+ function authorizationHeaders(token: string | undefined): Record<string, string> {
315
+ return token === undefined ? {} : { authorization: `Bearer ${token}` };
316
+ }
317
+
318
+ function isTimeoutError(error: unknown): boolean {
319
+ return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
320
+ }
321
+
322
+ function unreachableError(url: string, error: unknown): RemoteTransportError {
323
+ const code = (error as { code?: unknown }).code;
324
+ const detail = error instanceof Error ? error.message : String(error);
325
+ return new RemoteTransportError(
326
+ `remote endpoint unreachable: ${url} (${detail}${typeof code === "string" && code !== "" ? ` [${code}]` : ""})`,
327
+ );
328
+ }
329
+
330
+ /** Renewal re-anchor attempt (W5' / D-079, adjudication point B): after a fetch-phase
331
+ * failure with an anchor present, probe the CURRENT peer certificate.
332
+ * Same SPKI pin → server renewed its certificate keeping the key: swap the
333
+ * anchor (and persist) and let the caller retry once, invisibly. Different
334
+ * pin → identity change (reinstall or MITM): hard block. Unreachable probe
335
+ * → the original failure stands. */
336
+ async function reanchorFromProbe(url: string, anchor: RemoteTlsAnchor): Promise<boolean> {
337
+ const probe = await probeRemoteTls(url).catch(() => undefined);
338
+ if (probe === undefined) return false;
339
+ if (anchor.pin !== undefined && probe.spkiPin !== anchor.pin) {
340
+ throw new RemoteTransportError(
341
+ `remote '${anchor.name ?? "endpoint"}' TLS identity changed — pinned ${anchor.pin}, got ${probe.spkiPin}; if the server was reinstalled this is expected: refresh trust with 'ukp register --url <url> --token <token>'`,
342
+ );
343
+ }
344
+ anchor.ca = probe.certPem;
345
+ if (anchor.registryPath !== undefined && anchor.name !== undefined) {
346
+ try {
347
+ refreshRemoteTlsCert(anchor.registryPath, anchor.name, probe.certPem);
348
+ } catch {
349
+ // persistence is best-effort: the in-memory anchor already unblocks
350
+ // this invocation; the next renewal re-anchors again.
351
+ }
352
+ }
353
+ return true;
354
+ }
355
+
356
+ /** Fetch + JSON-decode with one transparent renewal re-anchor: a pinned
357
+ * https anchor turns a certificate-verification fetch failure into a probe —
358
+ * same SPKI keeps going (anchor swapped in place), a different SPKI blocks. */
359
+ async function fetchJson(
360
+ url: string,
361
+ init: RequestInit,
362
+ anchor?: RemoteTlsAnchor,
363
+ ): Promise<{ status: number; body: unknown }> {
364
+ const attempt = async (ca?: string, wrap = true): Promise<{ status: number; body: unknown }> => {
365
+ let response: Response;
366
+ try {
367
+ response = await fetch(url, {
368
+ ...init,
369
+ signal: AbortSignal.timeout(remoteTimeoutMs()),
370
+ ...(ca !== undefined ? { tls: { ca } } : {}),
371
+ } as RequestInit);
372
+ } catch (error) {
373
+ // wrap=false keeps the raw error so the anchored caller can classify
374
+ // it (TLS verification failure → re-anchor probe) before wrapping.
375
+ if (!wrap) throw error;
376
+ throw unreachableError(url, error);
377
+ }
378
+ let body: unknown;
379
+ try {
380
+ body = await response.json();
381
+ } catch {
382
+ throw new RemoteTransportError(`remote endpoint returned a non-JSON body (status ${response.status}): ${url}`);
383
+ }
384
+ return { status: response.status, body };
385
+ };
386
+
387
+ if (anchor === undefined) return await attempt();
388
+ try {
389
+ return await attempt(anchor.ca, false);
390
+ } catch (error) {
391
+ if (error instanceof RemoteTransportError) throw error; // non-JSON body: not a TLS event
392
+ if (isTimeoutError(error)) throw unreachableError(url, error);
393
+ // Re-anchoring needs a pinned identity: only the SPKI pin distinguishes
394
+ // "renewed certificate, same key" from "different identity".
395
+ if (anchor.pin !== undefined && await reanchorFromProbe(url, anchor)) return await attempt(anchor.ca);
396
+ throw unreachableError(url, error);
397
+ }
398
+ }
399
+
400
+ function asRecord(body: unknown, url: string): Record<string, unknown> {
401
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
402
+ throw new RemoteTransportError(`remote endpoint returned an unexpected payload shape: ${url}`);
403
+ }
404
+ return body as Record<string, unknown>;
405
+ }
406
+
407
+ export interface DiscoveryFetch {
408
+ doc: DiscoveryDocument;
409
+ /** TOFU observations (caller renders as warnings; mismatch never blocks). */
410
+ warnings: string[];
411
+ bearerRequired: boolean;
412
+ }
413
+
414
+ /** Fetch + validate the discovery document, run the TOFU pin comparison. */
415
+ export async function fetchDiscoveryDocument(
416
+ binding: RegistryBinding,
417
+ transport: RemoteTransportHandle,
418
+ token?: string,
419
+ ): Promise<DiscoveryFetch> {
420
+ return fetchDiscoveryDocumentAt(transport.base, {
421
+ name: binding.name,
422
+ ...(binding.instance_uid !== undefined ? { pinnedUid: binding.instance_uid } : {}),
423
+ ...(token !== undefined ? { token } : {}),
424
+ ...(transport.tls !== undefined ? { tlsAnchor: transport.tls } : {}),
425
+ });
426
+ }
427
+
428
+ /** Raw well-known fetch shared by the endpoint and door document paths:
429
+ * decodes JSON, checks the protocol name/version, and reports whether the
430
+ * service declares bearer auth. Scope discrimination (absence of `scope` =
431
+ * endpoint document) is the callers' job — that IS the wire contract
432
+ * (ADR-REM-004 / O-1). */
433
+ async function fetchWellKnownRecord(
434
+ base: string,
435
+ options: { token?: string; tlsAnchor?: RemoteTlsAnchor },
436
+ ): Promise<{ record: Record<string, unknown>; bearerRequired: boolean }> {
437
+ const url = `${base}${DISCOVERY_PATH}`;
438
+ const { body } = await fetchJson(url, { headers: authorizationHeaders(options.token) }, options.tlsAnchor);
439
+ const record = asRecord(body, url);
440
+ if (record.protocol !== PROTOCOL_NAME) {
441
+ throw new RemoteTransportError(`service at ${base} is not a ukp-remote service (protocol: ${String(record.protocol)})`);
442
+ }
443
+ if (record.protocol_version !== "1") {
444
+ throw new RemoteTransportError(`service at ${base} speaks ukp-remote protocol version ${String(record.protocol_version)}; this client supports 1`);
445
+ }
446
+ const schemes = (record.security as { schemes?: unknown } | undefined)?.schemes;
447
+ return { record, bearerRequired: Array.isArray(schemes) && schemes.includes("bearer") };
448
+ }
449
+
450
+ /** URL-addressed variant for registration (the name is not known yet — it
451
+ * comes FROM this document per RQ-14). `tlsAnchor` carries the registration
452
+ * probe's captured certificate for self-signed servers (W5'). A door
453
+ * document here is a classified failure: this path is for endpoint
454
+ * documents; doors go through `fetchRegistrationDocument`. */
455
+ export async function fetchDiscoveryDocumentAt(
456
+ url: string,
457
+ options: { name?: string; pinnedUid?: string; token?: string; tlsAnchor?: RemoteTlsAnchor } = {},
458
+ ): Promise<DiscoveryFetch> {
459
+ const base = url.replace(/\/+$/, "");
460
+ const label = options.name ?? base;
461
+ const { record, bearerRequired } = await fetchWellKnownRecord(base, options);
462
+ if (record.scope === "host") {
463
+ throw new RemoteTransportError(
464
+ `service at ${base} is a host door (scope:"host"); register it with 'ukp register --url ${base}' to import its endpoints`,
465
+ );
466
+ }
467
+ const warnings: string[] = [];
468
+ if (options.pinnedUid !== undefined && record.instance_uid !== options.pinnedUid) {
469
+ // TOFU (ADR-REM-003, RQ-17): warn by default, refuse under UKP_TOFU=block —
470
+ // never silently trust the new identity.
471
+ const detail = `endpoint '${label}' identity changed (pinned ${options.pinnedUid}, served ${String(record.instance_uid)}); re-register with 'ukp register --url' if this replacement is intended`;
472
+ if (tofuMode() === "block") {
473
+ throw new RemoteTransportError(`${detail} (refused: UKP_TOFU=block)`);
474
+ }
475
+ warnings.push(detail);
476
+ }
477
+ return {
478
+ doc: record as unknown as DiscoveryDocument,
479
+ warnings,
480
+ bearerRequired,
481
+ };
482
+ }
483
+
484
+ /** Door roster validation (O-1): every `endpoints[]` entry must carry a
485
+ * string name + instance_uid — the trust payload clients pin per endpoint. */
486
+ function parseDoorEndpoints(record: Record<string, unknown>, base: string): DoorEndpointSummary[] {
487
+ const rawEndpoints = record.endpoints;
488
+ if (!Array.isArray(rawEndpoints) || rawEndpoints.length === 0) {
489
+ throw new RemoteTransportError(`host door at ${base} declares no endpoints`);
490
+ }
491
+ const endpoints: DoorEndpointSummary[] = [];
492
+ for (const entry of rawEndpoints) {
493
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
494
+ throw new RemoteTransportError(`host door at ${base} declares a malformed endpoints[] entry`);
495
+ }
496
+ const summary = entry as Record<string, unknown>;
497
+ if (typeof summary.name !== "string" || typeof summary.instance_uid !== "string") {
498
+ throw new RemoteTransportError(`host door at ${base} declares an endpoint without name/instance_uid`);
499
+ }
500
+ endpoints.push(summary as unknown as DoorEndpointSummary);
501
+ }
502
+ return endpoints;
503
+ }
504
+
505
+ export interface DoorFetch {
506
+ doc: DoorDocument;
507
+ bearerRequired: boolean;
508
+ }
509
+
510
+ /** Fetch + validate a host door document (ADR-REM-004 / O-1): requires
511
+ * `scope:"host"` and a well-formed `endpoints[]` roster. Per-endpoint TOFU
512
+ * happens on the per-endpoint documents at import/call time — the door
513
+ * itself carries no identity. */
514
+ export async function fetchDoorDocument(
515
+ url: string,
516
+ options: { token?: string; tlsAnchor?: RemoteTlsAnchor } = {},
517
+ ): Promise<DoorFetch> {
518
+ const base = url.replace(/\/+$/, "");
519
+ const { record, bearerRequired } = await fetchWellKnownRecord(base, options);
520
+ if (record.scope !== "host") {
521
+ throw new RemoteTransportError(`service at ${base} is not a host door (scope: ${String(record.scope)}); register it directly with 'ukp register --url ${base}'`);
522
+ }
523
+ return {
524
+ doc: { ...(record as unknown as DoorDocument), endpoints: parseDoorEndpoints(record, base) },
525
+ bearerRequired,
526
+ };
527
+ }
528
+
529
+ /** Registration-time well-known fetch with scope discrimination (O-1): the
530
+ * document at the url's origin self-describes — `scope:"host"` routes to the
531
+ * door import flow, absence routes to today's single-endpoint registration.
532
+ * One fetch, one verdict. */
533
+ export type RegistrationFetch =
534
+ | { kind: "door"; door: DoorFetch }
535
+ | { kind: "endpoint"; discovery: DiscoveryFetch };
536
+
537
+ export async function fetchRegistrationDocument(
538
+ url: string,
539
+ options: { token?: string; tlsAnchor?: RemoteTlsAnchor } = {},
540
+ ): Promise<RegistrationFetch> {
541
+ const base = url.replace(/\/+$/, "");
542
+ const { record, bearerRequired } = await fetchWellKnownRecord(base, options);
543
+ if (record.scope === "host") {
544
+ return {
545
+ kind: "door",
546
+ door: {
547
+ doc: { ...(record as unknown as DoorDocument), endpoints: parseDoorEndpoints(record, base) },
548
+ bearerRequired,
549
+ },
550
+ };
551
+ }
552
+ return { kind: "endpoint", discovery: { doc: record as unknown as DiscoveryDocument, warnings: [], bearerRequired } };
553
+ }
554
+
555
+ export interface RemoteSearchExecution {
556
+ outcome: SearchEndpointOutcome;
557
+ /** Provider-native results array (wire `results`): the render source for
558
+ * human result units, replacing local providerOutput's role. */
559
+ results: unknown[];
560
+ /** Inline references (wire `references`, RQ-07): per-result handoff keys
561
+ * with server-declared `ukp_uri`. */
562
+ references: Array<{ ukp_uri?: string }> | undefined;
563
+ }
564
+
565
+ /** POST /v1/search → endpoint outcome in the local SearchEndpointOutcome
566
+ * vocabulary (status/message/envelope-compatible fields). */
567
+ export async function remoteSearch(
568
+ binding: RegistryBinding,
569
+ transport: RemoteTransportHandle,
570
+ token: string | undefined,
571
+ query: string,
572
+ limit: number,
573
+ ): Promise<RemoteSearchExecution> {
574
+ const base = transport.base;
575
+ const { status, body } = await fetchJson(`${base}/v1/search`, {
576
+ method: "POST",
577
+ headers: { "content-type": "application/json", ...authorizationHeaders(token) },
578
+ body: JSON.stringify({ query, limit }),
579
+ }, transport.tls);
580
+ const record = asRecord(body, `${base}/v1/search`);
581
+ const envelope = record.endpoints as Array<Record<string, unknown>> | undefined;
582
+ const entry = Array.isArray(envelope) ? envelope[0] : undefined;
583
+
584
+ if (status === 401) {
585
+ return { outcome: failedOutcome(binding.name, authMessage(token)), results: [], references: undefined };
586
+ }
587
+ if (entry === undefined) {
588
+ return { outcome: failedOutcome(binding.name, `remote search returned no endpoint result (status ${status})`), results: [], references: undefined };
589
+ }
590
+ const entryStatus = entry.status;
591
+ if (entryStatus !== "succeeded" && entryStatus !== "no_matches") {
592
+ const message = typeof entry.message === "string" ? entry.message : `remote endpoint status '${String(entryStatus)}'`;
593
+ return { outcome: { name: binding.name, provider: providerOf(record), status: "failed", message }, results: [], references: undefined };
594
+ }
595
+ const referencesRecord = record.references as { results?: unknown } | undefined;
596
+ const references = referencesRecord !== undefined && Array.isArray(referencesRecord.results)
597
+ ? referencesRecord.results as Array<{ ukp_uri?: string }>
598
+ : undefined;
599
+ return {
600
+ outcome: {
601
+ name: binding.name,
602
+ provider: providerOf(record),
603
+ status: entryStatus,
604
+ ...(typeof entry.message === "string" ? { message: entry.message } : {}),
605
+ },
606
+ results: Array.isArray(record.results) ? record.results : [],
607
+ references,
608
+ };
609
+ }
610
+
611
+ function authMessage(token: string | undefined): string {
612
+ return token === undefined
613
+ ? "remote endpoint requires a bearer token; set UKP_ENDPOINT_<NAME>_TOKEN (NAME upper-cased, '-' → '_')"
614
+ : "remote endpoint rejected the bearer token (401)";
615
+ }
616
+
617
+ function providerOf(record: Record<string, unknown>): string | null {
618
+ const endpoints = record.endpoints as Array<Record<string, unknown>> | undefined;
619
+ const provider = Array.isArray(endpoints) ? endpoints[0]?.provider : undefined;
620
+ return typeof provider === "string" ? provider : null;
621
+ }
622
+
623
+ function failedOutcome(name: string, message: string): SearchEndpointOutcome {
624
+ return { name, provider: null, status: "failed", message };
625
+ }
626
+
627
+ export interface RemoteReadResult {
628
+ status: number;
629
+ ok: boolean;
630
+ content: string;
631
+ /** Envelope error fields for failures (class/message). */
632
+ errorClass?: string;
633
+ errorMessage?: string;
634
+ reference: string;
635
+ }
636
+
637
+ /** GET /v1/read — raw transport; classification into ReadOutcome happens in
638
+ * the read adapter (commands/read.ts) where the local vocabulary lives. */
639
+ export async function remoteRead(
640
+ transport: RemoteTransportHandle,
641
+ token: string | undefined,
642
+ params: { ref?: string; uri?: string; lines?: string; pin?: string },
643
+ ): Promise<RemoteReadResult> {
644
+ const base = transport.base;
645
+ const search = new URLSearchParams();
646
+ if (params.ref !== undefined) search.set("ref", params.ref);
647
+ if (params.uri !== undefined) search.set("uri", params.uri);
648
+ if (params.lines !== undefined) search.set("lines", params.lines);
649
+ if (params.pin !== undefined) search.set("pin", params.pin);
650
+ const { status, body } = await fetchJson(`${base}/v1/read?${search.toString()}`, {
651
+ headers: authorizationHeaders(token),
652
+ }, transport.tls);
653
+ const record = asRecord(body, `${base}/v1/read`);
654
+ const error = record.error as { class?: unknown; message?: unknown } | undefined;
655
+ return {
656
+ status,
657
+ ok: record.ok === true,
658
+ content: typeof record.content === "string" ? record.content : "",
659
+ ...(error !== undefined && typeof error.class === "string" ? { errorClass: error.class } : {}),
660
+ ...(error !== undefined && typeof error.message === "string" ? { errorMessage: error.message } : {}),
661
+ reference: typeof record.reference === "string" ? record.reference : (params.ref ?? params.uri ?? ""),
662
+ };
663
+ }
664
+
665
+ export interface RemoteNavResult {
666
+ status: number;
667
+ /** Success marker: the nav envelope itself (ukp.nav.v1) — it has no `ok`
668
+ * field, so schema presence is the verdict. */
669
+ ok: boolean;
670
+ envelope?: NavEnvelope;
671
+ /** Transport-shape error fields for failures (class/message). */
672
+ errorClass?: string;
673
+ errorMessage?: string;
674
+ }
675
+
676
+ /** GET /v1/nav — raw transport; classification into NavFailure happens in
677
+ * the nav adapter (commands/nav.ts) where the local vocabulary lives. */
678
+ export async function remoteNav(
679
+ transport: RemoteTransportHandle,
680
+ token: string | undefined,
681
+ params: { path?: string; depth?: number },
682
+ ): Promise<RemoteNavResult> {
683
+ const base = transport.base;
684
+ const search = new URLSearchParams();
685
+ if (params.path !== undefined) search.set("path", params.path);
686
+ if (params.depth !== undefined) search.set("depth", String(params.depth));
687
+ const query = search.size > 0 ? `?${search.toString()}` : "";
688
+ const { status, body } = await fetchJson(`${base}/v1/nav${query}`, {
689
+ headers: authorizationHeaders(token),
690
+ }, transport.tls);
691
+ const record = asRecord(body, `${base}/v1/nav`);
692
+ const error = record.error as { class?: unknown; message?: unknown } | undefined;
693
+ // Shape gate (read precedent validates what it consumes): a body claiming
694
+ // the schema but missing the rendered fields would crash the shared
695
+ // renderers downstream — treat it as a failed call, not a success.
696
+ const ok = record.schema === "ukp.nav.v1"
697
+ && Array.isArray(record.entries)
698
+ && Array.isArray(record.diagnostics);
699
+ return {
700
+ status,
701
+ ok,
702
+ ...(ok ? { envelope: record as unknown as NavEnvelope } : {}),
703
+ ...(!ok && error !== undefined && typeof error.class === "string" ? { errorClass: error.class } : {}),
704
+ ...(!ok && error !== undefined && typeof error.message === "string" ? { errorMessage: error.message } : {}),
705
+ };
706
+ }
707
+
708
+ export interface RemoteRgExecution {
709
+ outcome: RgEndpointOutcome;
710
+ /** Envelope-level warnings from the server run (scope/duplicate notes —
711
+ * usually empty server-side; endpoint failures live in the outcome). */
712
+ warnings: string[];
713
+ }
714
+
715
+ function remoteRgMatches(raw: unknown): RgMatch[] | undefined {
716
+ if (!Array.isArray(raw)) return undefined;
717
+ return raw.flatMap((entry): RgMatch[] => {
718
+ if (typeof entry !== "object" || entry === null) return [];
719
+ const match = entry as Record<string, unknown>;
720
+ if (typeof match.path !== "string") return [];
721
+ return [{
722
+ path: match.path,
723
+ ...(typeof match.line === "number" ? { line: match.line } : {}),
724
+ ...(typeof match.text === "string" ? { text: match.text } : {}),
725
+ ...(typeof match.ukp_uri === "string" ? { ukp_uri: match.ukp_uri } : {}),
726
+ }];
727
+ });
728
+ }
729
+
730
+ function remoteRgCounts(raw: unknown): RgCountEntry[] | undefined {
731
+ if (!Array.isArray(raw)) return undefined;
732
+ return raw.flatMap((entry): RgCountEntry[] => {
733
+ if (typeof entry !== "object" || entry === null) return [];
734
+ const count = entry as Record<string, unknown>;
735
+ if (typeof count.path !== "string" || typeof count.count !== "number") return [];
736
+ return [{ path: count.path, count: count.count }];
737
+ });
738
+ }
739
+
740
+ const RG_WIRE_STATUSES = new Set(["succeeded", "no_matches", "skipped", "failed", "interrupted", "cancelled"]);
741
+
742
+ /** GET /v1/rg — single-endpoint outcome in the local RgEndpointOutcome
743
+ * vocabulary (provider is rg's external tier regardless of transport). */
744
+ export async function remoteRg(
745
+ binding: RegistryBinding,
746
+ transport: RemoteTransportHandle,
747
+ token: string | undefined,
748
+ params: {
749
+ query: string;
750
+ limit: number;
751
+ glob?: string;
752
+ type?: string;
753
+ ignoreCase?: boolean;
754
+ count?: boolean;
755
+ passthrough: readonly string[];
756
+ },
757
+ ): Promise<RemoteRgExecution> {
758
+ const base = transport.base;
759
+ const search = new URLSearchParams({ query: params.query, limit: String(params.limit) });
760
+ if (params.glob !== undefined) search.set("glob", params.glob);
761
+ if (params.type !== undefined) search.set("type", params.type);
762
+ if (params.ignoreCase === true) search.set("i", "1");
763
+ if (params.count === true) search.set("count", "1");
764
+ for (const arg of params.passthrough) search.append("passthrough", arg);
765
+ const { status, body } = await fetchJson(`${base}/v1/rg?${search.toString()}`, {
766
+ headers: authorizationHeaders(token),
767
+ }, transport.tls);
768
+ const record = asRecord(body, `${base}/v1/rg`);
769
+ const error = record.error as { class?: unknown; message?: unknown } | undefined;
770
+
771
+ if (status === 401) {
772
+ return { outcome: { name: binding.name, provider: EXTERNAL_PROVIDER, status: "failed", message: authMessage(token) }, warnings: [] };
773
+ }
774
+ if (error !== undefined) {
775
+ // Route-level failure: an older serve without /v1/rg answers 404
776
+ // not-found; anything else keeps the server's message.
777
+ const message = typeof error.message === "string"
778
+ ? error.message
779
+ : `remote rg failed (status ${status})`;
780
+ return { outcome: { name: binding.name, provider: EXTERNAL_PROVIDER, status: "failed", message }, warnings: [] };
781
+ }
782
+ const envelope = record.endpoints as Array<Record<string, unknown>> | undefined;
783
+ const entry = Array.isArray(envelope) ? envelope[0] : undefined;
784
+ if (entry === undefined) {
785
+ return {
786
+ outcome: { name: binding.name, provider: EXTERNAL_PROVIDER, status: "failed", message: `remote rg returned no endpoint result (status ${status})` },
787
+ warnings: [],
788
+ };
789
+ }
790
+ const wireStatus = typeof entry.status === "string" && RG_WIRE_STATUSES.has(entry.status) ? entry.status : "failed";
791
+ const matches = remoteRgMatches(entry.matches);
792
+ const counts = remoteRgCounts(entry.counts);
793
+ const warnings = Array.isArray(record.warnings) ? record.warnings.filter((item): item is string => typeof item === "string") : [];
794
+ return {
795
+ outcome: {
796
+ name: binding.name,
797
+ provider: EXTERNAL_PROVIDER,
798
+ status: wireStatus as RgEndpointOutcome["status"],
799
+ ...(typeof entry.message === "string" ? { message: entry.message } : {}),
800
+ ...(matches !== undefined ? { matches } : {}),
801
+ ...(counts !== undefined ? { counts } : {}),
802
+ ...(entry.truncated === true ? { truncated: true } : {}),
803
+ },
804
+ warnings,
805
+ };
806
+ }
807
+
808
+ export interface RemoteProposeResult {
809
+ status: number;
810
+ /** Success marker: the ukp.propose.v1 envelope with a valid three-state
811
+ * status and revision — schema + field presence is the verdict (nav
812
+ * precedent). */
813
+ ok: boolean;
814
+ result?: ProposeResult;
815
+ /** Transport-shape error fields for failures (class/message). */
816
+ errorClass?: string;
817
+ errorMessage?: string;
818
+ }
819
+
820
+ const PROPOSE_WIRE_STATUSES = new Set<ProposeStatus>(["created", "unchanged", "updated"]);
821
+
822
+ /** PUT /v1/propose/{id} — the write face (W8 / ADR-REM-005). Body is the
823
+ * proposal text itself (UTF-8); classification into ProposeFailure happens
824
+ * in the propose command adapter where the local vocabulary lives. */
825
+ export async function remotePropose(
826
+ transport: RemoteTransportHandle,
827
+ token: string | undefined,
828
+ id: string,
829
+ content: string,
830
+ ): Promise<RemoteProposeResult> {
831
+ const base = transport.base;
832
+ const url = `${base}/v1/propose/${id}`;
833
+ const { status, body } = await fetchJson(url, {
834
+ method: "PUT",
835
+ headers: { "content-type": "text/plain; charset=utf-8", ...authorizationHeaders(token) },
836
+ body: content,
837
+ }, transport.tls);
838
+ const record = asRecord(body, url);
839
+ const error = record.error as { class?: unknown; message?: unknown } | undefined;
840
+ const ok = record.schema === "ukp.propose.v1"
841
+ && typeof record.id === "string"
842
+ && typeof record.status === "string"
843
+ && PROPOSE_WIRE_STATUSES.has(record.status as ProposeStatus)
844
+ && typeof record.revision === "number"
845
+ && Number.isSafeInteger(record.revision)
846
+ && record.revision >= 1;
847
+ return {
848
+ status,
849
+ ok,
850
+ ...(ok
851
+ ? { result: { id: record.id as string, status: record.status as ProposeStatus, revision: record.revision as number } }
852
+ : {}),
853
+ ...(!ok && error !== undefined && typeof error.class === "string" ? { errorClass: error.class } : {}),
854
+ ...(!ok && error !== undefined && typeof error.message === "string" ? { errorMessage: error.message } : {}),
855
+ };
856
+ }