@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.
@@ -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 whole registry
34
- * over `/e/<name>/` routing. */
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 - or the whole registry as a host door, one HTTP server for every endpoint - for remote UKP clients",
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 every local endpoint as one host door routed by name.",
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 the whole registry as a host door (/e/<name>/ routing, all endpoints, one port)" },
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
- // W12 print-only branch: render the Windows artifacts and stop. No
262
- // listener, no admission token check (the token is pasted into the
263
- // generated script later), and the env token is deliberately not read
264
- // — the output is secret-free by construction.
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: renderServeTaskArtifacts({
284
- host,
285
- port,
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
- * every local binding in the registry, routed by name at `/e/<name>/…` with
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. Both shapes expose a discovery document
50
- * (manifest projection + protocol version + instance identity) plus the
51
- * read-side capability routes (search/read since W1, nav/rg since W6); the
52
- * per-endpoint documents are byte-identical across modes, so client-side
53
- * TOFU and name-assertion paths are shared. The capability layer runs
54
- * unchanged behind the routes (provider/transport axes stay orthogonal):
55
- * search via `runSearch` with a single-endpoint scope, read via `runRead`,
56
- * nav via `runNav`, rg via `runRg`. There is no session, no streaming, and
57
- * no server-side artifact: responses inline the reference data (RQ-07) and
58
- * generate a serve-scoped run id. */
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 EVERY local binding
70
- * in the registry over one listener, routed by name at `/e/<name>/…`, with
71
- * a `scope:"host"` door document at the well-known path. Present = the
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), plus
148
- * the snapshot of write-capable (propose-declaring) names — the banner's
149
- * "you opened a write face" line (ADR-REM-005 verdict C). */
150
- door?: { endpoints: string[]; write: string[] };
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
- * schtasks / wake environment). */
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): every LOCAL binding in the registry,
283
- * read fresh by the caller per request (new endpoints appear without a
284
- * restart). Remote bindings are not servable and never make the roster. */
285
- function listDoorEndpoints(registryPath: string): Array<{ name: string; folder: string }> {
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). Unreadable/name-drifted Services drop out silently here;
294
- * they are already reported by the roster/discovery paths. */
295
- function listWriteEndpoints(registryPath: string): string[] {
296
- return listDoorEndpoints(registryPath).flatMap((endpoint) => {
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
- function doorNotFound(name: string | undefined, registryPath: string): Response {
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,