@cortexkit/subc-client 0.19.0 → 0.21.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/client.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  type Frame,
27
27
  } from "./envelope.js";
28
28
  import { launchNonceOrUndefined } from "./launch-nonce.js";
29
+ import type { Principal } from "./provider.js";
29
30
  import {
30
31
  belongsToConnection,
31
32
  createRouteHandle,
@@ -118,7 +119,19 @@ export interface ConsumerIdentity {
118
119
  launch_nonce: string;
119
120
  }
120
121
 
122
+ /** A scope selector; scopeEpoch is required even for the scope's owner. */
123
+ export interface RouteScope {
124
+ /** Owner principal, passed verbatim. The daemon decides which principal kinds may own scopes. */
125
+ owner: Principal;
126
+ /** Opaque, non-empty ref within the owner. */
127
+ ref: string;
128
+ /** Safe non-negative integer, including zero; serialized as scope_epoch. */
129
+ scopeEpoch: number;
130
+ }
131
+
121
132
  export interface RouteOpenOptions {
133
+ /** Open under this scope. The caller must be its owner or a listed carrier; use a daemon advertising scopes/v1. Serialized as { owner, ref, scope_epoch }. */
134
+ scope?: RouteScope;
122
135
  /** Optional override for the consumer identity; by default the SUBC_MODULE_ID environment variable and the process's launch nonce (see `launchNonce`) are used when both are non-empty; a refused launch-nonce descriptor counts as no nonce. Set null to send route.open without consumer_identity. */
123
136
  consumerIdentity?: ConsumerIdentity | null;
124
137
  /**
@@ -202,6 +215,8 @@ export interface RequestOptions {
202
215
  }
203
216
 
204
217
  export interface ManagedCallOptions extends RequestOptions {
218
+ /** Scope for the cached route and its reconnect reopens. The caller must be the scope's owner or a listed carrier; use a daemon advertising scopes/v1. Each owner/ref/epoch has a separate cache entry. */
219
+ scope?: RouteScope;
205
220
  /** Overrides the per-client identity used for route.open before this call. */
206
221
  identity?: BindIdentity;
207
222
  /** Defaults to management_surface, matching the store/host management APIs. */
@@ -236,6 +251,8 @@ export interface CloseRouteOptions {
236
251
  }
237
252
 
238
253
  export interface ManagedCloseRouteOptions extends CloseRouteOptions {
254
+ /** Scope selector used by the cached managed route; omitted selects only an unscoped route. */
255
+ scope?: RouteScope;
239
256
  /** Consumer identity used by the cached managed route. */
240
257
  consumerIdentity?: ConsumerIdentity | null;
241
258
  /** The registry whose derived capability set identifies the cached route. */
@@ -297,6 +314,12 @@ export class SubcCallError extends Error {
297
314
  get detail(): unknown {
298
315
  return this.cause instanceof SubcError ? this.cause.detail : undefined;
299
316
  }
317
+
318
+ /** Why the route ended. A reason never makes an in-flight call safe to resend. */
319
+ get closeReason(): RouteEndReason | undefined {
320
+ return this.cause instanceof SubcError || this.cause instanceof SubcCallError
321
+ ? this.cause.closeReason : undefined;
322
+ }
300
323
  }
301
324
 
302
325
  /**
@@ -324,6 +347,7 @@ export class SubcError extends Error {
324
347
  * those reasons at the transport boundary.
325
348
  */
326
349
  readonly detail?: unknown,
350
+ readonly closeReason?: RouteEndReason,
327
351
  ) {
328
352
  super(message);
329
353
  }
@@ -404,15 +428,11 @@ export interface ConnectOptions {
404
428
  */
405
429
  timeoutArbitrationGraceMs?: number;
406
430
  /**
407
- * Observer for daemon-originated channel-0 control pushes (`route.closing`,
408
- * `route.closed`, and any op added later). Purely advisory: the daemon's
409
- * load-bearing route-death signal remains the GOODBYE frame, and nothing in
410
- * the client's own route lifecycle consumes these. The contract is the wire
411
- * contract verbatim -- unrecognized ops still arrive here (callers ignore
412
- * what they don't know, per the MUST-ignore clause), a push that fails to
413
- * parse as JSON is dropped without surfacing, and an observer throw is
414
- * swallowed like every other caller callback so it cannot fail the read loop
415
- * or unrelated requests.
431
+ * Observer for parsed daemon control pushes, including unrecognized ops which
432
+ * callers should ignore. Malformed JSON is dropped and observer exceptions are
433
+ * swallowed so they cannot fail unrelated requests. The client records route
434
+ * reasons before notifying the observer, but closes routes only on the separate
435
+ * GOODBYE frame, never on a control push.
416
436
  */
417
437
  onControlPush?: (push: ControlPush) => void;
418
438
  }
@@ -431,11 +451,18 @@ export type KnownRouteCloseReason =
431
451
  | "restart"
432
452
  | "disable"
433
453
  | "crash"
434
- | "capability_denied";
454
+ | "capability_denied"
455
+ | "scope_ended"
456
+ | "scope_carrier_removed"
457
+ | "scope_delegation_changed"
458
+ | "scope_parent_ended";
435
459
 
436
460
  /** A route-close reason decoded from a daemon control push. */
437
461
  export type RouteCloseReason = KnownRouteCloseReason | "unknown";
438
462
 
463
+ /** Why a route ended. Even a planned closure can leave a call's outcome unknown. */
464
+ export type RouteEndReason = RouteCloseReason | "closed_by_caller" | "connection_lost";
465
+
439
466
  /** Whether the close reason alone permits reopening a route automatically. */
440
467
  export type RouteCloseDisposition = "may_reopen" | "must_not_reopen";
441
468
 
@@ -452,6 +479,10 @@ export function parseRouteCloseReason(reason: unknown): RouteCloseReason {
452
479
  case "disable":
453
480
  case "crash":
454
481
  case "capability_denied":
482
+ case "scope_ended":
483
+ case "scope_carrier_removed":
484
+ case "scope_delegation_changed":
485
+ case "scope_parent_ended":
455
486
  return reason;
456
487
  default:
457
488
  return "unknown";
@@ -467,6 +498,10 @@ export function classifyRouteCloseReason(reason: unknown): RouteCloseDisposition
467
498
  case "disable":
468
499
  case "crash":
469
500
  case "capability_denied":
501
+ case "scope_ended":
502
+ case "scope_carrier_removed":
503
+ case "scope_delegation_changed":
504
+ case "scope_parent_ended":
470
505
  case "unknown":
471
506
  return "must_not_reopen";
472
507
  }
@@ -579,6 +614,9 @@ interface CachedRoute {
579
614
  moduleId: string;
580
615
  target: Extract<RouteTarget, { kind: ManagedRouteKind }>;
581
616
  identity: BindIdentity;
617
+ scope?: RouteScope;
618
+ /** A terminal daemon close must survive connection replacement, not become a reopen. */
619
+ endReason?: RouteCloseReason;
582
620
  consumerIdentity?: ConsumerIdentity;
583
621
  reverseRequests: ReverseRequestRegistry;
584
622
  handle: RouteHandle | null;
@@ -600,6 +638,9 @@ export class SubcClient {
600
638
  private readonly lateResponses = new Map<string, (frame: Frame) => void>();
601
639
  private readonly routes = new Map<string, CachedRoute>();
602
640
  private readonly liveRoutes = new Map<number, RouteHandle>();
641
+ private readonly routeModules = new Map<number, string>();
642
+ private readonly routeEndReasons = new Map<number, { reason: RouteCloseReason; final: boolean }>();
643
+ private readonly legacyChannelReasons = new Map<number, RouteCloseReason>();
603
644
  private connectionToken = newConnectionToken();
604
645
  private ingressEpochDropCount = 0;
605
646
  private closedErr: Error | null = null;
@@ -687,12 +728,14 @@ export class SubcClient {
687
728
  * Open a route and return its connection-bound immutable handle. At most
688
729
  * MAX_ROUTE_OPENS_IN_FLIGHT opens are outstanding at once; further calls wait
689
730
  * their turn.
731
+ * A scoped open requires the caller to be the scope's owner or a listed carrier.
690
732
  */
691
733
  async routeOpen(target: RouteTarget, identity: BindIdentity, opts: RouteOpenOptions = {}): Promise<RouteHandle> {
734
+ const scope = validatedRouteScope(opts.scope);
692
735
  // Without a timeout the gate always grants a slot eventually.
693
736
  const release = (await this.routeOpenGate.acquire())!;
694
737
  try {
695
- return await this.sendRouteOpen(target, identity, opts);
738
+ return await this.sendRouteOpen(target, identity, { ...opts, scope });
696
739
  } finally {
697
740
  release();
698
741
  }
@@ -704,6 +747,7 @@ export class SubcClient {
704
747
  identity: BindIdentity,
705
748
  opts: RouteOpenOptions = {},
706
749
  ): Promise<RouteHandle> {
750
+ const scope = validatedRouteScope(opts.scope);
707
751
  const consumerIdentity = routeOpenConsumerIdentity(opts);
708
752
  const reverseRequests = opts.reverseRequests ?? new ReverseRequestRegistry();
709
753
  reverseRequests.seal();
@@ -714,6 +758,7 @@ export class SubcClient {
714
758
  identity,
715
759
  ...(consumerIdentity ? { consumer_identity: consumerIdentity } : {}),
716
760
  ...(consumerCapabilities.length > 0 ? { consumer_capabilities: consumerCapabilities } : {}),
761
+ ...(scope ? { scope: { owner: scope.owner, ref: scope.ref, scope_epoch: scope.scopeEpoch } } : {}),
717
762
  });
718
763
 
719
764
  let installed: RouteHandle | null = null;
@@ -724,6 +769,7 @@ export class SubcClient {
724
769
  throw new SubcError(`route.open returned no route handle: ${JSON.stringify(parsed)}`);
725
770
  }
726
771
  installed = this.installRoute(parsed.route_channel, parsed.route_epoch, reverseRequests);
772
+ this.routeModules.set(parsed.route_channel, target.module_id);
727
773
  return true;
728
774
  };
729
775
  const closeLateRoute = (frame: Frame): void => {
@@ -732,7 +778,7 @@ export class SubcClient {
732
778
  const parsed = this.parseJson(frame) as { route_channel?: number; route_epoch?: number };
733
779
  if (typeof parsed.route_channel !== "number" || typeof parsed.route_epoch !== "number") return;
734
780
  const lateHandle = this.installRoute(parsed.route_channel, parsed.route_epoch);
735
- this.failHandle(lateHandle, new SubcError("late route.open was closed", "route_closed"));
781
+ this.failHandle(lateHandle, new SubcError("late route.open was closed", "route_closed", undefined, "closed_by_caller"));
736
782
  this.liveRoutes.delete(lateHandle.channel);
737
783
  this.sendRouteGoodbye(lateHandle, true);
738
784
  } catch {
@@ -770,6 +816,8 @@ export class SubcClient {
770
816
  /**
771
817
  * Managed route + request convenience. Opens and caches a route for the module,
772
818
  * reconnecting and re-opening cached routes after connection drops.
819
+ * Scoped routes are isolated by owner/ref/epoch and require owner or carrier authority.
820
+ * Terminal scope close reasons prevent automatic reopens under the same selector.
773
821
  */
774
822
  async call<Response = unknown>(
775
823
  moduleId: string,
@@ -1018,7 +1066,7 @@ export class SubcClient {
1018
1066
  }
1019
1067
  }
1020
1068
  if (opts.drain) await this.drainUnaryOnHandle(handle);
1021
- this.failHandle(handle, new SubcError("route closed by closeRoute", "route_closed"));
1069
+ this.failHandle(handle, new SubcError("route closed by closeRoute", "route_closed", undefined, "closed_by_caller"));
1022
1070
  if (this.liveRoutes.get(handle.channel) === handle) this.liveRoutes.delete(handle.channel);
1023
1071
  this.sendRouteGoodbye(handle);
1024
1072
  }
@@ -1034,6 +1082,7 @@ export class SubcClient {
1034
1082
  identity,
1035
1083
  routeOpenConsumerIdentity(opts),
1036
1084
  (opts.reverseRequests ?? new ReverseRequestRegistry()).capabilities(),
1085
+ validatedRouteScope(opts.scope),
1037
1086
  );
1038
1087
  const cached = this.routes.get(key);
1039
1088
  if (!cached) return;
@@ -1161,11 +1210,13 @@ export class SubcClient {
1161
1210
  };
1162
1211
  pending.timer = setTimeout(() => this.arbitrateTimeout(key, pending, channel, corr, ms), ms);
1163
1212
  this.pending.set(key, pending);
1164
- if (signal) this.attachCancelSignal(signal, key, handle, corr, priority);
1165
1213
  writeBorrowed(this.sock, encodeFrame(frame), Date.now() + ms).catch((error) => {
1166
1214
  const current = this.pending.get(key);
1167
1215
  if (current) this.rejectPending(key, current, error instanceof Error ? error : new SubcError(String(error)));
1168
1216
  });
1217
+ // Queue REQUEST first: an already-aborted signal emits CANCEL immediately,
1218
+ // and the daemon can only cancel a correlation it has already received.
1219
+ if (signal) this.attachCancelSignal(signal, key, handle, corr, priority);
1169
1220
  });
1170
1221
  }
1171
1222
 
@@ -1292,17 +1343,19 @@ export class SubcClient {
1292
1343
  };
1293
1344
  pending.timer = setTimeout(() => this.arbitrateTimeout(key, pending, handle.channel, corr, ms), ms);
1294
1345
  this.pending.set(key, pending);
1295
- if (signal) this.attachCancelSignal(signal, key, handle, corr, priority);
1296
1346
  const write = writeTrackedBorrowed(this.sock, encodeFrame(frame), Date.now() + ms);
1297
1347
  handedToSocket = write.queued;
1298
1348
  write.completed.catch((error) => {
1299
1349
  const current = this.pending.get(key);
1300
1350
  if (current) this.rejectPending(key, current, error instanceof Error ? error : new SubcError(String(error)));
1301
1351
  });
1352
+ // Preserve REQUEST/CANCEL order even when the signal was already aborted.
1353
+ if (signal && write.queued) this.attachCancelSignal(signal, key, handle, corr, priority);
1302
1354
  });
1303
1355
  }
1304
1356
 
1305
1357
  private async cachedRouteHandle(moduleId: string, opts: ManagedCallOptions): Promise<RouteHandle> {
1358
+ const scope = validatedRouteScope(opts.scope);
1306
1359
  const identity = opts.identity ?? this.opts.identity;
1307
1360
  if (!identity) {
1308
1361
  throw new SubcCallError(
@@ -1318,7 +1371,7 @@ export class SubcClient {
1318
1371
  const consumerIdentity = routeOpenConsumerIdentity(opts);
1319
1372
  const reverseRequests = opts.reverseRequests ?? new ReverseRequestRegistry();
1320
1373
  reverseRequests.seal();
1321
- const key = routeCacheKey(target, identity, consumerIdentity, reverseRequests.capabilities());
1374
+ const key = routeCacheKey(target, identity, consumerIdentity, reverseRequests.capabilities(), scope);
1322
1375
  let cached = this.routes.get(key);
1323
1376
  if (!cached) {
1324
1377
  cached = {
@@ -1326,6 +1379,7 @@ export class SubcClient {
1326
1379
  moduleId,
1327
1380
  target,
1328
1381
  identity,
1382
+ scope,
1329
1383
  consumerIdentity,
1330
1384
  reverseRequests,
1331
1385
  handle: null,
@@ -1333,6 +1387,7 @@ export class SubcClient {
1333
1387
  };
1334
1388
  this.routes.set(key, cached);
1335
1389
  }
1390
+ if (cached.endReason !== undefined) throw this.endedCachedRoute(cached.endReason);
1336
1391
  if (cached.handle && this.isLiveHandle(cached.handle)) return cached.handle;
1337
1392
  // The retries end at the retry deadline or at the call's own timeoutMs,
1338
1393
  // whichever comes first. Without timeoutMs the call has no overall deadline
@@ -1378,6 +1433,7 @@ export class SubcClient {
1378
1433
  const refusals = new RouteOpenRefusals();
1379
1434
  for (;;) {
1380
1435
  if (cached.closed) throw this.routeClosedDuringOpen();
1436
+ if (cached.endReason !== undefined) throw this.endedCachedRoute(cached.endReason);
1381
1437
  try {
1382
1438
  await this.ensureConnectedForManaged();
1383
1439
  } catch (error) {
@@ -1392,6 +1448,7 @@ export class SubcClient {
1392
1448
  try {
1393
1449
  outcome = {
1394
1450
  handle: await this.sendRouteOpen(cached.target, cached.identity, {
1451
+ scope: cached.scope,
1395
1452
  consumerIdentity: cached.consumerIdentity ?? null,
1396
1453
  reverseRequests: cached.reverseRequests,
1397
1454
  }),
@@ -1650,9 +1707,10 @@ export class SubcClient {
1650
1707
  }
1651
1708
  for (const key of routeKeys) {
1652
1709
  const cached = this.routes.get(key);
1653
- if (!cached || cached.closed) continue;
1710
+ if (!cached || cached.closed || cached.endReason !== undefined) continue;
1654
1711
  try {
1655
1712
  const handle = await this.routeOpen(cached.target, cached.identity, {
1713
+ scope: cached.scope,
1656
1714
  consumerIdentity: cached.consumerIdentity ?? null,
1657
1715
  reverseRequests: cached.reverseRequests,
1658
1716
  });
@@ -1683,7 +1741,13 @@ export class SubcClient {
1683
1741
  }
1684
1742
 
1685
1743
  private routeClosedDuringOpen(): SubcCallError {
1686
- return new SubcCallError("not_sent", "route was closed before route.open completed", "route_closed");
1744
+ const cause = new SubcError("route was closed before route.open completed", "route_closed", undefined, "closed_by_caller");
1745
+ return new SubcCallError("not_sent", cause.message, cause.code, cause);
1746
+ }
1747
+
1748
+ private endedCachedRoute(reason: RouteCloseReason): SubcCallError {
1749
+ const cause = new SubcError(`cached route closed by subc (${reason}); must not reopen`, "route_closed", undefined, reason);
1750
+ return this.terminalCallError(cause.message, cause);
1687
1751
  }
1688
1752
 
1689
1753
  private async readLoop(sock: SubcSocket, generation: number): Promise<void> {
@@ -1726,12 +1790,12 @@ export class SubcClient {
1726
1790
  if (frame.header.channel === 0 && frame.header.ty === FrameType.Push) {
1727
1791
  // Daemon-originated control push (route.closing / route.closed / future
1728
1792
  // ops). Never matches a pending (corr is daemon-chosen), never an error
1729
- // path: unparseable bodies and absent observers both drop silently per
1730
- // the wire contract's MUST-ignore clause, and an observer throw is
1793
+ // path: unparseable bodies drop silently. Reasons are recorded even without
1794
+ // an observer, and an observer throw is
1731
1795
  // swallowed for the same reason as onProgress below -- a caller callback
1732
1796
  // must not fail the read loop.
1733
1797
  const observer = this.opts.onControlPush;
1734
- if (observer) {
1798
+ {
1735
1799
  let parsed: ControlPush | null = null;
1736
1800
  try {
1737
1801
  const body = this.parseJson(frame) as Record<string, unknown>;
@@ -1740,8 +1804,9 @@ export class SubcClient {
1740
1804
  // Unparseable control push: ignored by contract.
1741
1805
  }
1742
1806
  if (parsed) {
1807
+ this.recordRouteEnd(parsed);
1743
1808
  try {
1744
- observer(parsed);
1809
+ observer?.(parsed);
1745
1810
  } catch {
1746
1811
  // Observer's own throw, on its own stack; the stream must survive.
1747
1812
  }
@@ -1823,7 +1888,8 @@ export class SubcClient {
1823
1888
  // so it stays kind=outcome_unknown -- the same class as a mid-flight socket
1824
1889
  // drop, and never the not_sent/unknown_channel class that call() retries.
1825
1890
  // The code says WHICH route ended, not that it is safe to send again.
1826
- this.failHandle(handle, new SubcError("route closed by subc (GOODBYE)", "route_closed"));
1891
+ this.failHandle(handle, new SubcError("route closed by subc (GOODBYE)", "route_closed", undefined, this.routeEndReason(handle.channel)));
1892
+ this.preventRouteReopen(handle.channel, this.routeEndReason(handle.channel));
1827
1893
  if (this.liveRoutes.get(handle.channel) === handle) this.liveRoutes.delete(handle.channel);
1828
1894
  this.evictRouteHandle(handle);
1829
1895
  return;
@@ -1990,6 +2056,43 @@ export class SubcClient {
1990
2056
  }
1991
2057
  }
1992
2058
 
2059
+ private recordRouteEnd(push: ControlPush): void {
2060
+ const reason = routeCloseReason(push);
2061
+ if (reason === undefined) return;
2062
+ if ("channels" in push.body) {
2063
+ if (Array.isArray(push.body.channels)) {
2064
+ for (const channel of push.body.channels) {
2065
+ if (typeof channel !== "number" || !Number.isInteger(channel) || channel < 0 || channel > 65535) continue;
2066
+ const final = push.op === "route.closed";
2067
+ if (final || !this.routeEndReasons.get(channel)?.final) {
2068
+ this.routeEndReasons.set(channel, { reason, final });
2069
+ if (final) this.preventRouteReopen(channel, reason);
2070
+ }
2071
+ }
2072
+ }
2073
+ } else if (typeof push.body.module_id === "string") {
2074
+ for (const [channel, module] of this.routeModules) {
2075
+ if (module === push.body.module_id) {
2076
+ this.legacyChannelReasons.set(channel, reason);
2077
+ if (push.op === "route.closed") this.preventRouteReopen(channel, reason);
2078
+ }
2079
+ }
2080
+ }
2081
+ }
2082
+
2083
+ private preventRouteReopen(channel: number, reason: RouteCloseReason): void {
2084
+ if (classifyRouteCloseReason(reason) !== "must_not_reopen") return;
2085
+ for (const cached of this.routes.values()) {
2086
+ if (cached.handle?.channel === channel) cached.endReason = reason;
2087
+ }
2088
+ }
2089
+
2090
+ private routeEndReason(channel: number): RouteCloseReason {
2091
+ const specific = this.routeEndReasons.get(channel);
2092
+ if (specific) return specific.reason;
2093
+ return this.legacyChannelReasons.get(channel) ?? "unknown";
2094
+ }
2095
+
1993
2096
  private failHandle(handle: RouteHandle, error: Error): void {
1994
2097
  for (const [key, pending] of this.pending) {
1995
2098
  if (pending.handle && sameRouteHandle(pending.handle, handle)) this.rejectPending(key, pending, error);
@@ -1997,9 +2100,15 @@ export class SubcClient {
1997
2100
  }
1998
2101
 
1999
2102
  private fail(err: Error): void {
2103
+ const routeError = err instanceof SubcError && err.closeReason ? err
2104
+ : new SubcError(err.message, errorCode(err), undefined,
2105
+ this.closeStarted ? "closed_by_caller" : "connection_lost");
2000
2106
  if (!this.closedErr) this.closedErr = err;
2107
+ this.routeEndReasons.clear();
2108
+ this.legacyChannelReasons.clear();
2109
+ this.routeModules.clear();
2001
2110
  for (const [key, pending] of this.pending) {
2002
- this.rejectPending(key, pending, err);
2111
+ this.rejectPending(key, pending, pending.handle ? routeError : err);
2003
2112
  }
2004
2113
  }
2005
2114
 
@@ -2033,6 +2142,9 @@ export class SubcClient {
2033
2142
  reverseRequests = new ReverseRequestRegistry(),
2034
2143
  ): RouteHandle {
2035
2144
  const handle = createRouteHandle(channel, epoch, this.connectionToken, reverseRequests);
2145
+ this.routeEndReasons.delete(channel);
2146
+ this.legacyChannelReasons.delete(channel);
2147
+ this.routeModules.delete(channel);
2036
2148
  this.liveRoutes.set(channel, handle);
2037
2149
  return handle;
2038
2150
  }
@@ -2134,6 +2246,8 @@ function isRouteOpenRefusal(err: unknown): err is SubcError & { code: string } {
2134
2246
  export function isRetryableRouteOpenCode(code: string | undefined): boolean {
2135
2247
  // A capability deny is policy, never a transient target-availability failure.
2136
2248
  if (code === "capability_forbidden") return false;
2249
+ // Decoding a flow stamp alone does not promise flow behaviour.
2250
+ if (code === "target_flow_unsupported") return false;
2137
2251
  return (
2138
2252
  code === "module_reloading" ||
2139
2253
  code === "module_warming" ||
@@ -2258,12 +2372,37 @@ function routeCacheKey(
2258
2372
  identity: BindIdentity,
2259
2373
  consumerIdentity: ConsumerIdentity | undefined,
2260
2374
  consumerCapabilities: readonly string[],
2375
+ scope?: RouteScope,
2261
2376
  ): string {
2262
2377
  const consumerPart = consumerIdentity
2263
2378
  ? `${consumerIdentity.module_id}\0${consumerIdentity.launch_nonce}`
2264
2379
  : "";
2265
2380
  const capabilitiesPart = [...consumerCapabilities].sort().join("\0");
2266
- return `${target.kind}\0${target.module_id}\0${identity.project_root}\0${identity.harness}\0${identity.session}\0${consumerPart}\0${capabilitiesPart}`;
2381
+ // A JSON tuple keeps opaque refs (including embedded separators) distinct.
2382
+ const scopePart = scope
2383
+ ? JSON.stringify([scope.owner.kind, scope.owner.kind === "reserved" ? scope.owner.module_id : null, scope.ref, scope.scopeEpoch])
2384
+ : "";
2385
+ return `${target.kind}\0${target.module_id}\0${identity.project_root}\0${identity.harness}\0${identity.session}\0${consumerPart}\0${capabilitiesPart}\0${scopePart}`;
2386
+ }
2387
+
2388
+ /** Validate before any route-open I/O, and snapshot caller-owned mutable values for reopens. */
2389
+ function validatedRouteScope(scope: RouteScope | undefined): RouteScope | undefined {
2390
+ if (scope === undefined) return undefined;
2391
+ if (!scope || typeof scope.owner !== "object" || !scope.owner ||
2392
+ !["reserved", "direct", "unverified"].includes(scope.owner.kind) ||
2393
+ (scope.owner.kind === "reserved" && (typeof scope.owner.module_id !== "string" || scope.owner.module_id.length === 0))) {
2394
+ throw new SubcError("scope.owner must be a Principal with a non-empty reserved module_id", "invalid_scope");
2395
+ }
2396
+ if (typeof scope.ref !== "string" || scope.ref.length === 0) {
2397
+ throw new SubcError("scope.ref must be a non-empty string", "invalid_scope");
2398
+ }
2399
+ if (scope.scopeEpoch === undefined) {
2400
+ throw new SubcError("scope.scopeEpoch is required", "scope_epoch_required");
2401
+ }
2402
+ if (!Number.isSafeInteger(scope.scopeEpoch) || scope.scopeEpoch < 0) {
2403
+ throw new SubcError("scope.scopeEpoch must be a safe non-negative integer", "invalid_scope");
2404
+ }
2405
+ return { owner: { ...scope.owner }, ref: scope.ref, scopeEpoch: scope.scopeEpoch };
2267
2406
  }
2268
2407
 
2269
2408
  function routeOpenConsumerIdentity(opts: RouteOpenOptions = {}): ConsumerIdentity | undefined {
@@ -1,8 +1,8 @@
1
1
  // Port of subc-transport's connection_file.rs reader. The connection file is
2
2
  // the daemon's published rendezvous record; its `key` is the shared transport
3
- // secret. We refuse to trust a key from a file other local users can read,
4
- // exactly as the Rust reader does, so a leaked (group/world-readable) key is a
5
- // loud failure rather than a silent downgrade.
3
+ // secret. We refuse to trust a key from a file another local user owns or can
4
+ // read, exactly as the Rust reader does, so a substituted or leaked
5
+ // (group/world-readable) key is a loud failure rather than a silent downgrade.
6
6
 
7
7
  import { promises as fs } from "node:fs";
8
8
 
@@ -64,14 +64,22 @@ function validate(info: ConnectionInfo): void {
64
64
  }
65
65
 
66
66
  /**
67
- * On unix, reject any group/other permission bit: the key is published
68
- * owner-only (0600), so a wider mode means the secret has leaked. On Windows the
67
+ * On unix, refuse a file owned by anyone but `expectedUid`, then any
68
+ * group/other permission bit. A file another user owns is untrusted even at
69
+ * 0600: its owner could have written their own endpoint and key, and this
70
+ * client would then authenticate to them. A wider mode on our own file means the
71
+ * secret has leaked. `expectedUid` is undefined where the platform has no
72
+ * effective uid (Windows), and the owner check is skipped there. On Windows the
69
73
  * file inherits the per-user profile directory ACL at create time and there are
70
74
  * no portable mode bits to re-check, matching the Rust no-op.
71
75
  */
72
- async function verifyOwnerOnly(path: string): Promise<void> {
76
+ function verifyOwnerOnly(path: string, stat: { uid: number; mode: number }, expectedUid: number | undefined): void {
73
77
  if (process.platform === "win32") return;
74
- const stat = await fs.stat(path);
78
+ if (expectedUid !== undefined && stat.uid !== expectedUid) {
79
+ throw new ConnectionFileError(
80
+ `connection file ${path} is owned by uid ${stat.uid}, expected effective uid ${expectedUid}`,
81
+ );
82
+ }
75
83
  const mode = stat.mode & 0o777;
76
84
  if ((mode & 0o077) !== 0) {
77
85
  throw new ConnectionFileError(
@@ -80,10 +88,29 @@ async function verifyOwnerOnly(path: string): Promise<void> {
80
88
  }
81
89
  }
82
90
 
83
- /** Read, permission-check, and validate a connection file. */
91
+ /** Read, ownership- and permission-check, and validate a connection file. */
84
92
  export async function readConnectionFile(path: string): Promise<ConnectionInfo> {
85
- await verifyOwnerOnly(path);
86
- const raw = await fs.readFile(path, "utf8");
93
+ return readConnectionFileForUid(path, process.geteuid?.());
94
+ }
95
+
96
+ /**
97
+ * @internal `readConnectionFile` with the expected owner uid supplied by the
98
+ * caller, so tests can present a foreign owner without changing the process uid.
99
+ */
100
+ export async function readConnectionFileForUid(
101
+ path: string,
102
+ expectedUid: number | undefined,
103
+ ): Promise<ConnectionInfo> {
104
+ // Check the opened file, not a second lookup of the path: if the path were
105
+ // replaced between a stat and the read, an unchecked key would get through.
106
+ const handle = await fs.open(path, "r");
107
+ let raw: string;
108
+ try {
109
+ verifyOwnerOnly(path, await handle.stat(), expectedUid);
110
+ raw = await handle.readFile({ encoding: "utf8" });
111
+ } finally {
112
+ await handle.close();
113
+ }
87
114
  let parsed: Record<string, unknown>;
88
115
  try {
89
116
  parsed = JSON.parse(raw) as Record<string, unknown>;
package/src/envelope.ts CHANGED
@@ -172,18 +172,19 @@ function validateHeaderFields(header: EnvelopeHeader): void {
172
172
  "sheddable_illegal_frame_type",
173
173
  );
174
174
  }
175
- if (channel === 0 && epoch !== 0) {
176
- throw new DecodeError(
177
- `control channel carried nonzero epoch ${epoch}`,
178
- "nonzero_epoch_on_control_channel",
179
- );
180
- }
175
+ // Match the wire decoder's precedence when a header violates both rules.
181
176
  if (isPureHeader(ty) && len !== 0) {
182
177
  throw new DecodeError(
183
178
  `pure-header frame ${FrameType[ty]} declared non-zero body length ${len}`,
184
179
  "pure_header_frame_with_body",
185
180
  );
186
181
  }
182
+ if (channel === 0 && epoch !== 0) {
183
+ throw new DecodeError(
184
+ `control channel carried nonzero epoch ${epoch}`,
185
+ "nonzero_epoch_on_control_channel",
186
+ );
187
+ }
187
188
  }
188
189
 
189
190
  /** Decode and validate a header from the front of `bytes`. */
@@ -233,18 +234,19 @@ export function decodeHeader(bytes: Uint8Array): EnvelopeHeader {
233
234
  }
234
235
  const channel = view.getUint16(7, true);
235
236
  const epoch = view.getUint32(9, true);
236
- if (channel === 0 && epoch !== 0) {
237
- throw new DecodeError(
238
- `control channel carried nonzero epoch ${epoch}`,
239
- "nonzero_epoch_on_control_channel",
240
- );
241
- }
237
+ // Body length is checked before the control epoch in Rust and Swift too.
242
238
  if (isPureHeader(ty) && len !== 0) {
243
239
  throw new DecodeError(
244
240
  `pure-header frame ${FrameType[ty]} declared non-zero body length ${len}`,
245
241
  "pure_header_frame_with_body",
246
242
  );
247
243
  }
244
+ if (channel === 0 && epoch !== 0) {
245
+ throw new DecodeError(
246
+ `control channel carried nonzero epoch ${epoch}`,
247
+ "nonzero_epoch_on_control_channel",
248
+ );
249
+ }
248
250
  return { len, ver, ty, flags, channel, epoch, corr: view.getBigUint64(13, true) };
249
251
  }
250
252
 
@@ -278,7 +280,17 @@ export function buildFrameWithVersion(
278
280
  return { header, body };
279
281
  }
280
282
 
281
- /** Encode a frame to wire bytes: header followed by exactly `len` body bytes. */
283
+ /**
284
+ * Encode a frame to wire bytes: header followed by exactly `len` body bytes.
285
+ *
286
+ * Refuses, before producing any bytes, a frame the peer's decoder would reject:
287
+ * a header that fails any wire decode rule, or a body over MAX_FRAME_BODY_LEN.
288
+ * A `Frame` can be assembled by hand without going through `buildFrame`, and
289
+ * once a rejected header is on the socket the peer tears the whole connection
290
+ * down, so the refusal has to happen here rather than on the far side. The
291
+ * header check runs the encoded bytes through `decodeHeader` itself, so the
292
+ * writer cannot drift from the reader's rules.
293
+ */
282
294
  export function encodeFrame(frame: Frame): Uint8Array {
283
295
  if (frame.header.len !== frame.body.length) {
284
296
  throw new DecodeError(
@@ -286,7 +298,14 @@ export function encodeFrame(frame: Frame): Uint8Array {
286
298
  "frame_length_mismatch",
287
299
  );
288
300
  }
301
+ if (frame.body.length > MAX_FRAME_BODY_LEN) {
302
+ throw new DecodeError(
303
+ `frame body ${frame.body.length} exceeds max ${MAX_FRAME_BODY_LEN}`,
304
+ "frame_body_too_large",
305
+ );
306
+ }
289
307
  const header = encodeHeader(frame.header);
308
+ decodeHeader(header);
290
309
  const output = new Uint8Array(header.length + frame.body.length);
291
310
  output.set(header, 0);
292
311
  output.set(frame.body, header.length);
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ export {
19
19
  type RouteTarget,
20
20
  type ManagedRouteKind,
21
21
  type ConsumerIdentity,
22
+ type RouteScope,
22
23
  type RouteOpenOptions,
23
24
  type CatalogCapabilities,
24
25
  type CatalogCapabilityRequirement,
@@ -29,6 +30,7 @@ export {
29
30
  type ControlPush,
30
31
  type KnownRouteCloseReason,
31
32
  type RouteCloseReason,
33
+ type RouteEndReason,
32
34
  type RouteCloseDisposition,
33
35
  type ReconnectBackoff,
34
36
  type SubcCallErrorKind,