@catheadowl/ukp 0.2.5 → 0.2.6
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/README.md +10 -5
- package/docs/remote-deployment.md +94 -16
- package/package.json +1 -1
- package/src/capabilities/door-health.ts +480 -0
- package/src/capabilities/tls-identity.ts +3 -2
- package/src/cli.ts +6 -0
- package/src/commands/diagnose.ts +80 -2
- package/src/commands/guide-content/remote.txt +1 -1
- package/src/commands/init.ts +27 -0
- package/src/commands/serve-task.ts +334 -44
- package/src/commands/serve.ts +101 -19
- package/src/server.ts +76 -43
package/src/commands/serve.ts
CHANGED
|
@@ -7,9 +7,11 @@ import {
|
|
|
7
7
|
type KitParsed,
|
|
8
8
|
type UkpCommandSpec,
|
|
9
9
|
} from "./kit.ts";
|
|
10
|
+
import { readRegistry } from "../registry.ts";
|
|
11
|
+
import { homedir } from "node:os";
|
|
10
12
|
import { normalizeTlsSanEntry } from "../capabilities/tls-identity.ts";
|
|
11
13
|
import { DISCOVERY_PATH, startUkpServer, type ServeInfo } from "../server.ts";
|
|
12
|
-
import { printTaskPreflightError, renderServeTaskArtifacts, type ServeTaskTls } from "./serve-task.ts";
|
|
14
|
+
import { printTaskPreflightError, renderServeTaskArtifacts, writeServeTaskArtifacts, type ServeTaskTls } from "./serve-task.ts";
|
|
13
15
|
|
|
14
16
|
export interface ServeCommandContext {
|
|
15
17
|
currentDirectory: string;
|
|
@@ -17,6 +19,10 @@ export interface ServeCommandContext {
|
|
|
17
19
|
qmdCommand?: readonly string[];
|
|
18
20
|
/** Overrides `process.env.UKP_SERVE_TOKEN` for tests. */
|
|
19
21
|
tokens?: readonly string[];
|
|
22
|
+
/** Overrides the home directory `--print-task --write` writes into
|
|
23
|
+
* (tests point this at a scratch home; production resolves the real
|
|
24
|
+
* user home). */
|
|
25
|
+
homeDir?: string;
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
/** `UKP_SERVE_TOKEN=a,b,c` (RQ-16): comma-separated, trimmed, empty entries
|
|
@@ -30,17 +36,19 @@ function parseServeTokens(raw: string | undefined): string[] {
|
|
|
30
36
|
* Bun.serve listener keeps the process alive after runCli returns its exit
|
|
31
37
|
* code; SIGINT/SIGTERM stop the listener so the process exits with it.
|
|
32
38
|
* `--endpoint` is optional (W7 / ADR-REM-004): present = the original
|
|
33
|
-
* single-endpoint mode; absent = host door mode serving the
|
|
34
|
-
*
|
|
39
|
+
* single-endpoint mode; absent = host door mode serving the registry over
|
|
40
|
+
* `/e/<name>/` routing — every local binding, or the `--select` whitelist
|
|
41
|
+
* subset (ADR-REM-010). */
|
|
35
42
|
export const SERVE_SPEC: UkpCommandSpec = {
|
|
36
43
|
name: "serve",
|
|
37
|
-
summary: "serve one endpoint
|
|
44
|
+
summary: "serve one endpoint, a selected subset, or the whole registry as a host door - one HTTP server, one port - for remote UKP clients",
|
|
38
45
|
group: "operations",
|
|
39
46
|
description:
|
|
40
|
-
"Expose a registered endpoint over HTTP using the ukp-remote wire, or - without --endpoint - serve
|
|
41
|
-
usage: "[--endpoint <name>] [--host <addr>] [--port <n>] [--tls [--tls-san <ip|dns>]... | --tls-cert <pem> --tls-key <pem>] [--max-idle <seconds>] [--systemd-socket] [--print-task]",
|
|
47
|
+
"Expose a registered endpoint over HTTP using the ukp-remote wire, or - without --endpoint - serve local endpoints as one host door routed by name: all of them, or just the ones --select names.",
|
|
48
|
+
usage: "[--endpoint <name>] [--select <names>] [--host <addr>] [--port <n>] [--tls [--tls-san <ip|dns>]... | --tls-cert <pem> --tls-key <pem>] [--max-idle <seconds>] [--systemd-socket] [--print-task [--write]]",
|
|
42
49
|
options: [
|
|
43
|
-
{ flags: "--endpoint <name>", help: "the registered endpoint to expose; omit it to serve
|
|
50
|
+
{ flags: "--endpoint <name>", help: "the registered endpoint to expose; omit it to serve a host door (/e/<name>/ routing, one port) instead" },
|
|
51
|
+
{ flags: "--select <names>", help: "host door serving ONLY the named endpoints (comma-separated): one port, /e/<name>/ routing, and a door document that declares just this subset - unselected local endpoints are not served, routed, or declared; mutually exclusive with --endpoint" },
|
|
44
52
|
{ flags: "--host <addr>", help: "listen address (default 127.0.0.1, loopback only without a token)" },
|
|
45
53
|
{ flags: "--port <n>", help: "listen port (default 8570)" },
|
|
46
54
|
{ flags: "--tls", help: "serve HTTPS with a self-signed identity (auto-generated under .ukp/tls/, SAN covers this host's addresses; clients pin it at registration)" },
|
|
@@ -51,6 +59,7 @@ export const SERVE_SPEC: UkpCommandSpec = {
|
|
|
51
59
|
{ flags: "--max-idle <seconds>", help: "exit after <seconds> without requests (self-reap; the orphan backstop for on-demand-woken doors - fractional values accepted for tests)" },
|
|
52
60
|
{ flags: "--systemd-socket", help: "serve on the systemd socket-activation listener (LISTEN_FDS fd 3) instead of binding a port - the port belongs to your .socket unit; Linux-only" },
|
|
53
61
|
{ flags: "--print-task", help: "Windows: print this door's process-manager artifacts (start script, hidden launcher, Task Scheduler command, firewall rule) instead of serving - review and apply them yourself; nothing is installed or started for you, and the token is yours to paste (never printed)" },
|
|
62
|
+
{ flags: "--write", help: "with --print-task: write the two artifact files (%USERPROFILE%\\.ukp\\start-door.cmd with the token interpolated from UKP_SERVE_TOKEN, plus the hidden launcher) instead of printing them - existing files are never overwritten, the token is never displayed, and the task/firewall steps stay on stdout for you to apply" },
|
|
54
63
|
],
|
|
55
64
|
helpSuffix: [
|
|
56
65
|
"",
|
|
@@ -63,7 +72,8 @@ export const SERVE_SPEC: UkpCommandSpec = {
|
|
|
63
72
|
" GET /v1/rg?query=... lexical search (ukp.rg.v1, ukp_uri handoff)",
|
|
64
73
|
"",
|
|
65
74
|
"Wire (ukp-remote v1), host door (no --endpoint):",
|
|
66
|
-
` GET ${DISCOVERY_PATH} door document (scope:"host", endpoint roster
|
|
75
|
+
` GET ${DISCOVERY_PATH} door document (scope:"host", endpoint roster -`,
|
|
76
|
+
" the --select subset when --select is given)",
|
|
67
77
|
" GET /e/<name>/.well-known/ukp.json per-endpoint document (same shape",
|
|
68
78
|
" as single-endpoint mode; TOFU pins anchor here)",
|
|
69
79
|
" POST /e/<name>/v1/search capabilities routed by endpoint name",
|
|
@@ -103,6 +113,12 @@ export const SERVE_SPEC: UkpCommandSpec = {
|
|
|
103
113
|
export interface ParsedServe {
|
|
104
114
|
/** Present = single-endpoint mode; absent = host door mode (W7). */
|
|
105
115
|
endpoint?: string;
|
|
116
|
+
/** `--select` names (ADR-REM-010): present next to an absent endpoint =
|
|
117
|
+
* subset door — the door serves, routes, and declares only these
|
|
118
|
+
* endpoints. Parsed here exactly the way register --select parses its
|
|
119
|
+
* list (comma-separated, trimmed, empty entries dropped); the execute
|
|
120
|
+
* layer's eager check (usage error with the roster) is the admission. */
|
|
121
|
+
select?: string[];
|
|
106
122
|
host: string;
|
|
107
123
|
port: number;
|
|
108
124
|
/** Present = the --max-idle self-reap timer is armed (W9). */
|
|
@@ -112,6 +128,10 @@ export interface ParsedServe {
|
|
|
112
128
|
/** Present = render the Windows process-manager artifacts instead of
|
|
113
129
|
* serving (W12; Windows-only, print-only, never starts a listener). */
|
|
114
130
|
printTask?: boolean;
|
|
131
|
+
/** Present = the print-task artifacts' file pair lands on disk with the
|
|
132
|
+
* env token interpolated (O-020 ruling C); only valid next to
|
|
133
|
+
* --print-task, and the print surface stays secret-free. */
|
|
134
|
+
write?: boolean;
|
|
115
135
|
/** `--tls-san` values, normalized to `IP:x`/`DNS:y` SAN form (repeatable);
|
|
116
136
|
* valid only next to `--tls` — the execute layer enforces the pairing.
|
|
117
137
|
* Absent when the flag is not passed (parse output stays byte-identical
|
|
@@ -120,11 +140,24 @@ export interface ParsedServe {
|
|
|
120
140
|
}
|
|
121
141
|
|
|
122
142
|
function toParsedServe(parsed: KitParsed): ParsedServe {
|
|
123
|
-
const options = parsed.options as { host?: string; port?: string; endpoint?: string; maxIdle?: string; systemdSocket?: boolean; printTask?: boolean; tlsSan?: string[] };
|
|
143
|
+
const options = parsed.options as { host?: string; port?: string; endpoint?: string; select?: string; maxIdle?: string; systemdSocket?: boolean; printTask?: boolean; write?: boolean; tlsSan?: string[] };
|
|
124
144
|
const endpoint = options.endpoint;
|
|
125
145
|
if (endpoint !== undefined && endpoint.length === 0) {
|
|
126
146
|
throw new KitUsageError("--endpoint <name> must not be empty (omit it to serve a host door)");
|
|
127
147
|
}
|
|
148
|
+
// --select parses with register --select's exact list semantics (the same
|
|
149
|
+
// operation's other side): comma-separated, trimmed, empties dropped. A
|
|
150
|
+
// flag that parses to nothing is a usage error, not an empty whitelist.
|
|
151
|
+
let select: string[] | undefined;
|
|
152
|
+
if (options.select !== undefined) {
|
|
153
|
+
select = options.select.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
|
154
|
+
if (select.length === 0) {
|
|
155
|
+
throw new KitUsageError("--select requires at least one endpoint name");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (endpoint !== undefined && select !== undefined) {
|
|
159
|
+
throw new KitUsageError("--endpoint and --select cannot be used together (--endpoint is the N=1 form; --select narrows a host door)");
|
|
160
|
+
}
|
|
128
161
|
const host = options.host ?? "127.0.0.1";
|
|
129
162
|
const port = Number(options.port ?? "8570");
|
|
130
163
|
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
|
|
@@ -138,6 +171,10 @@ function toParsedServe(parsed: KitParsed): ParsedServe {
|
|
|
138
171
|
if (printTask && systemdSocket) {
|
|
139
172
|
throw new KitUsageError("--print-task is the Windows resident form; --systemd-socket is the Linux one (systemd holds the port - see the deployment handbook)");
|
|
140
173
|
}
|
|
174
|
+
const write = options.write === true;
|
|
175
|
+
if (write && !printTask) {
|
|
176
|
+
throw new KitUsageError("--write modifies --print-task: run ukp serve --print-task --write (without --print-task, serve just serves)");
|
|
177
|
+
}
|
|
141
178
|
let maxIdleSeconds: number | undefined;
|
|
142
179
|
if (options.maxIdle !== undefined) {
|
|
143
180
|
maxIdleSeconds = Number(options.maxIdle);
|
|
@@ -160,11 +197,13 @@ function toParsedServe(parsed: KitParsed): ParsedServe {
|
|
|
160
197
|
}
|
|
161
198
|
return {
|
|
162
199
|
...(endpoint !== undefined ? { endpoint } : {}),
|
|
200
|
+
...(select !== undefined ? { select } : {}),
|
|
163
201
|
host,
|
|
164
202
|
port,
|
|
165
203
|
...(maxIdleSeconds !== undefined ? { maxIdleSeconds } : {}),
|
|
166
204
|
...(systemdSocket ? { systemdSocket } : {}),
|
|
167
205
|
...(printTask ? { printTask } : {}),
|
|
206
|
+
...(write ? { write } : {}),
|
|
168
207
|
...(tlsSan.length > 0 ? { tlsSan } : {}),
|
|
169
208
|
};
|
|
170
209
|
}
|
|
@@ -184,6 +223,9 @@ export function renderServeBanner(info: ServeInfo): string {
|
|
|
184
223
|
` listening: ${info.url}`,
|
|
185
224
|
` discovery: ${info.url.startsWith("http") ? `${info.url}${DISCOVERY_PATH}` : info.url} (host door)`,
|
|
186
225
|
` endpoints: ${info.door!.endpoints.length > 0 ? info.door!.endpoints.join(", ") : "(none - register endpoints on this host)"}`,
|
|
226
|
+
...(info.door!.select !== undefined
|
|
227
|
+
? [` select: ${info.door!.select.join(", ")} (subset door - unselected local endpoints are not served, routed, or declared)`]
|
|
228
|
+
: []),
|
|
187
229
|
` write: ${info.door!.write.length > 0 ? info.door!.write.join(", ") + " (propose via PUT /e/<name>/v1/propose/<id>)" : "(no endpoint declares propose)"}`,
|
|
188
230
|
` auth: ${info.authRequired ? "bearer token required" : "no token (loopback bind; ssh-forwarded clients authenticate by SSH key)"}`,
|
|
189
231
|
` rg: ${info.rg === "ok" ? "ok" : "missing (rg calls skip with a warning - install ripgrep on this door's PATH)"}`,
|
|
@@ -241,7 +283,7 @@ export function executeServeCommand(
|
|
|
241
283
|
context: ServeCommandContext,
|
|
242
284
|
): KitCommandResult {
|
|
243
285
|
return executeKitCommand(SERVE_SPEC, args, (parsed) => {
|
|
244
|
-
const { endpoint, host, port, maxIdleSeconds, systemdSocket, tlsSan = [] } = toParsedServe(parsed);
|
|
286
|
+
const { endpoint, select, host, port, maxIdleSeconds, systemdSocket, write, tlsSan = [] } = toParsedServe(parsed);
|
|
245
287
|
const allowAnonymous = (parsed.options as { allowAnonymous?: boolean }).allowAnonymous === true;
|
|
246
288
|
const printTask = (parsed.options as { printTask?: boolean }).printTask === true;
|
|
247
289
|
const options = parsed.options as { tls?: boolean; tlsCert?: string; tlsKey?: string };
|
|
@@ -258,10 +300,37 @@ export function executeServeCommand(
|
|
|
258
300
|
if (tlsSan.length > 0 && options.tls !== true) {
|
|
259
301
|
throw new KitUsageError("--tls-san is only used with --tls (an explicit --tls-cert certificate carries its own SAN)");
|
|
260
302
|
}
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
303
|
+
// Subset-door admission (ADR-REM-010): a name not among the local
|
|
304
|
+
// bindings is a usage error with the roster — the eager check mirrors
|
|
305
|
+
// register --select's door-import behavior and the ingress admission
|
|
306
|
+
// precedent (a typo silently narrowing the door is a zero-signal
|
|
307
|
+
// misconfiguration). An unreadable registry skips the check: that
|
|
308
|
+
// failure surface is the door's own, shown per request like today.
|
|
309
|
+
if (select !== undefined && endpoint === undefined) {
|
|
310
|
+
let localNames: string[] | undefined;
|
|
311
|
+
try {
|
|
312
|
+
localNames = readRegistry(context.registryPath)
|
|
313
|
+
.filter((binding) => binding.kind !== "remote")
|
|
314
|
+
.map((binding) => binding.name);
|
|
315
|
+
} catch {
|
|
316
|
+
localNames = undefined;
|
|
317
|
+
}
|
|
318
|
+
if (localNames !== undefined) {
|
|
319
|
+
const unknown = select.filter((name) => !localNames!.includes(name));
|
|
320
|
+
if (unknown.length > 0) {
|
|
321
|
+
throw new KitUsageError(
|
|
322
|
+
`--select names not registered on this host: ${unknown.join(", ")} (available: ${localNames.join(", ")})`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// W12 print branch: render the Windows artifacts and stop. No listener,
|
|
328
|
+
// no admission token check. Print mode never reads the env token - the
|
|
329
|
+
// output is secret-free by construction. Write mode (O-020 ruling C:
|
|
330
|
+
// the D-090 secret-free clause scopes to the print surface) reads
|
|
331
|
+
// UKP_SERVE_TOKEN to interpolate it into the files it writes - the
|
|
332
|
+
// token exists in exactly one place, the cmd file's bytes; stdout stays
|
|
333
|
+
// secret-free in both modes.
|
|
265
334
|
if (printTask) {
|
|
266
335
|
const preflight = printTaskPreflightError({
|
|
267
336
|
platform: process.platform,
|
|
@@ -278,13 +347,25 @@ export function executeServeCommand(
|
|
|
278
347
|
: options.tlsCert !== undefined && options.tlsKey !== undefined
|
|
279
348
|
? { mode: "certificates", certPath: options.tlsCert, keyPath: options.tlsKey }
|
|
280
349
|
: undefined;
|
|
350
|
+
const input = {
|
|
351
|
+
host,
|
|
352
|
+
port,
|
|
353
|
+
...(endpoint !== undefined ? { endpoint } : {}),
|
|
354
|
+
...(select !== undefined ? { select } : {}),
|
|
355
|
+
...(tls !== undefined ? { tls } : {}),
|
|
356
|
+
};
|
|
357
|
+
if (!write) {
|
|
358
|
+
return {
|
|
359
|
+
exitCode: 0,
|
|
360
|
+
stdout: renderServeTaskArtifacts(input),
|
|
361
|
+
stderr: "",
|
|
362
|
+
};
|
|
363
|
+
}
|
|
281
364
|
return {
|
|
282
365
|
exitCode: 0,
|
|
283
|
-
stdout:
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
...(endpoint !== undefined ? { endpoint } : {}),
|
|
287
|
-
...(tls !== undefined ? { tls } : {}),
|
|
366
|
+
stdout: writeServeTaskArtifacts(input, {
|
|
367
|
+
token: process.env.UKP_SERVE_TOKEN,
|
|
368
|
+
homeDir: context.homeDir ?? homedir(),
|
|
288
369
|
}),
|
|
289
370
|
stderr: "",
|
|
290
371
|
};
|
|
@@ -310,6 +391,7 @@ export function executeServeCommand(
|
|
|
310
391
|
host,
|
|
311
392
|
port,
|
|
312
393
|
...(endpoint !== undefined ? { endpointName: endpoint } : {}),
|
|
394
|
+
...(select !== undefined ? { select } : {}),
|
|
313
395
|
...(tokens.length > 0 ? { tokens } : {}),
|
|
314
396
|
...(maxIdleSeconds !== undefined ? { maxIdleSeconds } : {}),
|
|
315
397
|
...(systemdSocket !== undefined && systemdSocket ? { systemdSocket } : {}),
|
package/src/server.ts
CHANGED
|
@@ -44,18 +44,22 @@ import { KitUsageError } from "./commands/kit.ts";
|
|
|
44
44
|
/** ukp-remote wire v1 server core (ADR-REM-001/002/003, ukp_remote W1/W7):
|
|
45
45
|
* two serving shapes over one wire. Single-endpoint mode exposes ONE
|
|
46
46
|
* registered endpoint; door mode (`endpointName` absent, ADR-REM-004) serves
|
|
47
|
-
*
|
|
47
|
+
* local bindings in the registry, routed by name at `/e/<name>/…` with
|
|
48
48
|
* a `scope:"host"` door document — the registry is read fresh per request so
|
|
49
|
-
* the door grows without restart.
|
|
50
|
-
* (
|
|
51
|
-
*
|
|
52
|
-
* per-endpoint documents
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* nav
|
|
57
|
-
*
|
|
58
|
-
*
|
|
49
|
+
* the door grows without restart. Door mode comes in two widths
|
|
50
|
+
* (ADR-REM-010): every local binding, or the `select` whitelist subset. To
|
|
51
|
+
* anyone the subset excludes, the door pretends those endpoints do not
|
|
52
|
+
* exist — roster, routes, per-endpoint documents, and 404 rosters all see
|
|
53
|
+
* the same effective set (select ∩ current registry). Both shapes expose a
|
|
54
|
+
* discovery document (manifest projection + protocol version + instance
|
|
55
|
+
* identity) plus the read-side capability routes (search/read since W1,
|
|
56
|
+
* nav/rg since W6); the per-endpoint documents are byte-identical across
|
|
57
|
+
* modes, so client-side TOFU and name-assertion paths are shared. The
|
|
58
|
+
* capability layer runs unchanged behind the routes (provider/transport
|
|
59
|
+
* axes stay orthogonal): search via `runSearch` with a single-endpoint
|
|
60
|
+
* scope, read via `runRead`, nav via `runNav`, rg via `runRg`. There is no
|
|
61
|
+
* session, no streaming, and no server-side artifact: responses inline the
|
|
62
|
+
* reference data (RQ-07) and generate a serve-scoped run id. */
|
|
59
63
|
|
|
60
64
|
export const PROTOCOL_NAME = "ukp-remote";
|
|
61
65
|
export const PROTOCOL_VERSION = "1";
|
|
@@ -66,11 +70,20 @@ export const DISCOVERY_PATH = "/.well-known/ukp.json";
|
|
|
66
70
|
export const PROPOSE_BODY_LIMIT_BYTES = 1024 * 1024;
|
|
67
71
|
|
|
68
72
|
export interface ServeConfig {
|
|
69
|
-
/** Absent = host door mode (ADR-REM-004 / O-2): serve
|
|
70
|
-
*
|
|
71
|
-
*
|
|
73
|
+
/** Absent = host door mode (ADR-REM-004 / O-2): serve local bindings in
|
|
74
|
+
* the registry over one listener, routed by name at `/e/<name>/…`, with a
|
|
75
|
+
* `scope:"host"` door document at the well-known path. Present = the
|
|
72
76
|
* original single-endpoint mode (N=1 retreat stays legal long-term). */
|
|
73
77
|
endpointName?: string;
|
|
78
|
+
/** Subset door (ADR-REM-010 / O-020 ruling D): a declarative name
|
|
79
|
+
* whitelist next to an ABSENT endpointName — the door serves only the
|
|
80
|
+
* selected local bindings, and to anyone the list excludes the door
|
|
81
|
+
* pretends those endpoints do not exist (roster, routes, per-endpoint
|
|
82
|
+
* documents, 404 rosters all carry the same effective set). The server
|
|
83
|
+
* layer does NOT validate the names at startup — the command layer's
|
|
84
|
+
* eager check (usage error with the roster) is the admission; here the
|
|
85
|
+
* set just intersects with the per-request registry read. */
|
|
86
|
+
select?: readonly string[];
|
|
74
87
|
currentDirectory: string;
|
|
75
88
|
registryPath: string;
|
|
76
89
|
qmdCommand?: readonly string[];
|
|
@@ -144,10 +157,13 @@ export interface ServeInfo {
|
|
|
144
157
|
folder?: string;
|
|
145
158
|
instanceUid?: string;
|
|
146
159
|
/** Door mode fields (present iff mode === "door"): the startup snapshot of
|
|
147
|
-
* servable endpoint names (the door itself grows without restart),
|
|
148
|
-
*
|
|
149
|
-
* "you opened a write face" line (ADR-REM-005 verdict C)
|
|
150
|
-
|
|
160
|
+
* servable endpoint names (the door itself grows without restart), the
|
|
161
|
+
* snapshot of write-capable (propose-declaring) names — the banner's
|
|
162
|
+
* "you opened a write face" line (ADR-REM-005 verdict C) — and, on a
|
|
163
|
+
* subset door (ADR-REM-010), the declared select whitelist itself (the
|
|
164
|
+
* banner's `select:` line: what was asked for, next to what is being
|
|
165
|
+
* served). */
|
|
166
|
+
door?: { endpoints: string[]; write: string[]; select?: string[] };
|
|
151
167
|
url: string;
|
|
152
168
|
host: string;
|
|
153
169
|
port: number;
|
|
@@ -158,7 +174,7 @@ export interface ServeInfo {
|
|
|
158
174
|
* consumer-channel only. The banner line is the operator channel, and the
|
|
159
175
|
* probe environment is by construction the one that matters (ukp diagnose
|
|
160
176
|
* measures the invoking shell instead, which can differ from the door's
|
|
161
|
-
*
|
|
177
|
+
* Task Scheduler / wake environment). */
|
|
162
178
|
rg: "ok" | "missing";
|
|
163
179
|
/** TLS identity summary (W5' / D-079) when serving over HTTPS; the pin is
|
|
164
180
|
* what remote clients TOFU-pin at registration. `source` "re-signed" marks
|
|
@@ -279,21 +295,25 @@ export function buildDiscoveryDocument(
|
|
|
279
295
|
};
|
|
280
296
|
}
|
|
281
297
|
|
|
282
|
-
/** Servable endpoints of a door (O-2):
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
|
|
298
|
+
/** Servable endpoints of a door (O-2): the LOCAL bindings in the registry
|
|
299
|
+
* the door's width admits — every one of them, or the `select` whitelist's
|
|
300
|
+
* members (ADR-REM-010) — read fresh by the caller per request (new
|
|
301
|
+
* endpoints appear without a restart). Remote bindings are not servable and
|
|
302
|
+
* never make the roster. */
|
|
303
|
+
function listDoorEndpoints(registryPath: string, select?: readonly string[]): Array<{ name: string; folder: string }> {
|
|
286
304
|
return readRegistry(registryPath)
|
|
287
305
|
.filter((binding) => binding.kind !== "remote")
|
|
306
|
+
.filter((binding) => select === undefined || select.includes(binding.name))
|
|
288
307
|
.map((binding) => ({ name: binding.name, folder: binding.path! }));
|
|
289
308
|
}
|
|
290
309
|
|
|
291
310
|
/** Startup snapshot of write-capable (propose-declaring) door endpoints —
|
|
292
311
|
* the banner line that makes the opened write face visible (ADR-REM-005
|
|
293
|
-
* verdict C).
|
|
294
|
-
*
|
|
295
|
-
|
|
296
|
-
|
|
312
|
+
* verdict C). Subset doors narrow it to the select set (ADR-REM-010).
|
|
313
|
+
* Unreadable/name-drifted Services drop out silently here; they are already
|
|
314
|
+
* reported by the roster/discovery paths. */
|
|
315
|
+
function listWriteEndpoints(registryPath: string, select?: readonly string[]): string[] {
|
|
316
|
+
return listDoorEndpoints(registryPath, select).flatMap((endpoint) => {
|
|
297
317
|
try {
|
|
298
318
|
const loaded = loadManifest(endpoint.folder);
|
|
299
319
|
return loaded.effectiveName === endpoint.name
|
|
@@ -758,10 +778,14 @@ function bearerGate(
|
|
|
758
778
|
* read fresh, the name segment has already passed ENDPOINT_NAME (injection
|
|
759
779
|
* unreachable), unknown and non-servable names get the roster 404, and the
|
|
760
780
|
* RQ-14 binding↔manifest identity check runs per request like the
|
|
761
|
-
* single-mode discovery fetch does.
|
|
781
|
+
* single-mode discovery fetch does. A subset door (ADR-REM-010) routes an
|
|
782
|
+
* excluded name into the SAME roster-404 branch as an unknown one — the
|
|
783
|
+
* response is byte-identical either way, so the roster is the only name
|
|
784
|
+
* list any response ever carries. */
|
|
762
785
|
function resolveDoorRoute(
|
|
763
786
|
registryPath: string,
|
|
764
787
|
name: string,
|
|
788
|
+
select?: readonly string[],
|
|
765
789
|
): { folder: string } | { response: Response } {
|
|
766
790
|
let binding;
|
|
767
791
|
try {
|
|
@@ -770,8 +794,8 @@ function resolveDoorRoute(
|
|
|
770
794
|
const reason = error instanceof Error ? error.message : String(error);
|
|
771
795
|
return { response: jsonResponse(errorBody("provider-unavailable", `Host Registry is not readable: ${reason}`), 503) };
|
|
772
796
|
}
|
|
773
|
-
if (binding === undefined || binding.kind === "remote") {
|
|
774
|
-
const roster = listDoorEndpoints(registryPath).map((endpoint) => endpoint.name).join(", ");
|
|
797
|
+
if (binding === undefined || binding.kind === "remote" || (select !== undefined && !select.includes(name))) {
|
|
798
|
+
const roster = listDoorEndpoints(registryPath, select).map((endpoint) => endpoint.name).join(", ");
|
|
775
799
|
return {
|
|
776
800
|
response: jsonResponse(
|
|
777
801
|
errorBody("not-found", `no such endpoint '${name}' on this host door (available: ${roster})`),
|
|
@@ -808,10 +832,13 @@ function resolveDoorRoute(
|
|
|
808
832
|
|
|
809
833
|
/** Door-mode routing table (ADR-REM-004 / O-2). The door document and the
|
|
810
834
|
* per-endpoint documents stay public (ADR-REM-003 declaration/capability
|
|
811
|
-
* divide); every `/e/<name>/v1/*` route passes the door-level bearer gate.
|
|
812
|
-
|
|
835
|
+
* divide); every `/e/<name>/v1/*` route passes the door-level bearer gate.
|
|
836
|
+
* The roster a 404 carries is the door's effective set — on a subset door
|
|
837
|
+
* (ADR-REM-010) that is the select ∩ registry intersection, never the full
|
|
838
|
+
* registry. */
|
|
839
|
+
function doorNotFound(name: string | undefined, registryPath: string, select?: readonly string[]): Response {
|
|
813
840
|
if (name !== undefined) {
|
|
814
|
-
const roster = listDoorEndpoints(registryPath).map((endpoint) => endpoint.name).join(", ");
|
|
841
|
+
const roster = listDoorEndpoints(registryPath, select).map((endpoint) => endpoint.name).join(", ");
|
|
815
842
|
return jsonResponse(
|
|
816
843
|
errorBody("not-found", `no such endpoint '${name}' on this host door (available: ${roster})`),
|
|
817
844
|
404,
|
|
@@ -827,6 +854,11 @@ function doorNotFound(name: string | undefined, registryPath: string): Response
|
|
|
827
854
|
}
|
|
828
855
|
|
|
829
856
|
function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnType<typeof resolveTlsMaterial>): StartedServe {
|
|
857
|
+
// The door's width (ADR-REM-010): undefined = the whole registry, a name
|
|
858
|
+
// list = the subset whitelist. One variable feeds every surface below —
|
|
859
|
+
// door document, per-request routes, 404 rosters — so the subset cannot
|
|
860
|
+
// drift between them.
|
|
861
|
+
const select = config.select;
|
|
830
862
|
const handler = async (request: Request): Promise<Response> => {
|
|
831
863
|
const url = new URL(request.url);
|
|
832
864
|
|
|
@@ -835,7 +867,7 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
|
|
|
835
867
|
return jsonResponse(errorBody("method-not-allowed", `the discovery document is a GET resource`), 405);
|
|
836
868
|
}
|
|
837
869
|
try {
|
|
838
|
-
return jsonResponse(buildDoorDocument(listDoorEndpoints(config.registryPath), (config.tokens?.length ?? 0) > 0));
|
|
870
|
+
return jsonResponse(buildDoorDocument(listDoorEndpoints(config.registryPath, select), (config.tokens?.length ?? 0) > 0));
|
|
839
871
|
} catch (error) {
|
|
840
872
|
const reason = error instanceof Error ? error.message : String(error);
|
|
841
873
|
return jsonResponse(errorBody("provider-unavailable", `Host Registry is not readable: ${reason}`), 503);
|
|
@@ -849,13 +881,13 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
|
|
|
849
881
|
const name = routeMatch[1]!;
|
|
850
882
|
const rest = routeMatch[2] ?? "/";
|
|
851
883
|
if (!ENDPOINT_NAME.test(name)) {
|
|
852
|
-
return doorNotFound(name, config.registryPath);
|
|
884
|
+
return doorNotFound(name, config.registryPath, select);
|
|
853
885
|
}
|
|
854
886
|
if (rest === DISCOVERY_PATH) {
|
|
855
887
|
if (request.method !== "GET") {
|
|
856
888
|
return jsonResponse(errorBody("method-not-allowed", `the discovery document is a GET resource`), 405);
|
|
857
889
|
}
|
|
858
|
-
const resolved = resolveDoorRoute(config.registryPath, name);
|
|
890
|
+
const resolved = resolveDoorRoute(config.registryPath, name, select);
|
|
859
891
|
if ("response" in resolved) return resolved.response;
|
|
860
892
|
try {
|
|
861
893
|
const loaded = loadManifest(resolved.folder);
|
|
@@ -871,15 +903,15 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
|
|
|
871
903
|
const denied = bearerGate(request, config.tokens);
|
|
872
904
|
if (denied !== undefined) return denied;
|
|
873
905
|
const id = rest.slice("/v1/propose/".length);
|
|
874
|
-
if (id.includes("/")) return doorNotFound(undefined, config.registryPath);
|
|
875
|
-
const resolved = resolveDoorRoute(config.registryPath, name);
|
|
906
|
+
if (id.includes("/")) return doorNotFound(undefined, config.registryPath, select);
|
|
907
|
+
const resolved = resolveDoorRoute(config.registryPath, name, select);
|
|
876
908
|
if ("response" in resolved) return resolved.response;
|
|
877
909
|
return await handlePropose(request, config, name, resolved.folder, id);
|
|
878
910
|
}
|
|
879
911
|
if (rest === "/v1/search" || rest === "/v1/read" || rest === "/v1/nav" || rest === "/v1/rg") {
|
|
880
912
|
const denied = bearerGate(request, config.tokens);
|
|
881
913
|
if (denied !== undefined) return denied;
|
|
882
|
-
const resolved = resolveDoorRoute(config.registryPath, name);
|
|
914
|
+
const resolved = resolveDoorRoute(config.registryPath, name, select);
|
|
883
915
|
if ("response" in resolved) return resolved.response;
|
|
884
916
|
if (rest === "/v1/search") {
|
|
885
917
|
return await handleSearch(request, config, name, resolved.folder);
|
|
@@ -892,10 +924,10 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
|
|
|
892
924
|
}
|
|
893
925
|
return handleRg(url, request, config, name);
|
|
894
926
|
}
|
|
895
|
-
return doorNotFound(undefined, config.registryPath);
|
|
927
|
+
return doorNotFound(undefined, config.registryPath, select);
|
|
896
928
|
}
|
|
897
929
|
|
|
898
|
-
return doorNotFound(undefined, config.registryPath);
|
|
930
|
+
return doorNotFound(undefined, config.registryPath, select);
|
|
899
931
|
};
|
|
900
932
|
|
|
901
933
|
const listener = startServeListener(handler, {
|
|
@@ -910,8 +942,9 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
|
|
|
910
942
|
const info: ServeInfo = {
|
|
911
943
|
mode: "door",
|
|
912
944
|
door: {
|
|
913
|
-
endpoints: listDoorEndpoints(config.registryPath).map((endpoint) => endpoint.name),
|
|
914
|
-
write: listWriteEndpoints(config.registryPath),
|
|
945
|
+
endpoints: listDoorEndpoints(config.registryPath, select).map((endpoint) => endpoint.name),
|
|
946
|
+
write: listWriteEndpoints(config.registryPath, select),
|
|
947
|
+
...(select !== undefined ? { select: [...select] } : {}),
|
|
915
948
|
},
|
|
916
949
|
url: listener.publicUrl,
|
|
917
950
|
host,
|