@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
|
@@ -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
|
+
}
|