@catheadowl/ukp 0.2.6 → 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,6 +42,14 @@ 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
@@ -57,7 +67,12 @@ import { KitUsageError } from "./commands/kit.ts";
57
67
  * modes, so client-side TOFU and name-assertion paths are shared. The
58
68
  * capability layer runs unchanged behind the routes (provider/transport
59
69
  * axes stay orthogonal): search via `runSearch` with a single-endpoint
60
- * scope, read via `runRead`, nav via `runNav`, rg via `runRg`. There is no
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
61
76
  * session, no streaming, and no server-side artifact: responses inline the
62
77
  * reference data (RQ-07) and generate a serve-scoped run id. */
63
78
 
@@ -96,9 +111,24 @@ export interface ServeConfig {
96
111
  * readable without it). In door mode the same gate covers all
97
112
  * `/e/<name>/v1/*` routes (door-level auth, O-2). */
98
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;
99
127
  /** TLS transport (W5' / D-079): `self-signed` generates and persists an
100
- * identity under the Service folder (`.ukp/tls/`) — in door mode under the
101
- * 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);
102
132
  * `certificates` serves operator-provided PEM files (Let's Encrypt IP
103
133
  * certs, mkcert, private CA). `sanEntries` (the `--tls-san` flag, already
104
134
  * normalized `IP:x`/`DNS:y`) merges extra coverage into the self-signed
@@ -168,6 +198,11 @@ export interface ServeInfo {
168
198
  host: string;
169
199
  port: number;
170
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 };
171
206
  /** ripgrep availability probed once at startup IN THE DOOR'S OWN PROCESS
172
207
  * (2026-09-20, liku feedback): /v1/rg serves every endpoint and a missing
173
208
  * binary degrades per-endpoint on the wire by design — but that truth is
@@ -299,11 +334,18 @@ export function buildDiscoveryDocument(
299
334
  * the door's width admits — every one of them, or the `select` whitelist's
300
335
  * members (ADR-REM-010) — read fresh by the caller per request (new
301
336
  * 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 }> {
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 }> {
304
345
  return readRegistry(registryPath)
305
346
  .filter((binding) => binding.kind !== "remote")
306
347
  .filter((binding) => select === undefined || select.includes(binding.name))
348
+ .filter((binding) => admit === undefined || admit(binding.name))
307
349
  .map((binding) => ({ name: binding.name, folder: binding.path! }));
308
350
  }
309
351
 
@@ -713,8 +755,10 @@ function startServeListener(
713
755
 
714
756
  /** TLS material resolution (W5' / D-079), shared by both serve modes: a
715
757
  * self-signed identity is generated/re-signed/persisted under `identityDir`
716
- * (the Service folder's `.ukp/tls/` in endpoint mode, the registry directory
717
- * 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
718
762
  * are read from the operator's paths. Bun.serve takes PEM contents
719
763
  * (path-string handling is platform-dependent). Every failure — unreadable
720
764
  * operator files, an unusable persisted identity, openssl unable to sign —
@@ -759,19 +803,95 @@ function resolveTlsMaterial(
759
803
  }
760
804
  }
761
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. */
762
860
  function bearerGate(
763
861
  request: Request,
764
- tokens: readonly string[] | undefined,
765
- ): Response | undefined {
766
- if ((tokens?.length ?? 0) === 0) return undefined;
767
- const authorization = request.headers.get("authorization");
768
- if (!tokens!.some((token) => authorization === `Bearer ${token}`)) {
769
- return jsonResponse(
770
- errorBody("auth-failure", "missing or invalid bearer token (Authorization: Bearer <token>)"),
771
- 401,
772
- );
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() };
773
869
  }
774
- 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);
775
895
  }
776
896
 
