@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.
@@ -0,0 +1,368 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+
5
+ /** Labeled credential file for a self-hosted bearer gate (ukp_remote W13 /
6
+ * RQ-16c). This module is the first resident of the `src/plumbing/` subtree:
7
+ * a purity-tested, zero-UKP-import library (only `node:` builtins and
8
+ * subtree-relative imports are legal — `test/plumbing-purity.test.ts`
9
+ * enforces it mechanically). Every UKP semantic (where the file lives, when
10
+ * the gate consults it, how rosters filter) stays with the callers; what
11
+ * lives here is the credential-file mechanics the mainstream already settled:
12
+ *
13
+ * - Key shape `ukp_<label>_<id>_<secret>` (GitHub-PAT-style self-describing
14
+ * prefix; the id lets verification hash exactly one row).
15
+ * - Storage keeps a SHA-256 of the secret only, never the raw key
16
+ * (high-entropy secrets make a fast hash the right trade — no argon2).
17
+ * - One line per key; revocation is deleting (or commenting out) the line.
18
+ * - Scopes are required per line, Docker-registry-grammar
19
+ * `name:access,name:access` with `*` as the explicit full-power name.
20
+ *
21
+ * Evidence SSOT: the ukp_remote line's api-key-file-patterns research note. */
22
+
23
+ /** Column grammar: `<label> <id> sha256$<hex> <scopes>`, whitespace-run
24
+ * separated. `#`-prefixed lines and blanks are comments/spacers (revocation
25
+ * by commenting a line out stays a legal hand edit; the managed rewrites
26
+ * preserve them). The `sha256$` algorithm prefix follows the htpasswd/
27
+ * Authelia line-format lineage so a future algorithm is a new prefix, never
28
+ * an autodetect guess. */
29
+ export const TOKEN_HASH_ALGORITHM = "sha256";
30
+
31
+ /** Label and scope-name grammar — endpoint-name-shaped
32
+ * (`^(?=.{1,63}$)[a-z0-9]+(?:-[a-z0-9]+)*$`). Duplicated from the UKP
33
+ * config layer on purpose: this subtree may not import UKP code, and the
34
+ * key's own segments (label) must be `_`/`:`-free so the bearer string
35
+ * splits unambiguously. */
36
+ const NAME_GRAMMAR = /^(?=.{1,63}$)[a-z0-9]+(?:-[a-z0-9]+)*$/;
37
+
38
+ /** Scope wildcard: `*:rw` is the explicit full-power row (scope column is
39
+ * required — "no scopes listed" never means full power). */
40
+ export const SCOPE_WILDCARD = "*";
41
+
42
+ /** The bearer secret minted per key: 32 random bytes, base64url-unpadded
43
+ * (43 chars). High entropy is the premise of the fast-hash storage and of
44
+ * skipping online-bruteforce hardening. */
45
+ const SECRET_BYTES = 32;
46
+
47
+ /** Key id minted per key: 8 random bytes as lowercase hex (16 chars) —
48
+ * enough to make id collisions a non-event at self-hosted scale while the
49
+ * id stays the audit/display handle. */
50
+ const KEY_ID_BYTES = 8;
51
+
52
+ export type ScopeAccess = "ro" | "rw";
53
+
54
+ export interface TokenFileEntry {
55
+ label: string;
56
+ keyId: string;
57
+ /** Lowercase hex SHA-256 of the secret — the only secret-derived material
58
+ * the file ever holds. */
59
+ secretSha256: string;
60
+ /** Insertion-ordered scope map: endpoint name (or `*`) → access. */
61
+ scopes: Readonly<Record<string, ScopeAccess>>;
62
+ }
63
+
64
+ export interface MintedTokenKey {
65
+ /** `ukp_<label>_<id>_<secret>` — the raw credential, returned exactly
66
+ * once at mint time; the server stores only `line`'s hash column. */
67
+ token: string;
68
+ label: string;
69
+ keyId: string;
70
+ /** Ready-to-append file line for `storeTokenFileText`. */
71
+ line: string;
72
+ }
73
+
74
+ /** Usage-level failures of the pure helpers (bad label, bad scopes spec,
75
+ * bad file text). IO failures surface as thrown node errors from the file
76
+ * helpers below; classification stops at the grammar boundary. */
77
+ export class TokenFileUsageError extends Error {
78
+ constructor(message: string) {
79
+ super(message);
80
+ this.name = "TokenFileUsageError";
81
+ }
82
+ }
83
+
84
+ /** Parse a scopes column: comma-separated `<name>:(ro|rw)`, at least one
85
+ * entry, no duplicates (a repeated name is a declaration conflict, not a
86
+ * merge — the config-layer strictness stance). `*` composes with specific
87
+ * names; the specific name wins (`*:ro,cad:rw` = read-everything plus write
88
+ * on cad). */
89
+ export function parseTokenScopes(spec: string): Record<string, ScopeAccess> {
90
+ const scopes: Record<string, ScopeAccess> = {};
91
+ const trimmed = spec.trim();
92
+ if (trimmed === "") {
93
+ throw new TokenFileUsageError("scopes must not be empty (full access is the explicit '*:rw')");
94
+ }
95
+ for (const part of trimmed.split(",")) {
96
+ const match = /^(.+):(ro|rw)$/.exec(part.trim());
97
+ if (match === null) {
98
+ throw new TokenFileUsageError(
99
+ `invalid scope '${part.trim()}': expected <endpoint>:(ro|rw) entries, comma-separated (e.g. notes:ro,cad:rw; full access is '*:rw')`,
100
+ );
101
+ }
102
+ const name = match[1]!;
103
+ if (name !== SCOPE_WILDCARD && !NAME_GRAMMAR.test(name)) {
104
+ throw new TokenFileUsageError(
105
+ `invalid scope name '${name}': lowercase letters, digits, single hyphens, 1-63 chars (or '*')`,
106
+ );
107
+ }
108
+ if (Object.prototype.hasOwnProperty.call(scopes, name)) {
109
+ throw new TokenFileUsageError(`duplicate scope name '${name}' in '${spec}'`);
110
+ }
111
+ scopes[name] = match[2] as ScopeAccess;
112
+ }
113
+ return scopes;
114
+ }
115
+
116
+ /** Scope judgment: the specific row wins over `*`, and `rw` satisfies `ro`
117
+ * demands. Used per request by the gate's authorization step. */
118
+ export function scopeAllows(
119
+ scopes: Readonly<Record<string, ScopeAccess>>,
120
+ endpoint: string,
121
+ access: ScopeAccess,
122
+ ): boolean {
123
+ const specific = scopes[endpoint] ?? scopes[SCOPE_WILDCARD];
124
+ if (specific === undefined) return false;
125
+ return access === "ro" || specific === "rw";
126
+ }
127
+
128
+ /** Validate a label (client handle burned into the key string and the file
129
+ * line; revoke removes every line carrying it, so multiple active keys per
130
+ * label are the zero-downtime rotation shape). */
131
+ export function validateTokenLabel(label: string): void {
132
+ if (!NAME_GRAMMAR.test(label)) {
133
+ throw new TokenFileUsageError(
134
+ `invalid label '${label}': lowercase letters, digits, single hyphens, 1-63 chars`,
135
+ );
136
+ }
137
+ }
138
+
139
+ /** Mint a key: fresh id + secret, hash the secret, format the file line.
140
+ * Deterministic except through `randomBytes` (injected for tests). */
141
+ export function mintTokenFileKey(
142
+ label: string,
143
+ scopesSpec: string,
144
+ ): MintedTokenKey {
145
+ validateTokenLabel(label);
146
+ const scopes = parseTokenScopes(scopesSpec);
147
+ const keyId = randomBytes(KEY_ID_BYTES).toString("hex");
148
+ const secret = randomBytes(SECRET_BYTES).toString("base64url");
149
+ const token = `ukp_${label}_${keyId}_${secret}`;
150
+ return {
151
+ token,
152
+ label,
153
+ keyId,
154
+ line: formatTokenFileLine({ label, keyId, secretSha256: sha256Hex(secret), scopes }),
155
+ };
156
+ }
157
+
158
+ function sha256Hex(value: string): string {
159
+ return createHash(TOKEN_HASH_ALGORITHM).update(value, "utf8").digest("hex");
160
+ }
161
+
162
+ /** Serialize one entry as the canonical single-spaced line. */
163
+ export function formatTokenFileLine(entry: Omit<TokenFileEntry, "secretSha256"> & { secretSha256: string }): string {
164
+ const scopes = Object.entries(entry.scopes).map(([name, access]) => `${name}:${access}`).join(",");
165
+ return `${entry.label} ${entry.keyId} ${TOKEN_HASH_ALGORITHM}$${entry.secretSha256} ${scopes}`;
166
+ }
167
+
168
+ export interface TokenFileParseError {
169
+ /** 1-based line number in the file text. */
170
+ line: number;
171
+ message: string;
172
+ }
173
+
174
+ export type TokenFileParseResult =
175
+ | { entries: TokenFileEntry[] }
176
+ | { error: TokenFileParseError };
177
+
178
+ /** Parse the whole file text. Comment (`#`-prefixed) and blank lines are
179
+ * structurally legal and carry no entry; every other line must be a valid
180
+ * four-column row. Duplicate key ids are a format error (verification looks
181
+ * up by id — an ambiguous row must not half-authorize). Duplicate labels
182
+ * are legal (rotation keeps several active keys per client). */
183
+ export function parseTokenFileText(text: string): TokenFileParseResult {
184
+ const entries: TokenFileEntry[] = [];
185
+ const seenKeyIds = new Set<string>();
186
+ const lines = text.split("\n");
187
+ for (let index = 0; index < lines.length; index++) {
188
+ const raw = lines[index]!;
189
+ const line = raw.trim();
190
+ if (line === "" || line.startsWith("#")) continue;
191
+ const columns = line.split(/\s+/);
192
+ if (columns.length !== 4) {
193
+ return {
194
+ error: {
195
+ line: index + 1,
196
+ message: `expected 4 columns '<label> <id> ${TOKEN_HASH_ALGORITHM}$<hash> <scopes>', got ${columns.length}: '${line}'`,
197
+ },
198
+ };
199
+ }
200
+ const [label, keyId, hashColumn, scopesColumn] = columns as [string, string, string, string];
201
+ if (!NAME_GRAMMAR.test(label)) {
202
+ return { error: { line: index + 1, message: `invalid label '${label}'` } };
203
+ }
204
+ if (!/^[0-9a-f]{16}$/.test(keyId)) {
205
+ return { error: { line: index + 1, message: `invalid key id '${keyId}' (expected 16 lowercase hex chars)` } };
206
+ }
207
+ const hashMatch = new RegExp(`^${TOKEN_HASH_ALGORITHM}\\$([0-9a-f]{64})$`).exec(hashColumn);
208
+ if (hashMatch === null) {
209
+ return {
210
+ error: {
211
+ line: index + 1,
212
+ message: `invalid hash column '${hashColumn}' (expected ${TOKEN_HASH_ALGORITHM}$<64 lowercase hex chars>)`,
213
+ },
214
+ };
215
+ }
216
+ if (seenKeyIds.has(keyId)) {
217
+ return { error: { line: index + 1, message: `duplicate key id '${keyId}'` } };
218
+ }
219
+ let scopes: Record<string, ScopeAccess>;
220
+ try {
221
+ scopes = parseTokenScopes(scopesColumn);
222
+ } catch (error) {
223
+ if (error instanceof TokenFileUsageError) {
224
+ return { error: { line: index + 1, message: error.message } };
225
+ }
226
+ throw error;
227
+ }
228
+ seenKeyIds.add(keyId);
229
+ entries.push({ label, keyId, secretSha256: hashMatch[1]!, scopes });
230
+ }
231
+ return { entries };
232
+ }
233
+
234
+ /** Parse the bearer key shape `ukp_<label>_<id>_<secret>`: label and id are
235
+ * `_`-free by grammar, so the secret is everything after the third `_`
236
+ * (base64url may itself contain `_`). Returns undefined on any shape
237
+ * mismatch — authentication failures are data, not exceptions. */
238
+ export function parseTokenFileKey(token: string): { label: string; keyId: string; secret: string } | undefined {
239
+ const parts = token.split("_");
240
+ if (parts.length < 4 || parts[0] !== "ukp") return undefined;
241
+ const label = parts[1]!;
242
+ const keyId = parts[2]!;
243
+ const secret = parts.slice(3).join("_");
244
+ if (!NAME_GRAMMAR.test(label)) return undefined;
245
+ if (!/^[0-9a-f]{16}$/.test(keyId)) return undefined;
246
+ if (secret === "") return undefined;
247
+ return { label, keyId, secret };
248
+ }
249
+
250
+ /** Verify a presented bearer key against the parsed entries: look the row
251
+ * up by (label, keyId), then compare SHA-256 digests with
252
+ * `timingSafeEqual`. Digest lengths are fixed by the parse-time hash-column
253
+ * validation, so the comparison cannot throw on crafted input. Returns the
254
+ * matched entry (authorization data rides on it) or undefined. */
255
+ export function authenticateBearerKey(
256
+ presented: string,
257
+ entries: readonly TokenFileEntry[],
258
+ ): TokenFileEntry | undefined {
259
+ const parsed = parseTokenFileKey(presented);
260
+ if (parsed === undefined) return undefined;
261
+ const presentedDigest = createHash(TOKEN_HASH_ALGORITHM).update(parsed.secret, "utf8").digest();
262
+ for (const entry of entries) {
263
+ if (entry.keyId !== parsed.keyId || entry.label !== parsed.label) continue;
264
+ const storedDigest = Buffer.from(entry.secretSha256, "hex");
265
+ // Same-length by construction; timing-safe on the secret material only.
266
+ if (timingSafeEqual(storedDigest, presentedDigest)) return entry;
267
+ }
268
+ return undefined;
269
+ }
270
+
271
+ /** Permission enforcement for the credential file, OpenSSH-style: on POSIX,
272
+ * group/other read or write bits make the file unusable (machine refusal,
273
+ * not advice). Windows carries no group/other bits on `stat().mode` (the
274
+ * ACLs under %USERPROFILE% are the mechanism there), so the check is
275
+ * skipped rather than guaranteed-false. Returns the refusal message or
276
+ * undefined when the mode is acceptable. */
277
+ export function tokenFileModeProblem(mode: number, platform: string): string | undefined {
278
+ if (platform === "win32") return undefined;
279
+ const openBits = mode & 0o077;
280
+ if (openBits !== 0) {
281
+ return `permissions ${(mode & 0o777).toString(8).padStart(3, "0")} are too open: group/other must not read or write the token file (chmod 600)`;
282
+ }
283
+ return undefined;
284
+ }
285
+
286
+ // ---- file IO (node:fs is in-bounds for this subtree) ----------------------
287
+
288
+ export type TokenFileLoad =
289
+ | { text: string }
290
+ | { missing: true }
291
+ | { error: string };
292
+
293
+ /** Read the token file for verification or management. A missing file is
294
+ * the "no keys issued" state, not an error; a permission problem is the
295
+ * OpenSSH-shaped refusal; anything else is a classified read error. */
296
+ export function loadTokenFile(path: string): TokenFileLoad {
297
+ let text: string;
298
+ try {
299
+ text = readFileSync(path, "utf8");
300
+ } catch (error) {
301
+ const code = (error as NodeJS.ErrnoException).code;
302
+ if (code === "ENOENT") return { missing: true };
303
+ if (code === "EACCES" || code === "EPERM") {
304
+ return { error: `cannot read token file ${path}: permission denied` };
305
+ }
306
+ return { error: `cannot read token file ${path}: ${code ?? String(error)}` };
307
+ }
308
+ let mode: number;
309
+ try {
310
+ mode = statSync(path).mode;
311
+ } catch {
312
+ return { text };
313
+ }
314
+ const refusal = tokenFileModeProblem(mode, process.platform);
315
+ if (refusal !== undefined) return { error: `${path}: ${refusal}` };
316
+ return { text };
317
+ }
318
+
319
+ /** Persist the managed text: parent directories created on demand, written
320
+ * 0600 (best-effort on Windows), atomically replaced via temp+rename so a
321
+ * concurrently fresh-reading gate never observes a partial file. Comments
322
+ * and blank lines survive because callers pass the full managed text. */
323
+ export function storeTokenFile(path: string, text: string): void {
324
+ mkdirSync(dirname(path), { recursive: true });
325
+ const tempPath = `${path}.tmp.${process.pid}.${Date.now()}`;
326
+ writeFileSync(tempPath, text, { encoding: "utf8", mode: 0o600 });
327
+ try {
328
+ renameSync(tempPath, path);
329
+ } catch (error) {
330
+ if (existsSync(tempPath)) unlinkQuietly(tempPath);
331
+ throw error;
332
+ }
333
+ }
334
+
335
+ function unlinkQuietly(path: string): void {
336
+ try {
337
+ unlinkSync(path);
338
+ } catch {
339
+ // best-effort cleanup of our own temp file
340
+ }
341
+ }
342
+
343
+ /** Append one entry line to the managed text (newline discipline: text
344
+ * either is empty or already ends with exactly one newline). */
345
+ export function appendTokenLine(text: string, line: string): string {
346
+ const base = text === "" ? "" : (text.endsWith("\n") ? text : `${text}\n`);
347
+ return `${base}${line}\n`;
348
+ }
349
+
350
+ /** Remove every entry line whose label matches (revoke = the client, all
351
+ * its active keys). Comment/blank lines are preserved verbatim. Returns
352
+ * the rewritten text and how many lines were removed. */
353
+ export function removeTokenLines(text: string, label: string): { text: string; removed: number } {
354
+ let removed = 0;
355
+ const kept: string[] = [];
356
+ for (const raw of text.split("\n")) {
357
+ const trimmed = raw.trim();
358
+ if (trimmed !== "" && !trimmed.startsWith("#") && trimmed.split(/\s+/)[0] === label) {
359
+ removed++;
360
+ continue;
361
+ }
362
+ kept.push(raw);
363
+ }
364
+ // Drop one trailing separator run left by the removal so the file never
365
+ // accumulates blank tails across revoke cycles.
366
+ while (kept.length > 0 && kept[kept.length - 1] === "") kept.pop();
367
+ return { text: kept.length === 0 ? "" : `${kept.join("\n")}\n`, removed };
368
+ }
package/src/registry.ts CHANGED
@@ -45,8 +45,24 @@ const bindingSchema = z.object({
45
45
 
46
46
  const registrySchema = z.object({
47
47
  endpoints: z.array(bindingSchema),
48
+ /** Agent substrates for session hosting (ukp_session / ADR-SESSION-001
49
+ * spec §2): declaration-free v0 — entries are hand-edited; tier is
50
+ * sdk-only (app-server is a reserved future value and fails the schema). */
51
+ agents: z.array(z.object({
52
+ handle: z.string().min(1),
53
+ tier: z.literal("sdk"),
54
+ command: z.array(z.string().min(1)).min(1),
55
+ }).strict()).optional(),
48
56
  }).strict();
49
57
 
58
+ /** Host Registry agent entry (ukp_session): `command` is the substrate argv
59
+ * prefix — the session adapter appends its own mode arguments after it. */
60
+ export interface RegistryAgent {
61
+ handle: string;
62
+ tier: "sdk";
63
+ command: string[];
64
+ }
65
+
50
66
  /** Binding union over the `kind` axis (D-077). Local bindings keep the
51
67
  * historical `{name, path}` shape (no injected `kind` field), so parsed
52
68
  * locals stay byte-identical to the pre-remote era; remote bindings carry
@@ -315,6 +331,31 @@ export function readRegistry(registryPath: string): RegistryBinding[] {
315
331
  }
316
332
  }
317
333
 
334
+ /** Agent substrates declared in the Host Registry (ukp_session spec §2).
335
+ * Separate read so the bindings pipeline stays untouched; same strict
336
+ * schema, same classified error surface. Absent file or absent `agents`
337
+ * table both mean "no substrate on this host". */
338
+ export function readRegistryAgents(registryPath: string): RegistryAgent[] {
339
+ if (!existsSync(registryPath)) return [];
340
+ let raw: unknown;
341
+ try {
342
+ raw = parse(readFileSync(registryPath, "utf8"));
343
+ assertRestrictedToml(raw);
344
+ } catch (error) {
345
+ if (error instanceof ManifestError) throw new RegistryError(error.message, { cause: error });
346
+ throw new RegistryError(`Registry is not readable: ${registryPath}`, { cause: error });
347
+ }
348
+ const result = registrySchema.safeParse(raw);
349
+ if (!result.success) throw new RegistryError(`Registry schema is invalid: ${result.error.message}`);
350
+ const agents = result.data.agents ?? [];
351
+ const seen = new Set<string>();
352
+ for (const agent of agents) {
353
+ if (seen.has(agent.handle)) throw new RegistryError(`duplicate agent handle '${agent.handle}'`);
354
+ seen.add(agent.handle);
355
+ }
356
+ return [...agents].sort((left, right) => left.handle < right.handle ? -1 : left.handle > right.handle ? 1 : 0);
357
+ }
358
+
318
359
  /** Canonical serialization (configuration-contracts canonical form): local rows
319
360
  * `{name, path}`, remote rows `{name, kind, url, declared_name?, instance_uid?}`
320
361
  * (plus credentials/pins when present) — key order is insertion order, so
@@ -350,8 +391,13 @@ export function registerBinding(endpoints: readonly RegistryBinding[], binding:
350
391
  const sameName = endpoints.find((endpoint) => endpoint.name === canonical.name);
351
392
  if (sameName) {
352
393
  if (sameName.kind !== "remote" && sameName.path === canonical.path) return validateBindings(endpoints);
394
+ // Refusals carry their remedy (spec L140 contract): a local binding whose
395
+ // folder moved points at the one-step --rebind exit, everything else at
396
+ // the two-step unregister path (registry-commands rebind-one-step, 2026-10-24).
353
397
  throw new RegistryError(
354
- `endpoint name '${canonical.name}' is already bound to ${sameName.kind === "remote" ? sameName.url : sameName.path}`,
398
+ sameName.kind === "remote"
399
+ ? `endpoint name '${canonical.name}' is already bound to ${sameName.url}; unregister '${canonical.name}' first`
400
+ : `endpoint name '${canonical.name}' is already bound to ${sameName.path}; if the Service folder moved, re-run 'ukp register --rebind' from its new location, or unregister '${canonical.name}' first`,
355
401
  );
356
402
  }
357
403
  const samePath = endpoints.find((endpoint) => endpoint.kind !== "remote" && endpoint.path === canonical.path);
@@ -361,6 +407,31 @@ export function registerBinding(endpoints: readonly RegistryBinding[], binding:
361
407
  return validateBindings([...endpoints, canonical]);
362
408
  }
363
409
 
410
+ /** One-step same-name relocation (registry-commands domain, rebind-one-step
411
+ * ruling 2026-10-24, superseding the two-step remedy for this exit only):
412
+ * swap a LOCAL binding's location in one atomic state transition. Narrow by
413
+ * design — no rename, no alias, no remote handles (an unbound name, a
414
+ * remote-held name, or a location owned by another name all refuse with
415
+ * their own remedy); a same-path rebind is the idempotent no-op. */
416
+ export function rebindBinding(endpoints: readonly RegistryBinding[], binding: RegistryBinding): RegistryBinding[] {
417
+ const canonical: RegistryBinding = { name: binding.name, path: realpathSync(binding.path!) };
418
+ const sameName = endpoints.find((endpoint) => endpoint.name === canonical.name);
419
+ if (sameName === undefined) {
420
+ throw new RegistryError(`endpoint name '${canonical.name}' is not bound; nothing to rebind - run 'ukp register' to register it`);
421
+ }
422
+ if (sameName.kind === "remote") {
423
+ throw new RegistryError(
424
+ `endpoint name '${canonical.name}' is bound to the remote url ${sameName.url}; --rebind relocates local bindings - unregister '${canonical.name}' first`,
425
+ );
426
+ }
427
+ if (sameName.path === canonical.path) return validateBindings(endpoints);
428
+ const samePath = endpoints.find((endpoint) => endpoint.kind !== "remote" && endpoint.path === canonical.path);
429
+ if (samePath) {
430
+ throw new RegistryError(`Service location is already bound to '${samePath.name}'`);
431
+ }
432
+ return validateBindings([...endpoints.filter((endpoint) => endpoint.name !== canonical.name), canonical]);
433
+ }
434
+
364
435
  /** Remote registration (D-077; naming residence ADR-REM-007 / D-086): the
365
436
  * binding `name` is the consumer-chosen handle (defaults to the declared
366
437
  * name), `declared_name` is the registration-time provenance snapshot.
@@ -488,6 +559,12 @@ export function registerAt(registryPath: string, name: string, servicePath: stri
488
559
  return mutateRegistry(registryPath, (current) => registerBinding(current, { name, path: canonicalPath }));
489
560
  }
490
561
 
562
+ export function rebindAt(registryPath: string, name: string, servicePath: string): RegistryBinding[] {
563
+ const canonicalPath = realpathSync(servicePath);
564
+ if (!statSync(canonicalPath).isDirectory()) throw new RegistryError(`Service location is not a directory: ${servicePath}`);
565
+ return mutateRegistry(registryPath, (current) => rebindBinding(current, { name, path: canonicalPath }));
566
+ }
567
+
491
568
  export function registerRemoteAt(
492
569
  registryPath: string,
493
570
  binding: {