@catheadowl/ukp 0.2.5 → 0.3.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/src/server.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { dirname, join } from "node:path";
2
2
  import { readFileSync, writeFileSync, fstatSync, constants as fsConstants } from "node:fs";
3
+ import { createHash, timingSafeEqual } from "node:crypto";
3
4
  import { createServer as netCreateServer, connect as netConnect, type Server as NetServer } from "node:net";
4
5
  import { ENDPOINT_NAME, loadManifest, type LoadedManifest } from "./config/manifest.ts";
5
6
  import { localPathOf, readRegistry } from "./registry.ts";
@@ -12,7 +13,8 @@ import {
12
13
  proposeEnvelope,
13
14
  proposeUpsert,
14
15
  } from "./capabilities/propose.ts";
15
- import { certSanOf, ensureSelfSignedTlsFiles, spkiPinOf } from "./capabilities/tls-identity.ts";
16
+ import { certSanOf, ensureSelfSignedTlsFiles, migrateLegacyServiceTlsDir, spkiPinOf } from "./capabilities/tls-identity.ts";
17
+ import { retireTlsIgnoreLine } from "./commands/init.ts";
16
18
  import {
17
19
  buildInlineReferences,
18
20
  projectSearchEnvelope,
@@ -40,22 +42,39 @@ import {
40
42
  type ParsedRg,
41
43
  } from "./capabilities/rg.ts";
42
44
  import { KitUsageError } from "./commands/kit.ts";
45
+ import {
46
+ authenticateBearerKey,
47
+ loadTokenFile,
48
+ parseTokenFileText,
49
+ scopeAllows,
50
+ type ScopeAccess,
51
+ type TokenFileEntry,
52
+ } from "./plumbing/token-file.ts";
43
53
 
44
54
  /** ukp-remote wire v1 server core (ADR-REM-001/002/003, ukp_remote W1/W7):
45
55
  * two serving shapes over one wire. Single-endpoint mode exposes ONE
46
56
  * registered endpoint; door mode (`endpointName` absent, ADR-REM-004) serves
47
- * every local binding in the registry, routed by name at `/e/<name>/…` with
57
+ * local bindings in the registry, routed by name at `/e/<name>/…` with
48
58
  * 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. */
59
+ * the door grows without restart. Door mode comes in two widths
60
+ * (ADR-REM-010): every local binding, or the `select` whitelist subset. To
61
+ * anyone the subset excludes, the door pretends those endpoints do not
62
+ * exist — roster, routes, per-endpoint documents, and 404 rosters all see
63
+ * the same effective set (select ∩ current registry). Both shapes expose a
64
+ * discovery document (manifest projection + protocol version + instance
65
+ * identity) plus the read-side capability routes (search/read since W1,
66
+ * nav/rg since W6); the per-endpoint documents are byte-identical across
67
+ * modes, so client-side TOFU and name-assertion paths are shared. The
68
+ * capability layer runs unchanged behind the routes (provider/transport
69
+ * axes stay orthogonal): search via `runSearch` with a single-endpoint
70
+ * scope, read via `runRead`, nav via `runNav`, rg via `runRg`. The bearer
71
+ * gate has two sources (W13 / RQ-16c): env tokens — the full-power
72
+ * shortcut, original RQ-16 semantics — or the labeled token file, read
73
+ * fresh per request so deleting a line revokes without a restart; a file
74
+ * key's scopes narrow the door's rosters exactly like `--select` does
75
+ * (ADR-REM-010 mechanism reuse, no 403 class). There is no
76
+ * session, no streaming, and no server-side artifact: responses inline the
77
+ * reference data (RQ-07) and generate a serve-scoped run id. */
59
78
 
60
79
  export const PROTOCOL_NAME = "ukp-remote";
61
80
  export const PROTOCOL_VERSION = "1";
@@ -66,11 +85,20 @@ export const DISCOVERY_PATH = "/.well-known/ukp.json";
66
85
  export const PROPOSE_BODY_LIMIT_BYTES = 1024 * 1024;
67
86
 
68
87
  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
88
+ /** Absent = host door mode (ADR-REM-004 / O-2): serve local bindings in
89
+ * the registry over one listener, routed by name at `/e/<name>/…`, with a
90
+ * `scope:"host"` door document at the well-known path. Present = the
72
91
  * original single-endpoint mode (N=1 retreat stays legal long-term). */
73
92
  endpointName?: string;
93
+ /** Subset door (ADR-REM-010 / O-020 ruling D): a declarative name
94
+ * whitelist next to an ABSENT endpointName — the door serves only the
95
+ * selected local bindings, and to anyone the list excludes the door
96
+ * pretends those endpoints do not exist (roster, routes, per-endpoint
97
+ * documents, 404 rosters all carry the same effective set). The server
98
+ * layer does NOT validate the names at startup — the command layer's
99
+ * eager check (usage error with the roster) is the admission; here the
100
+ * set just intersects with the per-request registry read. */
101
+ select?: readonly string[];
74
102
  currentDirectory: string;
75
103
  registryPath: string;
76
104
  qmdCommand?: readonly string[];
@@ -83,9 +111,24 @@ export interface ServeConfig {
83
111
  * readable without it). In door mode the same gate covers all
84
112
  * `/e/<name>/v1/*` routes (door-level auth, O-2). */
85
113
  tokens?: readonly string[];
114
+ /** W13 / RQ-16c: the labeled token file (`~/.ukp/tokens`). When `tokens`
115
+ * is absent, the gate arms from this file instead: it is read FRESH on
116
+ * every request (revocation = deleting the line, no restart), a presented
117
+ * `ukp_<label>_<id>_<secret>` key is verified against the row's SHA-256,
118
+ * and the matched row's scopes decide which endpoints the key may touch
119
+ * (`name:ro|rw`, Docker-registry grammar, required per row). When
120
+ * `tokens` IS set, env wins outright and the file is not consulted —
121
+ * env is the full-power shortcut form of the same gate (AWS/gh/kubectl
122
+ * chain order). Scope enforcement reuses the ADR-REM-010 roster
123
+ * mechanism: an out-of-scope name shares the roster 404 with an unknown
124
+ * one, byte-identical, and the roster a scoped 404 carries is the key's
125
+ * own effective set — no new 403 class exists. */
126
+ tokenFilePath?: string;
86
127
  /** TLS transport (W5' / D-079): `self-signed` generates and persists an
87
- * identity under the Service folder (`.ukp/tls/`) — in door mode under the
88
- * registry's directory, since a door has no single Service folder;
128
+ * identity in the machine home beside the registry — `~/.ukp/tls/<name>/`
129
+ * in endpoint mode, the flat `~/.ukp/tls/` in door mode (a door has no
130
+ * single Service folder; endpoint mode converged on the same home
131
+ * 2026-11-01 because the identity is machine-scoped material);
89
132
  * `certificates` serves operator-provided PEM files (Let's Encrypt IP
90
133
  * certs, mkcert, private CA). `sanEntries` (the `--tls-san` flag, already
91
134
  * normalized `IP:x`/`DNS:y`) merges extra coverage into the self-signed
@@ -144,21 +187,29 @@ export interface ServeInfo {
144
187
  folder?: string;
145
188
  instanceUid?: string;
146
189
  /** 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[] };
190
+ * servable endpoint names (the door itself grows without restart), the
191
+ * snapshot of write-capable (propose-declaring) names — the banner's
192
+ * "you opened a write face" line (ADR-REM-005 verdict C) — and, on a
193
+ * subset door (ADR-REM-010), the declared select whitelist itself (the
194
+ * banner's `select:` line: what was asked for, next to what is being
195
+ * served). */
196
+ door?: { endpoints: string[]; write: string[]; select?: string[] };
151
197
  url: string;
152
198
  host: string;
153
199
  port: number;
154
200
  authRequired: boolean;
201
+ /** Present when the gate armed from the token file instead of env
202
+ * (W13 / RQ-16c) — feeds the banner's operator line. `keys` is a startup
203
+ * snapshot for visibility only; the live truth is the per-request fresh
204
+ * read (delete a line, next request 401s). */
205
+ tokenFile?: { keys: number };
155
206
  /** ripgrep availability probed once at startup IN THE DOOR'S OWN PROCESS
156
207
  * (2026-09-20, liku feedback): /v1/rg serves every endpoint and a missing
157
208
  * binary degrades per-endpoint on the wire by design — but that truth is
158
209
  * consumer-channel only. The banner line is the operator channel, and the
159
210
  * probe environment is by construction the one that matters (ukp diagnose
160
211
  * measures the invoking shell instead, which can differ from the door's
161
- * schtasks / wake environment). */
212
+ * Task Scheduler / wake environment). */
162
213
  rg: "ok" | "missing";
