@catheadowl/ukp 0.1.0 → 0.2.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.
@@ -7,6 +7,7 @@ import {
7
7
  type KitParsed,
8
8
  type UkpCommandSpec,
9
9
  } from "./kit.ts";
10
+ import { normalizeTlsSanEntry } from "../capabilities/tls-identity.ts";
10
11
  import { DISCOVERY_PATH, startUkpServer, type ServeInfo } from "../server.ts";
11
12
 
12
13
  export interface ServeCommandContext {
@@ -36,15 +37,18 @@ export const SERVE_SPEC: UkpCommandSpec = {
36
37
  group: "operations",
37
38
  description:
38
39
  "Expose a registered endpoint over HTTP using the ukp-remote wire, or — without --endpoint — serve every local endpoint as one host door routed by name.",
39
- usage: "[--endpoint <name>] [--host <addr>] [--port <n>] [--tls | --tls-cert <pem> --tls-key <pem>]",
40
+ usage: "[--endpoint <name>] [--host <addr>] [--port <n>] [--tls [--tls-san <ip|dns>]… | --tls-cert <pem> --tls-key <pem>] [--max-idle <seconds>] [--systemd-socket]",
40
41
  options: [
41
42
  { flags: "--endpoint <name>", help: "the registered endpoint to expose; omit it to serve the whole registry as a host door (/e/<name>/ routing, all endpoints, one port)" },
42
43
  { flags: "--host <addr>", help: "listen address (default 127.0.0.1, loopback only without a token)" },
43
44
  { flags: "--port <n>", help: "listen port (default 8570)" },
44
45
  { flags: "--tls", help: "serve HTTPS with a self-signed identity (auto-generated under .ukp/tls/, SAN covers this host's addresses; clients pin it at registration)" },
46
+ { flags: "--tls-san <ip|dns>", help: "extra SAN entry for the --tls self-signed identity (repeatable): a public/NAT IP or hostname no NIC of this host carries; a persisted certificate missing an entry is re-signed over the same key (pin unchanged, pinned clients re-anchor)", multi: true },
45
47
  { flags: "--tls-cert <pem>", help: "TLS certificate (chain) PEM path — Let's Encrypt, mkcert, or a private CA; pair with --tls-key" },
46
48
  { flags: "--tls-key <pem>", help: "TLS private key PEM path; pair with --tls-cert" },
47
49
  { flags: "--allow-anonymous", help: "permit tokenless access on loopback (local testing, or an ssh-forwarded host door where SSH carries encryption and auth; reverse-proxy deployments still require UKP_SERVE_TOKEN)" },
50
+ { flags: "--max-idle <seconds>", help: "exit after <seconds> without requests (self-reap; the orphan backstop for on-demand-woken doors — fractional values accepted for tests)" },
51
+ { flags: "--systemd-socket", help: "serve on the systemd socket-activation listener (LISTEN_FDS fd 3) instead of binding a port — the port belongs to your .socket unit; Linux-only" },
48
52
  ],
49
53
  helpSuffix: [
50
54
  "",
@@ -56,14 +60,14 @@ export const SERVE_SPEC: UkpCommandSpec = {
56
60
  " GET /v1/nav?path=&depth= markdown route view (ukp.nav.v1)",
57
61
  " GET /v1/rg?query=… lexical search (ukp.rg.v1, ukp_uri handoff)",
58
62
  "",
59
- "Wire (ukp-remote v1), host door (no --endpoint; ADR-REM-004):",
63
+ "Wire (ukp-remote v1), host door (no --endpoint):",
60
64
  ` GET ${DISCOVERY_PATH} door document (scope:"host", endpoint roster)`,
61
65
  " GET /e/<name>/.well-known/ukp.json per-endpoint document (same shape",
62
66
  " as single-endpoint mode; TOFU pins anchor here)",
63
67
  " POST /e/<name>/v1/search capabilities routed by endpoint name",
64
68
  " GET /e/<name>/v1/read|nav|rg",
65
69
  "",
66
- "Auth (deny by default, RQ-18):",
70
+ "Auth (deny by default):",
67
71
  " Serving requires UKP_SERVE_TOKEN (comma-separate several: alice,bob;",
68
72
  " any listed token authorizes). Tokenless serving is an explicit opt-in:",
69
73
  " --allow-anonymous, loopback binds only. The discovery document stays",
@@ -85,6 +89,12 @@ export const SERVE_SPEC: UkpCommandSpec = {
85
89
  " 2026-01 —, mkcert, a private CA). Plain HTTP remains loopback-only by",
86
90
  " admission; public exposure needs TLS or the ssh:// transport.",
87
91
  "",
92
+ " A cloud NAT/EIP host's public IP is on no NIC (--tls alone cannot",
93
+ " cover it): add --tls-san <public-ip> next to --tls. Repeatable; DNS",
94
+ " names accepted. The persisted certificate re-signs over the same key",
95
+ " when a new entry appears — the pin is unchanged and pinned clients",
96
+ " re-anchor transparently; dropping entries never re-signs.",
97
+ "",
88
98
  ].join("\n"),
89
99
  };
90
100
 
@@ -93,10 +103,19 @@ export interface ParsedServe {
93
103
  endpoint?: string;
94
104
  host: string;
95
105
  port: number;
106
+ /** Present = the --max-idle self-reap timer is armed (W9). */
107
+ maxIdleSeconds?: number;
108
+ /** Present = the systemd-passed listener (W10, Linux-only). */
109
+ systemdSocket?: boolean;
110
+ /** `--tls-san` values, normalized to `IP:x`/`DNS:y` SAN form (repeatable);
111
+ * valid only next to `--tls` — the execute layer enforces the pairing.
112
+ * Absent when the flag is not passed (parse output stays byte-identical
113
+ * to the pre-flag shape). */
114
+ tlsSan?: string[];
96
115
  }
97
116
 
98
117
  function toParsedServe(parsed: KitParsed): ParsedServe {
99
- const options = parsed.options as { host?: string; port?: string; endpoint?: string };
118
+ const options = parsed.options as { host?: string; port?: string; endpoint?: string; maxIdle?: string; systemdSocket?: boolean; tlsSan?: string[] };
100
119
  const endpoint = options.endpoint;
101
120
  if (endpoint !== undefined && endpoint.length === 0) {
102
121
  throw new KitUsageError("--endpoint <name> must not be empty (omit it to serve a host door)");
@@ -106,7 +125,35 @@ function toParsedServe(parsed: KitParsed): ParsedServe {
106
125
  if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
107
126
  throw new KitUsageError("--port must be an integer between 1 and 65535");
108
127
  }
109
- return { ...(endpoint !== undefined ? { endpoint } : {}), host, port };
128
+ const systemdSocket = options.systemdSocket === true;
129
+ if (systemdSocket && options.port !== undefined) {
130
+ throw new KitUsageError("--systemd-socket and --port are mutually exclusive (the socket unit owns the port)");
131
+ }
132
+ let maxIdleSeconds: number | undefined;
133
+ if (options.maxIdle !== undefined) {
134
+ maxIdleSeconds = Number(options.maxIdle);
135
+ if (!Number.isFinite(maxIdleSeconds) || maxIdleSeconds <= 0 || maxIdleSeconds > 86400) {
136
+ throw new KitUsageError("--max-idle must be a positive number of seconds (at most 86400)");
137
+ }
138
+ }
139
+ let tlsSan: string[] = [];
140
+ if (options.tlsSan !== undefined) {
141
+ try {
142
+ // normalized then deduplicated — case variants of one entry collapse
143
+ // here, so ParsedServe carries the canonical set
144
+ tlsSan = [...new Set(options.tlsSan.map(normalizeTlsSanEntry))];
145
+ } catch (error) {
146
+ throw new KitUsageError(error instanceof Error ? error.message : String(error));
147
+ }
148
+ }
149
+ return {
150
+ ...(endpoint !== undefined ? { endpoint } : {}),
151
+ host,
152
+ port,
153
+ ...(maxIdleSeconds !== undefined ? { maxIdleSeconds } : {}),
154
+ ...(systemdSocket ? { systemdSocket } : {}),
155
+ ...(tlsSan.length > 0 ? { tlsSan } : {}),
156
+ };
110
157
  }
111
158
 
112
159
  export function parseServeArgs(args: readonly string[]): ParsedServe {
@@ -122,10 +169,13 @@ export function renderServeBanner(info: ServeInfo): string {
122
169
  return [
123
170
  `serving host door (ukp-remote v1)`,
124
171
  ` listening: ${info.url}`,
125
- ` discovery: ${info.url}${DISCOVERY_PATH} (host door)`,
172
+ ` discovery: ${info.url.startsWith("http") ? `${info.url}${DISCOVERY_PATH}` : info.url} (host door)`,
126
173
  ` endpoints: ${info.door!.endpoints.length > 0 ? info.door!.endpoints.join(", ") : "(none — register endpoints on this host)"}`,
127
174
  ` write: ${info.door!.write.length > 0 ? info.door!.write.join(", ") + " (propose via PUT /e/<name>/v1/propose/<id>)" : "(no endpoint declares propose)"}`,
128
175
  ` auth: ${info.authRequired ? "bearer token required" : "no token (loopback bind; ssh-forwarded clients authenticate by SSH key)"}`,
176
+ ...(info.maxIdleSeconds !== undefined
177
+ ? [` idle: exits after ${info.maxIdleSeconds}s without requests (--max-idle)`]
178
+ : []),
129
179
  ...(info.tls !== undefined
130
180
  ? [` tls: ${info.tls.source === "operator" ? "operator certificate" : `self-signed identity (${info.tls.source})`} ${info.tls.pin} (SAN: ${info.tls.san})`]
131
181
  : []),
@@ -135,8 +185,11 @@ export function renderServeBanner(info: ServeInfo): string {
135
185
  return [
136
186
  `serving endpoint '${info.endpoint}' (ukp-remote v1)`,
137
187
  ` listening: ${info.url}`,
138
- ` discovery: ${info.url}${DISCOVERY_PATH}`,
188
+ ` discovery: ${info.url.startsWith("http") ? `${info.url}${DISCOVERY_PATH}` : info.url}`,
139
189
  ` auth: ${info.authRequired ? "bearer token required" : "no token (loopback only)"}`,
190
+ ...(info.maxIdleSeconds !== undefined
191
+ ? [` idle: exits after ${info.maxIdleSeconds}s without requests (--max-idle)`]
192
+ : []),
140
193
  ...(info.tls !== undefined
141
194
  ? [` tls: ${info.tls.source === "operator" ? "operator certificate" : `self-signed identity (${info.tls.source})`} ${info.tls.pin} (SAN: ${info.tls.san})`]
142
195
  : []),
@@ -173,24 +226,37 @@ export function executeServeCommand(
173
226
  context: ServeCommandContext,
174
227
  ): KitCommandResult {
175
228
  return executeKitCommand(SERVE_SPEC, args, (parsed) => {
176
- const { endpoint, host, port } = toParsedServe(parsed);
229
+ const { endpoint, host, port, maxIdleSeconds, systemdSocket, tlsSan = [] } = toParsedServe(parsed);
177
230
  const allowAnonymous = (parsed.options as { allowAnonymous?: boolean }).allowAnonymous === true;
178
231
  const options = parsed.options as { tls?: boolean; tlsCert?: string; tlsKey?: string };
179
232
  // TLS flag family (W5'): --tls and --tls-cert/--tls-key are mutually
180
- // exclusive; the explicit pair must arrive complete.
233
+ // exclusive; the explicit pair must arrive complete. --tls-san only
234
+ // shapes the self-signed generation — an operator certificate carries
235
+ // its own SAN, so the flag next to one is a mistake, not a no-op.
181
236
  if (options.tls === true && (options.tlsCert !== undefined || options.tlsKey !== undefined)) {
182
237
  throw new KitUsageError("--tls and --tls-cert/--tls-key are mutually exclusive");
183
238
  }
184
239
  if (options.tls !== true && (options.tlsCert !== undefined) !== (options.tlsKey !== undefined)) {
185
240
  throw new KitUsageError("--tls-cert and --tls-key are used together");
186
241
  }
242
+ if (tlsSan.length > 0 && options.tls !== true) {
243
+ throw new KitUsageError("--tls-san is only used with --tls (an explicit --tls-cert certificate carries its own SAN)");
244
+ }
187
245
  const tokens = context.tokens ?? parseServeTokens(process.env.UKP_SERVE_TOKEN);
246
+ // W10: socket activation must not admit tokenless serving — the bind
247
+ // address belongs to the socket unit (possibly public), so the loopback
248
+ // premise of --allow-anonymous is unverifiable here (RQ-18 review P1).
249
+ if (systemdSocket && tokens.length === 0) {
250
+ throw new Error(
251
+ "refusing to serve on --systemd-socket without a token: the listener's bind address belongs to the socket unit and may be public — tokenless loopback serving cannot be verified here; set UKP_SERVE_TOKEN",
252
+ );
253
+ }
188
254
  const decision = serveAuthDecision(host, tokens, allowAnonymous);
189
255
  if (!decision.ok) {
190
256
  const target = endpoint === undefined ? "host door" : `'${endpoint}'`;
191
257
  throw new Error(`refusing to serve ${target}: ${decision.reason}`);
192
258
  }
193
- const { server, info } = startUkpServer({
259
+ const { server, info, stopAll } = startUkpServer({
194
260
  currentDirectory: context.currentDirectory,
195
261
  registryPath: context.registryPath,
196
262
  qmdCommand: context.qmdCommand,
@@ -198,18 +264,26 @@ export function executeServeCommand(
198
264
  port,
199
265
  ...(endpoint !== undefined ? { endpointName: endpoint } : {}),
200
266
  ...(tokens.length > 0 ? { tokens } : {}),
267
+ ...(maxIdleSeconds !== undefined ? { maxIdleSeconds } : {}),
268
+ ...(systemdSocket !== undefined && systemdSocket ? { systemdSocket } : {}),
201
269
  ...(options.tls === true
202
- ? { tls: { mode: "self-signed" as const } }
270
+ ? { tls: { mode: "self-signed" as const, ...(tlsSan.length > 0 ? { sanEntries: tlsSan } : {}) } }
203
271
  : options.tlsCert !== undefined && options.tlsKey !== undefined
204
272
  ? { tls: { mode: "certificates" as const, certPath: options.tlsCert, keyPath: options.tlsKey } }
205
273
  : {}),
206
274
  });
207
275
  const stop = () => {
208
276
  console.error(`ukp serve: stopped (${info.url})`);
209
- server.stop(true);
277
+ stopAll();
210
278
  };
211
279
  process.once("SIGINT", stop);
212
280
  process.once("SIGTERM", stop);
281
+ // SIGHUP = the session died (client killed the ssh that woke this door).
282
+ // Bun IGNORES SIGHUP by default (ali E2E: every woken door survived its
283
+ // session and idled to max-idle) — the explicit handler restores the
284
+ // session-bound lifecycle; --max-idle stays the backstop for abnormal
285
+ // disconnects where no signal ever arrives. Never fires on Windows.
286
+ process.once("SIGHUP", stop);
213
287
  // Exit code 0 flows out through runCli; the server listener keeps the
214
288
  // process alive until the signal handler stops it.
215
289
  return { exitCode: 0, stdout: renderServeBanner(info), stderr: "" };
@@ -147,6 +147,12 @@ export function loadManifest(serviceFolder: string): LoadedManifest {
147
147
  const normalized = normalizeFileNativeFlatKeys(withoutLegacyGet, (message) => {
148
148
  throw new ManifestError(message);
149
149
  });
150
+ if (isRecord(normalized) && !Object.hasOwn(normalized, "capabilities")) {
151
+ throw new ManifestError(
152
+ "Service Manifest schema is invalid: missing required [capabilities] table; "
153
+ + "use an empty [capabilities] table for the provider-free read/nav/rg baseline",
154
+ );
155
+ }
150
156
  const result = manifestSchema.safeParse(normalized);
151
157
  if (!result.success) {
152
158
  throw new ManifestError(`Service Manifest schema is invalid: ${result.error.message}`);
package/src/registry.ts CHANGED
@@ -29,6 +29,12 @@ const bindingSchema = z.object({
29
29
  /** Client credential stored in the binding (D-078: plaintext, 0600 file —
30
30
  * AWS credentials-file / netrc convention; env override wins at call time). */
31
31
  token: z.string().min(1).optional(),
32
+ /** Remote-declared endpoint name snapshotted at registration
33
+ * (ADR-REM-007 / D-086): provenance and expected-assertion target. The
34
+ * binding's `name` is the consumer-chosen handle (defaults to the declared
35
+ * name); service identity stays with instance_uid / TLS pins. The profile
36
+ * check lives in validateBindings, next to `name` itself. */
37
+ declared_name: z.string().min(1).optional(),
32
38
  /** TLS trust anchor pinned at registration (W5' / D-079, self-signed
33
39
  * servers only — public-CA chains are not pinned): `tls_cert` is the PEM
34
40
  * used as the fetch trust anchor, `tls_pin` is the RFC 7469 SPKI pin that
@@ -53,6 +59,8 @@ export interface RegistryBinding {
53
59
  kind?: "remote";
54
60
  url?: string;
55
61
  instance_uid?: string;
62
+ /** Remote-declared name snapshot (ADR-REM-007 / D-086); see bindingSchema. */
63
+ declared_name?: string;
56
64
  /** Remote-only stored credential (plaintext; env takes precedence). */
57
65
  token?: string;
58
66
  /** Remote-only TLS anchor (W5' / D-079); see bindingSchema. */
@@ -197,6 +205,37 @@ export class RegistryError extends Error {
197
205
  }
198
206
  }
199
207
 
208
+ /** Registration-only admission (D-089): since W9's on-demand wake an explicit
209
+ * port in an ssh:// url selects nothing — the woken door listens on a
210
+ * client-chosen loopback port — so intake rejects the dead grammar slot
211
+ * instead of documenting it (guide complexity attribution, D-087).
212
+ * Parse-time surfaces (parseRemoteUrl / assertRemoteUrlAllowed) stay
213
+ * permissive on purpose: bindings written by the resident-door era (W4–W8,
214
+ * e.g. `ssh://host:8571`) keep loading and calling; migrating one is an
215
+ * explicit unregister + register without the port (idempotency keys on the
216
+ * url, so an in-place refresh is not offered). */
217
+ export function assertRegistrableRemoteUrl(raw: string): void {
218
+ assertRemoteUrlAllowed(raw);
219
+ // assertRemoteUrlAllowed returning without throwing means parseRemoteUrl is
220
+ // defined for this url — and new URL(raw) cannot throw either. No defensive
221
+ // try/catch: a rejection helper fails closed by construction.
222
+ const parts = parseRemoteUrl(raw)!;
223
+ const parsed = new URL(raw);
224
+ if (parts.scheme === "ssh" && parsed.port !== "") {
225
+ // The remedy is deliberately built portless from parts — `origin` would
226
+ // carry the rejected port right back into the suggestion. URL.hostname
227
+ // drops IPv6 brackets, so they are re-added for that shape.
228
+ const host = parts.host.includes(":") ? `[${parts.host}]` : parts.host;
229
+ const portless = `ssh://${parts.user !== undefined ? `${parts.user}@` : ""}${host}`
230
+ + `${parts.endpointName !== undefined ? `/${parts.endpointName}` : ""}`;
231
+ throw new RegistryError(
232
+ `ssh:// urls take no port: the woken door listens on a client-chosen loopback port and the url port selects nothing`
233
+ + ` — register '${portless}' instead`
234
+ + ` (for a non-standard sshd port, use an ssh config Host alias)`,
235
+ );
236
+ }
237
+ }
238
+
200
239
  export class RegistryBusyError extends RegistryError {
201
240
  constructor(path: string) {
202
241
  super(
@@ -221,6 +260,9 @@ function validateBindings(endpoints: readonly RegistryBinding[]): RegistryBindin
221
260
  if (endpoint.url === undefined) {
222
261
  throw new RegistryError(`remote binding '${endpoint.name}' requires a url`);
223
262
  }
263
+ if (endpoint.declared_name !== undefined && !ENDPOINT_NAME.test(endpoint.declared_name)) {
264
+ throw new RegistryError(`remote binding '${endpoint.name}' carries an invalid declared name '${endpoint.declared_name}'`);
265
+ }
224
266
  if ((endpoint.tls_cert !== undefined) !== (endpoint.tls_pin !== undefined)) {
225
267
  throw new RegistryError(`remote binding '${endpoint.name}' must carry tls_cert and tls_pin together`);
226
268
  }
@@ -230,9 +272,10 @@ function validateBindings(endpoints: readonly RegistryBinding[]): RegistryBindin
230
272
  } else {
231
273
  if (
232
274
  endpoint.url !== undefined || endpoint.instance_uid !== undefined || endpoint.token !== undefined
275
+ || endpoint.declared_name !== undefined
233
276
  || endpoint.tls_cert !== undefined || endpoint.tls_pin !== undefined
234
277
  ) {
235
- throw new RegistryError(`local binding '${endpoint.name}' must not carry remote fields (url/instance_uid/token/tls)`);
278
+ throw new RegistryError(`local binding '${endpoint.name}' must not carry remote fields (url/instance_uid/token/declared_name/tls)`);
236
279
  }
237
280
  if (endpoint.path === undefined || !isAbsolute(endpoint.path)) {
238
281
  throw new RegistryError(`registry path must be absolute: ${endpoint.path ?? "(missing)"}`);
@@ -273,14 +316,16 @@ export function readRegistry(registryPath: string): RegistryBinding[] {
273
316
  }
274
317
 
275
318
  /** Canonical serialization (configuration-contracts canonical form): local rows
276
- * `{name, path}`, remote rows `{name, kind, url, instance_uid?}` — key order
277
- * is insertion order, so build plain ordered objects before stringify. */
319
+ * `{name, path}`, remote rows `{name, kind, url, declared_name?, instance_uid?}`
320
+ * (plus credentials/pins when present) — key order is insertion order, so
321
+ * build plain ordered objects before stringify. */
278
322
  function toSerializableBinding(binding: RegistryBinding): Record<string, string> {
279
323
  if (binding.kind === "remote") {
280
324
  return {
281
325
  name: binding.name,
282
326
  kind: "remote",
283
327
  url: binding.url!,
328
+ ...(binding.declared_name !== undefined ? { declared_name: binding.declared_name } : {}),
284
329
  ...(binding.instance_uid !== undefined ? { instance_uid: binding.instance_uid } : {}),
285
330
  ...(binding.token !== undefined ? { token: binding.token } : {}),
286
331
  ...(binding.tls_cert !== undefined ? { tls_cert: binding.tls_cert } : {}),
@@ -316,8 +361,12 @@ export function registerBinding(endpoints: readonly RegistryBinding[], binding:
316
361
  return validateBindings([...endpoints, canonical]);
317
362
  }
318
363
 
319
- /** Remote registration (D-077): name comes from the discovery document
320
- * (RQ-14), instance_uid is the TOFU pin. Idempotent on same name+url. */
364
+ /** Remote registration (D-077; naming residence ADR-REM-007 / D-086): the
365
+ * binding `name` is the consumer-chosen handle (defaults to the declared
366
+ * name), `declared_name` is the registration-time provenance snapshot.
367
+ * Idempotency keys on url+declared name → refresh under the EXISTING handle
368
+ * (N-2): one url, one handle; a second handle for the same instance goes
369
+ * through a different url and warns at list time, never here. */
321
370
  export function registerRemoteBinding(
322
371
  endpoints: readonly RegistryBinding[],
323
372
  binding: RegistryBinding,
@@ -326,26 +375,57 @@ export function registerRemoteBinding(
326
375
  name: binding.name,
327
376
  kind: "remote",
328
377
  url: binding.url!,
378
+ ...(binding.declared_name !== undefined ? { declared_name: binding.declared_name } : {}),
329
379
  ...(binding.instance_uid !== undefined ? { instance_uid: binding.instance_uid } : {}),
330
380
  ...(binding.token !== undefined ? { token: binding.token } : {}),
331
381
  ...(binding.tls_cert !== undefined ? { tls_cert: binding.tls_cert } : {}),
332
382
  ...(binding.tls_pin !== undefined ? { tls_pin: binding.tls_pin } : {}),
333
383
  };
384
+ // API-seam enforcement of the registration-only ssh port rule (D-089): the
385
+ // registry never gains a NEW ported ssh binding, while legacy ones keep
386
+ // loading (validateBindings uses the permissive parse-time check).
387
+ assertRegistrableRemoteUrl(canonical.url!);
388
+ const sameUrl = endpoints.find((endpoint) => endpoint.kind === "remote" && endpoint.url === canonical.url);
389
+ if (sameUrl) {
390
+ // Legacy bindings (pre-ADR-REM-007) carry no declared_name; their handle
391
+ // WAS the declared name under RQ-14, so the name stands in for it.
392
+ const existingDeclared = sameUrl.declared_name ?? sameUrl.name;
393
+ const incomingDeclared = canonical.declared_name ?? canonical.name;
394
+ if (existingDeclared !== incomingDeclared) {
395
+ throw new RegistryError(
396
+ `remote endpoint at ${canonical.url} now declares '${incomingDeclared}', but binding '${sameUrl.name}' was registered for '${existingDeclared}'; unregister '${sameUrl.name}' and re-register`,
397
+ );
398
+ }
399
+ if (canonical.name !== sameUrl.name && canonical.name !== incomingDeclared) {
400
+ // An explicitly different handle for an instance already tracked under
401
+ // this url: one url, one handle (rename = unregister + register).
402
+ throw new RegistryError(
403
+ `remote endpoint ${canonical.url} is already registered as '${sameUrl.name}'; unregister it first to change the handle`,
404
+ );
405
+ }
406
+ // Default gesture (handle = declared name) or matching handle: the
407
+ // idempotent refresh of the TOFU pin and credentials lands under the
408
+ // EXISTING handle (N-2 — service replacement stays an explicit
409
+ // unregister + re-register, ADR-REM-003). Direct-API callers that omit
410
+ // declared_name must not erase the stored snapshot (the CLI always
411
+ // passes it; the skip/refusal wording that mirrors this branch lives in
412
+ // importDoorEndpoints).
413
+ const refreshed: RegistryBinding = {
414
+ ...canonical,
415
+ ...(canonical.name !== sameUrl.name ? { name: sameUrl.name } : {}),
416
+ ...(canonical.declared_name === undefined && sameUrl.declared_name !== undefined
417
+ ? { declared_name: sameUrl.declared_name }
418
+ : {}),
419
+ };
420
+ return validateBindings([...endpoints.filter((e) => e.name !== sameUrl.name), refreshed]);
421
+ }
334
422
  const sameName = endpoints.find((endpoint) => endpoint.name === canonical.name);
335
423
  if (sameName) {
336
- if (sameName.kind === "remote" && sameName.url === canonical.url) {
337
- // Same name+url re-registration refreshes the TOFU pin (service
338
- // replacement is an explicit re-register, ADR-REM-003).
339
- return validateBindings([...endpoints.filter((e) => e.name !== canonical.name), canonical]);
340
- }
341
424
  throw new RegistryError(
342
- `endpoint name '${canonical.name}' is already bound to ${sameName.kind === "remote" ? sameName.url : sameName.path}`,
425
+ `endpoint name '${canonical.name}' is already bound to ${sameName.kind === "remote" ? sameName.url : sameName.path}; `
426
+ + `register this endpoint under another handle with --name <handle>`,
343
427
  );
344
428
  }
345
- const sameUrl = endpoints.find((endpoint) => endpoint.kind === "remote" && endpoint.url === canonical.url);
346
- if (sameUrl) {
347
- throw new RegistryError(`remote endpoint url is already bound to '${sameUrl.name}'`);
348
- }
349
429
  return validateBindings([...endpoints, canonical]);
350
430
  }
351
431
 
@@ -410,7 +490,15 @@ export function registerAt(registryPath: string, name: string, servicePath: stri
410
490
 
411
491
  export function registerRemoteAt(
412
492
  registryPath: string,
413
- binding: { name: string; url: string; instance_uid?: string; token?: string; tls_cert?: string; tls_pin?: string },
493
+ binding: {
494
+ name: string;
495
+ url: string;
496
+ declared_name?: string;
497
+ instance_uid?: string;
498
+ token?: string;
499
+ tls_cert?: string;
500
+ tls_pin?: string;
501
+ },
414
502
  ): RegistryBinding[] {
415
503
  return mutateRegistry(registryPath, (current) => registerRemoteBinding(current, binding));
416
504
  }