777
897
  /** Per-request endpoint resolution for door routing (O-2): the registry is
@@ -781,11 +901,15 @@ function bearerGate(
781
901
  * single-mode discovery fetch does. A subset door (ADR-REM-010) routes an
782
902
  * excluded name into the SAME roster-404 branch as an unknown one — the
783
903
  * response is byte-identical either way, so the roster is the only name
784
- * list any response ever carries. */
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. */
785
908
  function resolveDoorRoute(
786
909
  registryPath: string,
787
910
  name: string,
788
911
  select?: readonly string[],
912
+ admit?: (name: string) => boolean,
789
913
  ): { folder: string } | { response: Response } {
790
914
  let binding;
791
915
  try {
@@ -794,8 +918,13 @@ function resolveDoorRoute(
794
918
  const reason = error instanceof Error ? error.message : String(error);
795
919
  return { response: jsonResponse(errorBody("provider-unavailable", `Host Registry is not readable: ${reason}`), 503) };
796
920
  }
797
- if (binding === undefined || binding.kind === "remote" || (select !== undefined && !select.includes(name))) {
798
- const roster = listDoorEndpoints(registryPath, select).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(", ");
799
928
  return {
800
929
  response: jsonResponse(
801
930
  errorBody("not-found", `no such endpoint '${name}' on this host door (available: ${roster})`),
@@ -835,10 +964,16 @@ function resolveDoorRoute(
835
964
  * divide); every `/e/<name>/v1/*` route passes the door-level bearer gate.
836
965
  * The roster a 404 carries is the door's effective set — on a subset door
837
966
  * (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 {
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 {
840
975
  if (name !== undefined) {
841
- const roster = listDoorEndpoints(registryPath, select).map((endpoint) => endpoint.name).join(", ");
976
+ const roster = listDoorEndpoints(registryPath, select, admit).map((endpoint) => endpoint.name).join(", ");
842
977
  return jsonResponse(
843
978
  errorBody("not-found", `no such endpoint '${name}' on this host door (available: ${roster})`),
844
979
  404,
@@ -867,7 +1002,9 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
867
1002
  return jsonResponse(errorBody("method-not-allowed", `the discovery document is a GET resource`), 405);
868
1003
  }
869
1004
  try {
870
- return jsonResponse(buildDoorDocument(listDoorEndpoints(config.registryPath, select), (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)));
871
1008
  } catch (error) {
872
1009
  const reason = error instanceof Error ? error.message : String(error);
873
1010
  return jsonResponse(errorBody("provider-unavailable", `Host Registry is not readable: ${reason}`), 503);
@@ -892,7 +1029,7 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
892
1029
  try {
893
1030
  const loaded = loadManifest(resolved.folder);
894
1031
  return jsonResponse(
895
- buildDiscoveryDocument(loaded, readInstanceUid(resolved.folder), (config.tokens?.length ?? 0) > 0),
1032
+ buildDiscoveryDocument(loaded, readInstanceUid(resolved.folder), gateArmed(config)),
896
1033
  );
897
1034
  } catch (error) {
898
1035
  const reason = error instanceof Error ? error.message : String(error);
@@ -900,18 +1037,22 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
900
1037
  }
901
1038
  }
902
1039
  if (rest.startsWith("/v1/propose/")) {
903
- const denied = bearerGate(request, config.tokens);
904
- 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");
905
1045
  const id = rest.slice("/v1/propose/".length);
906
- if (id.includes("/")) return doorNotFound(undefined, config.registryPath, select);
907
- const resolved = resolveDoorRoute(config.registryPath, name, select);
1046
+ if (id.includes("/")) return doorNotFound(undefined, config.registryPath, select, admit);
1047
+ const resolved = resolveDoorRoute(config.registryPath, name, select, admit);
908
1048
  if ("response" in resolved) return resolved.response;
909
1049
  return await handlePropose(request, config, name, resolved.folder, id);
910
1050
  }
911
1051
  if (rest === "/v1/search" || rest === "/v1/read" || rest === "/v1/nav" || rest === "/v1/rg") {
912
- const denied = bearerGate(request, config.tokens);
913
- if (denied !== undefined) return denied;
914
- const resolved = resolveDoorRoute(config.registryPath, name, select);
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);
915
1056
  if ("response" in resolved) return resolved.response;
916
1057
  if (rest === "/v1/search") {
917
1058
  return await handleSearch(request, config, name, resolved.folder);
@@ -920,7 +1061,7 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
920
1061
  return handleRead(url, request, config, name);
921
1062
  }
922
1063
  if (rest === "/v1/nav") {
923
- return handleNav(url, request, config, name);
1064
+ return await handleNav(url, request, config, name);
924
1065
  }
925
1066
  return handleRg(url, request, config, name);
926
1067
  }
@@ -938,6 +1079,7 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
938
1079
  ...(config.systemdSocket === true ? { systemdSocket: true } : {}),
939
1080
  });
940
1081
  const { server } = listener;
1082
+ const tokenFile = tokenFileSnapshot(config);
941
1083
 
942
1084
  const info: ServeInfo = {
943
1085
  mode: "door",
@@ -949,7 +1091,8 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
949
1091
  url: listener.publicUrl,
950
1092
  host,
951
1093
  port: listener.port,
952
- authRequired: (config.tokens?.length ?? 0) > 0,
1094
+ authRequired: gateArmed(config),
1095
+ ...(tokenFile !== undefined ? { tokenFile } : {}),
953
1096
  rg: rgExecutableAvailable() ? "ok" : "missing",
954
1097
  ...(tlsMaterial !== undefined
955
1098
  ? { tls: { pin: tlsMaterial.pin, san: tlsMaterial.san, source: tlsMaterial.source } }
@@ -959,6 +1102,19 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
959
1102
  return { server, info, stopAll: listener.stopAll };
960
1103
  }
961
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
+
962
1118
  export function startUkpServer(config: ServeConfig): StartedServe {
963
1119
  const host = config.host ?? "127.0.0.1";
964
1120
 
@@ -988,7 +1144,21 @@ export function startUkpServer(config: ServeConfig): StartedServe {
988
1144
  );
989
1145
  }
990
1146
 
991
- 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);
992
1162
 
993
1163
  const handler = async (request: Request): Promise<Response> => {
994
1164
  const url = new URL(request.url);
@@ -1011,7 +1181,7 @@ export function startUkpServer(config: ServeConfig): StartedServe {
1011
1181
  );
1012
1182
  }
1013
1183
  return jsonResponse(
1014
- buildDiscoveryDocument(loaded, readInstanceUid(serviceFolder), (config.tokens?.length ?? 0) > 0),
1184
+ buildDiscoveryDocument(loaded, readInstanceUid(serviceFolder), gateArmed(config)),
1015
1185
  );
1016
1186
  } catch (error) {
1017
1187
  return jsonResponse(
@@ -1022,8 +1192,15 @@ export function startUkpServer(config: ServeConfig): StartedServe {
1022
1192
  }
1023
1193
 
1024
1194
  if (url.pathname.startsWith("/v1/propose/")) {
1025
- const denied = bearerGate(request, config.tokens);
1026
- 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
+ }
1027
1204
  const id = url.pathname.slice("/v1/propose/".length);
1028
1205
  if (id.includes("/")) {
1029
1206
  return jsonResponse(
@@ -1043,8 +1220,12 @@ export function startUkpServer(config: ServeConfig): StartedServe {
1043
1220
  || url.pathname === "/v1/nav"
1044
1221
  || url.pathname === "/v1/rg"
1045
1222
  ) {
1046
- const denied = bearerGate(request, config.tokens);
1047
- 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
+ }
1048
1229
  if (url.pathname === "/v1/search") {
1049
1230
  return await handleSearch(request, config, binding.name, serviceFolder);
1050
1231
  }
@@ -1052,7 +1233,7 @@ export function startUkpServer(config: ServeConfig): StartedServe {
1052
1233
  return handleRead(url, request, config, binding.name);
1053
1234
  }
1054
1235
  if (url.pathname === "/v1/nav") {
1055
- return handleNav(url, request, config, binding.name);
1236
+ return await handleNav(url, request, config, binding.name);
1056
1237
  }
1057
1238
  return handleRg(url, request, config, binding.name);
1058
1239
  }
@@ -1074,6 +1255,7 @@ export function startUkpServer(config: ServeConfig): StartedServe {
1074
1255
  ...(config.systemdSocket === true ? { systemdSocket: true } : {}),
1075
1256
  });
1076
1257
  const { server } = listener;
1258
+ const tokenFile = tokenFileSnapshot(config);
1077
1259
 
1078
1260
  const info: ServeInfo = {
1079
1261
  mode: "endpoint",
@@ -1083,7 +1265,8 @@ export function startUkpServer(config: ServeConfig): StartedServe {
1083
1265
  url: listener.publicUrl,
1084
1266
  host,
1085
1267
  port: listener.port,
1086
- authRequired: (config.tokens?.length ?? 0) > 0,
1268
+ authRequired: gateArmed(config),
1269
+ ...(tokenFile !== undefined ? { tokenFile } : {}),
1087
1270
  rg: rgExecutableAvailable() ? "ok" : "missing",
1088
1271
  ...(tlsMaterial !== undefined
1089
1272
  ? { tls: { pin: tlsMaterial.pin, san: tlsMaterial.san, source: tlsMaterial.source } }
@@ -1182,7 +1365,7 @@ function handleRead(url: URL, request: Request, config: ServeConfig, endpointNam
1182
1365
  /** GET /v1/nav?path=&depth= → the nav envelope IS the response (W6): the
1183
1366
  * client renders it through the same renderNavHuman/renderNavJson path as a
1184
1367
  * local run. Failures use the transport error shape with nav's classes. */
1185
- 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> {
1186
1369
  if (request.method !== "GET") {
1187
1370
  return jsonResponse(errorBody("method-not-allowed", "/v1/nav is a GET route"), 405);
1188
1371
  }
@@ -1190,7 +1373,7 @@ function handleNav(url: URL, request: Request, config: ServeConfig, endpointName
1190
1373
  if ("error" in parsed) {
1191
1374
  return jsonResponse(errorBody("usage-error", parsed.error), 400);
1192
1375
  }
1193
- const outcome = runNav(parsed.request, {
1376
+ const outcome = await runNav(parsed.request, {
1194
1377
  currentDirectory: config.currentDirectory,
1195
1378
  registryPath: config.registryPath,
1196
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