163
214
  /** TLS identity summary (W5' / D-079) when serving over HTTPS; the pin is
164
215
  * what remote clients TOFU-pin at registration. `source` "re-signed" marks
@@ -279,21 +330,32 @@ export function buildDiscoveryDocument(
279
330
  };
280
331
  }
281
332
 
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 }> {
333
+ /** Servable endpoints of a door (O-2): the LOCAL bindings in the registry
334
+ * the door's width admits — every one of them, or the `select` whitelist's
335
+ * members (ADR-REM-010) — read fresh by the caller per request (new
336
+ * endpoints appear without a restart). Remote bindings are not servable and
337
+ * never make the roster. W13 adds the per-key narrowing axis: `admit` is
338
+ * the matched file key's scope predicate for the route's access level, so
339
+ * the roster a scoped caller can ever see is scope ∩ select ∩ registry. */
340
+ function listDoorEndpoints(
341
+ registryPath: string,
342
+ select?: readonly string[],
343
+ admit?: (name: string) => boolean,
344
+ ): Array<{ name: string; folder: string }> {
286
345
  return readRegistry(registryPath)
287
346
  .filter((binding) => binding.kind !== "remote")
347
+ .filter((binding) => select === undefined || select.includes(binding.name))
348
+ .filter((binding) => admit === undefined || admit(binding.name))
288
349
  .map((binding) => ({ name: binding.name, folder: binding.path! }));
289
350
  }
290
351
 
291
352
  /** Startup snapshot of write-capable (propose-declaring) door endpoints —
292
353
  * 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) => {
354
+ * verdict C). Subset doors narrow it to the select set (ADR-REM-010).
355
+ * Unreadable/name-drifted Services drop out silently here; they are already
356
+ * reported by the roster/discovery paths. */
357
+ function listWriteEndpoints(registryPath: string, select?: readonly string[]): string[] {
358
+ return listDoorEndpoints(registryPath, select).flatMap((endpoint) => {
297
359
  try {
298
360
  const loaded = loadManifest(endpoint.folder);
299
361
  return loaded.effectiveName === endpoint.name
@@ -693,8 +755,10 @@ function startServeListener(
693
755
 
694
756
  /** TLS material resolution (W5' / D-079), shared by both serve modes: a
695
757
  * self-signed identity is generated/re-signed/persisted under `identityDir`
696
- * (the Service folder's `.ukp/tls/` in endpoint mode, the registry directory
697
- * in door mode — a door has no single Service folder), explicit certificates
758
+ * (the machine home beside the registry: `~/.ukp/tls/<name>/` in endpoint
759
+ * mode, the home's flat root in door mode — a door has no single Service
760
+ * folder, and endpoint mode converged on the same home 2026-11-01 because
761
+ * the identity is machine-scoped material), explicit certificates
698
762
  * are read from the operator's paths. Bun.serve takes PEM contents
699
763
  * (path-string handling is platform-dependent). Every failure — unreadable
700
764
  * operator files, an unusable persisted identity, openssl unable to sign —
@@ -739,29 +803,113 @@ function resolveTlsMaterial(
739
803
  }
740
804
  }
741
805
 
806
+ /** CHK-01 ruling (D-100 / ADR 0027): the bearer check hashes both sides
807
+ * with SHA-256 and compares the fixed 32-byte digests via
808
+ * `crypto.timingSafeEqual`. Hashing first makes the comparison
809
+ * length-uniform — operator-chosen tokens (`UKP_SERVE_TOKEN`, arbitrary
810
+ * length — not fixed-length hex) and attacker-chosen header values can
811
+ * never reach the raw API's equal-length requirement, and no early exit
812
+ * (length mismatch, first differing byte) shapes the failure timing.
813
+ * Every failure class — missing header, non-Bearer scheme, wrong value —
814
+ * runs this one path and then the single 401 below, so responses stay
815
+ * byte-identical across classes (CHK-02, CWE-204 family: the gate is
816
+ * not a credential oracle). */
817
+ export function timingSafeBearerMatch(
818
+ authorization: string | null,
819
+ tokens: readonly string[],
820
+ ): boolean {
821
+ const presented = createHash("sha256").update(authorization ?? "").digest();
822
+ return tokens.some((token) =>
823
+ timingSafeEqual(createHash("sha256").update(`Bearer ${token}`).digest(), presented)
824
+ );
825
+ }
826
+
827
+ /** What the gate established about the caller (W13 / RQ-16c): an env token
828
+ * is the full-power shortcut form (its scope is the whole door, original
829
+ * RQ-16 semantics); a file key carries its row's scopes into authorization.
830
+ * `undefined` = no gate armed (loopback anonymous serving) — not a failure.
831
+ * The two never mix: env present means the file is not consulted at all. */
832
+ type GateCredential =
833
+ | { kind: "env" }
834
+ | { kind: "file"; entry: TokenFileEntry };
835
+
836
+ /** The single 401 every gate failure class emits (CHK-02 / CWE-204 family:
837
+ * the gate is not a credential oracle) — missing header, wrong scheme,
838
+ * unknown key, wrong secret, out-of-scope key on a single-endpoint gate. */
839
+ function authFailureResponse(): Response {
840
+ return jsonResponse(
841
+ errorBody("auth-failure", "missing or invalid bearer token (Authorization: Bearer <token>)"),
842
+ 401,
843
+ );
844
+ }
845
+
846
+ /** W13 / RQ-16c: whether ANY auth source is configured — env tokens or the
847
+ * token file. Drives `authRequired` (banner, discovery security.schemes)
848
+ * and the gate itself; with neither, serving is anonymous (the command
849
+ * layer's deny-by-default admission, RQ-18, is what prevents reaching here
850
+ * unarmed on non-loopback binds). */
851
+ function gateArmed(config: ServeConfig): boolean {
852
+ return (config.tokens?.length ?? 0) > 0 || config.tokenFilePath !== undefined;
853
+ }
854
+
855
+ /** The door gate, both sources (W13): env tokens compare through the
856
+ * CHK-01 digest path unchanged; a token file is fresh-read per request
857
+ * (missing/unreadable/unparseable file ⇒ no key can pass — fail closed)
858
+ * and the presented key is verified against exactly its (label, keyId) row
859
+ * with a timing-safe digest compare inside the plumbing module. */
742
860
  function bearerGate(
743
861
  request: Request,
744
- tokens: readonly string[] | undefined,
745
- ): Response | undefined {
746
- if ((tokens?.length ?? 0) === 0) return undefined;
747
- const authorization = request.headers.get("authorization");
748
- if (!tokens!.some((token) => authorization === `Bearer ${token}`)) {
749
- return jsonResponse(
750
- errorBody("auth-failure", "missing or invalid bearer token (Authorization: Bearer <token>)"),
751
- 401,
752
- );
862
+ config: ServeConfig,
863
+ ): { credential: GateCredential | undefined } | { response: Response } {
864
+ if (!gateArmed(config)) return { credential: undefined };
865
+ if ((config.tokens?.length ?? 0) > 0) {
866
+ return timingSafeBearerMatch(request.headers.get("authorization"), config.tokens!)
867
+ ? { credential: { kind: "env" } }
868
+ : { response: authFailureResponse() };
753
869
  }
754
- return undefined;
870
+ const loaded = loadTokenFile(config.tokenFilePath!);
871
+ if (!("text" in loaded)) return { response: authFailureResponse() };
872
+ const parsed = parseTokenFileText(loaded.text);
873
+ if ("error" in parsed) return { response: authFailureResponse() };
874
+ const authorization = request.headers.get("authorization");
875
+ // Scheme shape is part of the uniform failure path: a non-Bearer header
876
+ // fails exactly like a missing one (single 401, no early distinctions).
877
+ const match = /^Bearer (.+)$/.exec(authorization ?? "");
878
+ if (match === null) return { response: authFailureResponse() };
879
+ const entry = authenticateBearerKey(match[1]!, parsed.entries);
880
+ return entry !== undefined ? { credential: { kind: "file", entry } } : { response: authFailureResponse() };
881
+ }
882
+
883
+ /** Per-request roster admission from the matched key's scopes (rehearsal
884
+ * point E): read routes demand `ro`, the propose route demands `rw`. A
885
+ * `ro`-scoped endpoint is therefore absent from the write face's effective
886
+ * set the same way an unselected name is absent from a subset door — same
887
+ * roster-404 shape, no new refusal class. Env credentials and anonymous
888
+ * serving return undefined (the door's own width applies). */
889
+ function scopeAdmit(
890
+ credential: GateCredential | undefined,
891
+ access: ScopeAccess,
892
+ ): ((name: string) => boolean) | undefined {
893
+ if (credential?.kind !== "file") return undefined;
894
+ return (name: string) => scopeAllows(credential.entry.scopes, name, access);
755
895
  }
756
896
 
757
897
  /** Per-request endpoint resolution for door routing (O-2): the registry is
758
898
  * read fresh, the name segment has already passed ENDPOINT_NAME (injection
759
899
  * unreachable), unknown and non-servable names get the roster 404, and the
760
900
  * RQ-14 binding↔manifest identity check runs per request like the
761
- * single-mode discovery fetch does. */
901
+ * single-mode discovery fetch does. A subset door (ADR-REM-010) routes an
902
+ * excluded name into the SAME roster-404 branch as an unknown one — the
903
+ * response is byte-identical either way, so the roster is the only name
904
+ * list any response ever carries. W13 scopes ride the same branch: an
905
+ * out-of-scope name for the matched key is indistinguishable from an
906
+ * unknown one, and the roster it sees is the key's own effective set
907
+ * (`admit` = scope ∩ select ∩ registry), never the wider door. */
762
908
  function resolveDoorRoute(
763
909
  registryPath: string,
764
910
  name: string,
911
+ select?: readonly string[],
912
+ admit?: (name: string) => boolean,
765
913
  ): { folder: string } | { response: Response } {
766
914
  let binding;
767
915
  try {
@@ -770,8 +918,13 @@ function resolveDoorRoute(
770
918
  const reason = error instanceof Error ? error.message : String(error);
771
919
  return { response: jsonResponse(errorBody("provider-unavailable", `Host Registry is not readable: ${reason}`), 503) };
772
920
  }
773
- if (binding === undefined || binding.kind === "remote") {
774
- const roster = listDoorEndpoints(registryPath).map((endpoint) => endpoint.name).join(", ");
921
+ if (
922
+ binding === undefined
923
+ || binding.kind === "remote"
924
+ || (select !== undefined && !select.includes(name))
925
+ || (admit !== undefined && !admit(name))
926
+ ) {
927
+ const roster = listDoorEndpoints(registryPath, select, admit).map((endpoint) => endpoint.name).join(", ");
775
928
  return {
776
929
  response: jsonResponse(
777
930
  errorBody("not-found", `no such endpoint '${name}' on this host door (available: ${roster})`),
@@ -808,10 +961,19 @@ function resolveDoorRoute(
808
961
 
809
962
  /** Door-mode routing table (ADR-REM-004 / O-2). The door document and the
810
963
  * 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 {
964
+ * divide); every `/e/<name>/v1/*` route passes the door-level bearer gate.
965
+ * The roster a 404 carries is the door's effective set — on a subset door
966
+ * (ADR-REM-010) that is the select ∩ registry intersection, never the full
967
+ * registry; on an authenticated scoped route (W13) it further intersects
968
+ * with the matched key's scopes via `admit`. */
969
+ function doorNotFound(
970
+ name: string | undefined,
971
+ registryPath: string,
972
+ select?: readonly string[],
973
+ admit?: (name: string) => boolean,
974
+ ): Response {
813
975
  if (name !== undefined) {
814
- const roster = listDoorEndpoints(registryPath).map((endpoint) => endpoint.name).join(", ");
976
+ const roster = listDoorEndpoints(registryPath, select, admit).map((endpoint) => endpoint.name).join(", ");
815
977
  return jsonResponse(
816
978
  errorBody("not-found", `no such endpoint '${name}' on this host door (available: ${roster})`),
817
979
  404,
@@ -827,6 +989,11 @@ function doorNotFound(name: string | undefined, registryPath: string): Response
827
989
  }
828
990
 
829
991
  function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnType<typeof resolveTlsMaterial>): StartedServe {
992
+ // The door's width (ADR-REM-010): undefined = the whole registry, a name
993
+ // list = the subset whitelist. One variable feeds every surface below —
994
+ // door document, per-request routes, 404 rosters — so the subset cannot
995
+ // drift between them.
996
+ const select = config.select;
830
997
  const handler = async (request: Request): Promise<Response> => {
831
998
  const url = new URL(request.url);
832
999
 
@@ -835,7 +1002,9 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
835
1002
  return jsonResponse(errorBody("method-not-allowed", `the discovery document is a GET resource`), 405);
836
1003
  }
837
1004
  try {
838
- return jsonResponse(buildDoorDocument(listDoorEndpoints(config.registryPath), (config.tokens?.length ?? 0) > 0));
1005
+ // Public door document (ADR-REM-003): no key presented, no scope
1006
+ // narrowing — the door's own width is what the roster declares.
1007
+ return jsonResponse(buildDoorDocument(listDoorEndpoints(config.registryPath, select), gateArmed(config)));
839
1008
  } catch (error) {
840
1009
  const reason = error instanceof Error ? error.message : String(error);
841
1010
  return jsonResponse(errorBody("provider-unavailable", `Host Registry is not readable: ${reason}`), 503);
@@ -849,18 +1018,18 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
849
1018
  const name = routeMatch[1]!;
850
1019
  const rest = routeMatch[2] ?? "/";
851
1020
  if (!ENDPOINT_NAME.test(name)) {
852
- return doorNotFound(name, config.registryPath);
1021
+ return doorNotFound(name, config.registryPath, select);
853
1022
  }
854
1023
  if (rest === DISCOVERY_PATH) {
855
1024
  if (request.method !== "GET") {
856
1025
  return jsonResponse(errorBody("method-not-allowed", `the discovery document is a GET resource`), 405);
857
1026
  }
858
- const resolved = resolveDoorRoute(config.registryPath, name);
1027
+ const resolved = resolveDoorRoute(config.registryPath, name, select);
859
1028
  if ("response" in resolved) return resolved.response;
860
1029
  try {
861
1030
  const loaded = loadManifest(resolved.folder);
862
1031
  return jsonResponse(
863
- buildDiscoveryDocument(loaded, readInstanceUid(resolved.folder), (config.tokens?.length ?? 0) > 0),
1032
+ buildDiscoveryDocument(loaded, readInstanceUid(resolved.folder), gateArmed(config)),
864
1033
  );
865
1034
  } catch (error) {
866
1035
  const reason = error instanceof Error ? error.message : String(error);
@@ -868,18 +1037,22 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
868
1037
  }
869
1038
  }
870
1039
  if (rest.startsWith("/v1/propose/")) {
871
- const denied = bearerGate(request, config.tokens);
872
- if (denied !== undefined) return denied;
1040
+ const gate = bearerGate(request, config);
1041
+ if ("response" in gate) return gate.response;
1042
+ // The write face demands the rw bit: a ro-scoped endpoint is not in
1043
+ // this key's effective write roster (rehearsal point E).
1044
+ const admit = scopeAdmit(gate.credential, "rw");
873
1045
  const id = rest.slice("/v1/propose/".length);
874
- if (id.includes("/")) return doorNotFound(undefined, config.registryPath);
875
- const resolved = resolveDoorRoute(config.registryPath, name);
1046
+ if (id.includes("/")) return doorNotFound(undefined, config.registryPath, select, admit);
1047
+ const resolved = resolveDoorRoute(config.registryPath, name, select, admit);
876
1048
  if ("response" in resolved) return resolved.response;
877
1049
  return await handlePropose(request, config, name, resolved.folder, id);
878
1050
  }
879
1051
  if (rest === "/v1/search" || rest === "/v1/read" || rest === "/v1/nav" || rest === "/v1/rg") {
880
- const denied = bearerGate(request, config.tokens);
881
- if (denied !== undefined) return denied;
882
- const resolved = resolveDoorRoute(config.registryPath, name);
1052
+ const gate = bearerGate(request, config);
1053
+ if ("response" in gate) return gate.response;
1054
+ const admit = scopeAdmit(gate.credential, "ro");
1055
+ const resolved = resolveDoorRoute(config.registryPath, name, select, admit);
883
1056
  if ("response" in resolved) return resolved.response;
884
1057
  if (rest === "/v1/search") {
885
1058
  return await handleSearch(request, config, name, resolved.folder);
@@ -888,14 +1061,14 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
888
1061
  return handleRead(url, request, config, name);
889
1062
  }
890
1063
  if (rest === "/v1/nav") {
891
- return handleNav(url, request, config, name);
1064
+ return await handleNav(url, request, config, name);
892
1065
  }
893
1066
  return handleRg(url, request, config, name);
894
1067
  }
895
- return doorNotFound(undefined, config.registryPath);
1068
+ return doorNotFound(undefined, config.registryPath, select);
896
1069
  }
897
1070
 
898
- return doorNotFound(undefined, config.registryPath);
1071
+ return doorNotFound(undefined, config.registryPath, select);
899
1072
  };
900
1073
 
901
1074
  const listener = startServeListener(handler, {
@@ -906,17 +1079,20 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
906
1079
  ...(config.systemdSocket === true ? { systemdSocket: true } : {}),
907
1080
  });
908
1081
  const { server } = listener;
1082
+ const tokenFile = tokenFileSnapshot(config);
909
1083
 
910
1084
  const info: ServeInfo = {
911
1085
  mode: "door",
912
1086
  door: {
913
- endpoints: listDoorEndpoints(config.registryPath).map((endpoint) => endpoint.name),
914
- write: listWriteEndpoints(config.registryPath),
1087
+ endpoints: listDoorEndpoints(config.registryPath, select).map((endpoint) => endpoint.name),
1088
+ write: listWriteEndpoints(config.registryPath, select),
1089
+ ...(select !== undefined ? { select: [...select] } : {}),
915
1090
  },
916
1091
  url: listener.publicUrl,
917
1092
  host,
918
1093
  port: listener.port,
919
- authRequired: (config.tokens?.length ?? 0) > 0,
1094
+ authRequired: gateArmed(config),
1095
+ ...(tokenFile !== undefined ? { tokenFile } : {}),
920
1096
  rg: rgExecutableAvailable() ? "ok" : "missing",
921
1097
  ...(tlsMaterial !== undefined
922
1098
  ? { tls: { pin: tlsMaterial.pin, san: tlsMaterial.san, source: tlsMaterial.source } }
@@ -926,6 +1102,19 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
926
1102
  return { server, info, stopAll: listener.stopAll };
927
1103
  }
928
1104
 
1105
+ /** Startup snapshot of the token file for the banner's operator line
1106
+ * (W13): the live gate re-reads per request; this count is visibility, not
1107
+ * truth. A parse failure here is cosmetic-zero — the command layer's
1108
+ * admission already refused a malformed file, and the gate itself fails
1109
+ * closed on any read/parse problem. */
1110
+ function tokenFileSnapshot(config: ServeConfig): { keys: number } | undefined {
1111
+ if (config.tokenFilePath === undefined || (config.tokens?.length ?? 0) > 0) return undefined;
1112
+ const loaded = loadTokenFile(config.tokenFilePath);
1113
+ if (!("text" in loaded)) return { keys: 0 };
1114
+ const parsed = parseTokenFileText(loaded.text);
1115
+ return "error" in parsed ? { keys: 0 } : { keys: parsed.entries.length };
1116
+ }
1117
+
929
1118
  export function startUkpServer(config: ServeConfig): StartedServe {
930
1119
  const host = config.host ?? "127.0.0.1";
931
1120
 
@@ -955,7 +1144,21 @@ export function startUkpServer(config: ServeConfig): StartedServe {
955
1144
  );
956
1145
  }
957
1146
 
958
- const tlsMaterial = resolveTlsMaterial(config, join(serviceFolder, ".ukp", "tls"));
1147
+ // The self-signed identity is machine-scoped material (its SAN coverage
1148
+ // names THIS host's interfaces) and lives in the machine home beside the
1149
+ // registry — `~/.ukp/tls/<endpoint>/` here, the same home's flat root in
1150
+ // door mode (adjudication 2026-11-01, inbox service-dir-layering item 2;
1151
+ // endpoint mode converged on the home door mode already used). Keyed by
1152
+ // the manifest's effective name: the registry namespace holds one
1153
+ // endpoint per name, so a re-cloned folder re-attaches to its pinned
1154
+ // identity instead of orphaning every client (INV-04). A pre-2026-11-01
1155
+ // `.ukp/tls/` moves in once, keypair untouched; the retired `/tls/`
1156
+ // ignore rule swaps out in the same gesture.
1157
+ const identityDir = join(dirname(config.registryPath), "tls", startupManifest.effectiveName);
1158
+ if (migrateLegacyServiceTlsDir(serviceFolder, identityDir) === "moved") {
1159
+ retireTlsIgnoreLine(join(serviceFolder, ".ukp"));
1160
+ }
1161
+ const tlsMaterial = resolveTlsMaterial(config, identityDir);
959
1162
 
960
1163
  const handler = async (request: Request): Promise<Response> => {
961
1164
  const url = new URL(request.url);
@@ -978,7 +1181,7 @@ export function startUkpServer(config: ServeConfig): StartedServe {
978
1181
  );
979
1182
  }
980
1183
  return jsonResponse(
981
- buildDiscoveryDocument(loaded, readInstanceUid(serviceFolder), (config.tokens?.length ?? 0) > 0),
1184
+ buildDiscoveryDocument(loaded, readInstanceUid(serviceFolder), gateArmed(config)),
982
1185
  );
983
1186
  } catch (error) {
984
1187
  return jsonResponse(
@@ -989,8 +1192,15 @@ export function startUkpServer(config: ServeConfig): StartedServe {
989
1192
  }
990
1193
 
991
1194
  if (url.pathname.startsWith("/v1/propose/")) {
992
- const denied = bearerGate(request, config.tokens);
993
- if (denied !== undefined) return denied;
1195
+ const gate = bearerGate(request, config);
1196
+ if ("response" in gate) return gate.response;
1197
+ // Single-endpoint gates have no roster to hide behind: a key that
1198
+ // does not carry this endpoint at rw is simply not valid for this
1199
+ // gate's write face — the same uniform 401, no new refusal class.
1200
+ if (gate.credential?.kind === "file"
1201
+ && !scopeAllows(gate.credential.entry.scopes, binding.name, "rw")) {
1202
+ return authFailureResponse();
1203
+ }
994
1204
  const id = url.pathname.slice("/v1/propose/".length);
995
1205
  if (id.includes("/")) {
996
1206
  return jsonResponse(
@@ -1010,8 +1220,12 @@ export function startUkpServer(config: ServeConfig): StartedServe {
1010
1220
  || url.pathname === "/v1/nav"
1011
1221
  || url.pathname === "/v1/rg"
1012
1222
  ) {
1013
- const denied = bearerGate(request, config.tokens);
1014
- if (denied !== undefined) return denied;
1223
+ const gate = bearerGate(request, config);
1224
+ if ("response" in gate) return gate.response;
1225
+ if (gate.credential?.kind === "file"
1226
+ && !scopeAllows(gate.credential.entry.scopes, binding.name, "ro")) {
1227
+ return authFailureResponse();
1228
+ }
1015
1229
  if (url.pathname === "/v1/search") {
1016
1230
  return await handleSearch(request, config, binding.name, serviceFolder);
1017
1231
  }
@@ -1019,7 +1233,7 @@ export function startUkpServer(config: ServeConfig): StartedServe {
1019
1233
  return handleRead(url, request, config, binding.name);
1020
1234
  }
1021
1235
  if (url.pathname === "/v1/nav") {
1022
- return handleNav(url, request, config, binding.name);
1236
+ return await handleNav(url, request, config, binding.name);
1023
1237
  }
1024
1238
  return handleRg(url, request, config, binding.name);
1025
1239
  }
@@ -1041,6 +1255,7 @@ export function startUkpServer(config: ServeConfig): StartedServe {
1041
1255
  ...(config.systemdSocket === true ? { systemdSocket: true } : {}),
1042
1256
  });
1043
1257
  const { server } = listener;
1258
+ const tokenFile = tokenFileSnapshot(config);
1044
1259
 
1045
1260
  const info: ServeInfo = {
1046
1261
  mode: "endpoint",
@@ -1050,7 +1265,8 @@ export function startUkpServer(config: ServeConfig): StartedServe {
1050
1265
  url: listener.publicUrl,
1051
1266
  host,
1052
1267
  port: listener.port,
1053
- authRequired: (config.tokens?.length ?? 0) > 0,
1268
+ authRequired: gateArmed(config),
1269
+ ...(tokenFile !== undefined ? { tokenFile } : {}),
1054
1270
  rg: rgExecutableAvailable() ? "ok" : "missing",
1055
1271
  ...(tlsMaterial !== undefined
1056
1272
  ? { tls: { pin: tlsMaterial.pin, san: tlsMaterial.san, source: tlsMaterial.source } }
@@ -1149,7 +1365,7 @@ function handleRead(url: URL, request: Request, config: ServeConfig, endpointNam
1149
1365
  /** GET /v1/nav?path=&depth= → the nav envelope IS the response (W6): the
1150
1366
  * client renders it through the same renderNavHuman/renderNavJson path as a
1151
1367
  * local run. Failures use the transport error shape with nav's classes. */
1152
- function handleNav(url: URL, request: Request, config: ServeConfig, endpointName: string): Response {
1368
+ async function handleNav(url: URL, request: Request, config: ServeConfig, endpointName: string): Promise<Response> {
1153
1369
  if (request.method !== "GET") {
1154
1370
  return jsonResponse(errorBody("method-not-allowed", "/v1/nav is a GET route"), 405);
1155
1371
  }
@@ -1157,7 +1373,7 @@ function handleNav(url: URL, request: Request, config: ServeConfig, endpointName
1157
1373
  if ("error" in parsed) {
1158
1374
  return jsonResponse(errorBody("usage-error", parsed.error), 400);
1159
1375
  }
1160
- const outcome = runNav(parsed.request, {
1376
+ const outcome = await runNav(parsed.request, {
1161
1377
  currentDirectory: config.currentDirectory,
1162
1378
  registryPath: config.registryPath,
1163
1379
  });
@@ -36,12 +36,30 @@ function psSingleQuoted(arg: string): string {
36
36
 
37
37
  /** The relay script. `notFoundGuard` adds a Get-Command pre-check that exits
38
38
  * TOOL_NOT_FOUND_EXIT when argv[0] does not resolve — for callers that must
39
- * distinguish a missing tool from a tool's own failure exit. */
39
+ * distinguish a missing tool from a tool's own failure exit.
40
+ *
41
+ * The leading $ProgressPreference suppression is load-bearing for the stderr
42
+ * contract (2026-10-09 finding, TODO rg-clixml-pattern-failure-windows): the
43
+ * guard's Get-Command is the script's first cmdlet, which makes the engine
44
+ * emit its one-time "Preparing modules for first use" progress record, and
45
+ * with a redirected stderr powershell.exe serializes that record as a
46
+ * `#< CLIXML` stream ahead of the tool's own stderr — the consumer then
47
+ * surfaces `status: failed: #< CLIXML` instead of the real tool error.
48
+ * Suppressing progress keeps the channel tool-stderr-only; native stderr
49
+ * itself passes through byte-clean, exit code included (verified live:
50
+ * rg's regex parse error arrives readable; UTF-8 match text unaffected). */
40
51
  export function toolRelayScript(argv: readonly string[], options: { notFoundGuard?: boolean } = {}): string {
52
+ // The guard resolves via -Name, never -LiteralName: PS 5.1's Get-Command
53
+ // has no -LiteralName parameter, and an unknown parameter on it is not a
54
+ // binding error — the argument is silently swallowed and the call returns
55
+ // EVERY command, so a -LiteralName guard is vacuous (a missing tool then
56
+ // falls through to `&` and its CommandNotFoundException exits 0 with a
57
+ // CLIXML leak; live-verified 2026-10-09 review). -Name globs, but tool
58
+ // names ("rg", "ssh") contain no glob characters.
41
59
  const guard = options.notFoundGuard === true
42
- ? " if (-not (Get-Command -LiteralName $a[0] -ErrorAction SilentlyContinue)) { exit 127 };"
60
+ ? " if (-not (Get-Command -Name $a[0] -ErrorAction SilentlyContinue)) { exit 127 };"
43
61
  : "";
44
- return `$a = @(${argv.map(psSingleQuoted).join(",")});${guard} & $a[0] $a[1..($a.Count-1)]; exit $LASTEXITCODE`;
62
+ return `$ProgressPreference = 'SilentlyContinue'; $a = @(${argv.map(psSingleQuoted).join(",")});${guard} & $a[0] $a[1..($a.Count-1)]; exit $LASTEXITCODE`;
45
63
  }
46
64
 
47
65
  /** The powershell carrier argv for a tool invocation: spawn THIS instead of