@scp-dev/hub 0.5.0 → 0.6.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/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as _scp_dev_core from '@scp-dev/core';
2
- import { Envelope, RelayCore, CoreOptions, EndpointRow, PolicySubject, ScopeSubject, ScopeNode, ScopeDirection, Admission, MessageRow, EndpointView, ScopeCap, ScopeEntry, Claim, SendParams, SendResult, EndpointKind, Presence, StatusState, Verdict, JudgeModelConfig, JudgeClient, Sql, JudgePolicy, Wake, CrossAccountLink, XDelivered, XListed, ActivityRow, XRelease } from '@scp-dev/core';
2
+ import { Envelope, RelayCore, CoreOptions, EndpointRow, EndpointView, PolicySubject, ScopeSubject, ScopeNode, ScopeDirection, Admission, MessageRow, ScopeCap, ScopeEntry, Claim, SendParams, SendResult, EndpointKind, Presence, StatusState, Verdict, JudgeModelConfig, JudgeClient, Sql, JudgePolicy, Wake, CrossAccountLink, XDelivered, XListed, ActivityRow, XRelease } from '@scp-dev/core';
3
3
 
4
4
  type RelayAuth = Envelope["from"]["auth"];
5
5
  /** a machine name: one relay host of an account */
@@ -36,6 +36,8 @@ interface MirrorScope {
36
36
  parent_ep?: string | null;
37
37
  /** 0 for a root; null when not known */
38
38
  depth?: number | null;
39
+ /** a parent on another machine of the account, `<machine>/<handle>`; null for none */
40
+ parent_addr?: string | null;
39
41
  rules?: MirrorRules | null;
40
42
  caps?: MirrorCaps | null;
41
43
  /**
@@ -162,6 +164,19 @@ declare class FederatedCore extends RelayCore {
162
164
  protected resolve(address: unknown): EndpointRow;
163
165
  protected addressUsers(): string[];
164
166
  protected localRow(row: EndpointRow): boolean;
167
+ /** `<machine>/<handle>` of a row of this account: a mirrored row's remote, a local one's on this machine; null for another account's or without a machine */
168
+ private keyOf;
169
+ /** the row `<machine>/<handle>` names: a local one on this machine, else a mirrored one */
170
+ private byKey;
171
+ /** whether walking up from `start` (local and mirrored parents, then a link across machines) reaches a row whose key or id is in `targets` */
172
+ private reachesUp;
173
+ /** a parent on another machine is a mirrored agent of this account that neither is nor sits under the child or one of its descendants */
174
+ protected linkAcross(row: EndpointRow, parent: EndpointRow): string;
175
+ protected renamedHandle(from: string, to: string): void;
176
+ protected linkedAcross(a: EndpointRow, b: EndpointRow): boolean;
177
+ protected linkedParent(row: EndpointRow, shown?: Set<string>): string | null;
178
+ /** a live mirrored row shows the presence its machine mirrored, not one its last_seen decays to here */
179
+ protected view(row: EndpointRow, shown?: Set<string>): EndpointView;
165
180
  protected address(row: EndpointRow): string;
166
181
  protected subject(row: EndpointRow): PolicySubject;
167
182
  /** a stand-in for another account's sender is no endpoint of this relay: list, peers and snapshots leave it out */
@@ -373,6 +388,8 @@ declare class FederatedCore extends RelayCore {
373
388
  private placer;
374
389
  /** an agent row's place, rules, caps, title and incarnation after a view: what it left out stays, unless the endpoint is a new incarnation */
375
390
  private scopeColumns;
391
+ /** whether a link of `row` under `key` closes a cycle in what this relay mirrors */
392
+ private loops;
376
393
  /** wire caps of `machine` with their `ep:<handle>` references as this core's row ids; an endpoint the machine does not mirror is `gone`, which admits nothing */
377
394
  private localCaps;
378
395
  /** caps with this core's row ids named as `ep:<handle>`, as they cross machines */
package/dist/index.js CHANGED
@@ -90,6 +90,7 @@ var FederatedCore = class extends RelayCore {
90
90
  if (!columns.includes("incarnation")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN incarnation TEXT`);
91
91
  if (!columns.includes("scope_epoch")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN scope_epoch INTEGER`);
92
92
  if (!columns.includes("mirrored_caps")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN mirrored_caps TEXT`);
93
+ if (!columns.includes("mirrored_presence")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN mirrored_presence TEXT`);
93
94
  this.sql.all(`CREATE UNIQUE INDEX IF NOT EXISTS endpoints_remote ON endpoints (remote) WHERE remote IS NOT NULL`);
94
95
  if (this.xinbox) for (const statement of XACCT_MIGRATION) this.sql.all(statement);
95
96
  }
@@ -222,6 +223,63 @@ var FederatedCore = class extends RelayCore {
222
223
  localRow(row) {
223
224
  return !row.remote;
224
225
  }
226
+ /** `<machine>/<handle>` of a row of this account: a mirrored row's remote, a local one's on this machine; null for another account's or without a machine */
227
+ keyOf(row) {
228
+ const remote = row.remote;
229
+ if (remote) return isForeign(remote) ? null : remote;
230
+ return this.machine === null ? null : `${this.machine}/${row.handle}`;
231
+ }
232
+ /** the row `<machine>/<handle>` names: a local one on this machine, else a mirrored one */
233
+ byKey(key) {
234
+ if (!REMOTE.test(key)) return void 0;
235
+ const handle = handlePart(key);
236
+ if (machinePart(key) === this.machine) return validHandle(handle) ? this.byHandle(handle) : void 0;
237
+ return this.byRemote(key);
238
+ }
239
+ /** whether walking up from `start` (local and mirrored parents, then a link across machines) reaches a row whose key or id is in `targets` */
240
+ reachesUp(start, targets) {
241
+ const seen = /* @__PURE__ */ new Set();
242
+ let row = start;
243
+ while (row && !seen.has(row.id) && seen.size <= LIMITS.depthMax * 4) {
244
+ seen.add(row.id);
245
+ const key = this.keyOf(row);
246
+ if (targets.has(row.id) || key !== null && targets.has(key)) return true;
247
+ row = row.parent_ep ? this.byId(row.parent_ep) : row.parent_addr ? this.byKey(row.parent_addr) : void 0;
248
+ }
249
+ return false;
250
+ }
251
+ /** a parent on another machine is a mirrored agent of this account that neither is nor sits under the child or one of its descendants */
252
+ linkAcross(row, parent) {
253
+ const remote = parent.remote;
254
+ if (!remote || isForeign(remote) || parent.kind !== "agent" || handlePart(remote) === "")
255
+ throw new RelayError("invalid_params", "the parent is an agent endpoint of this account");
256
+ if (row.parent_addr === remote) return remote;
257
+ const below = this.sql.all(`SELECT id FROM endpoints WHERE tree = ?`, row.id).map((r) => r.id);
258
+ const key = this.keyOf(row);
259
+ if (this.reachesUp(parent, /* @__PURE__ */ new Set([row.id, ...below, ...key === null ? [] : [key]])))
260
+ throw new RelayError("scope_refused", `${this.address(parent)} sits under ${this.address(row)}: a session cannot go under its own descendant`);
261
+ return remote;
262
+ }
263
+ renamedHandle(from, to) {
264
+ if (this.machine !== null) this.sql.all(`UPDATE endpoints SET parent_addr = ? WHERE parent_addr = ?`, `${this.machine}/${to}`, `${this.machine}/${from}`);
265
+ }
266
+ linkedAcross(a, b) {
267
+ const [ka, kb] = [this.keyOf(a), this.keyOf(b)];
268
+ return a.parent_addr !== null && a.parent_addr === kb || b.parent_addr !== null && b.parent_addr === ka;
269
+ }
270
+ linkedParent(row, shown) {
271
+ const key = row.parent_addr;
272
+ if (!key) return null;
273
+ const parent = this.byKey(key);
274
+ if (parent) return shown && !shown.has(parent.id) ? null : this.address(parent);
275
+ return shown ? null : this.remoteAddress(key);
276
+ }
277
+ /** a live mirrored row shows the presence its machine mirrored, not one its last_seen decays to here */
278
+ view(row, shown) {
279
+ const view = super.view(row, shown);
280
+ const { remote, mirrored_presence: mirrored } = row;
281
+ return remote && row.connections > 0 && (mirrored === "online" || mirrored === "idle") ? { ...view, presence: mirrored } : view;
282
+ }
225
283
  address(row) {
226
284
  const remote = row.remote;
227
285
  if (remote && isForeign(remote)) return remote.slice(1);
@@ -369,6 +427,7 @@ var FederatedCore = class extends RelayCore {
369
427
  const old = !before && view.was !== null ? this.byRemote(`${machine}/${view.was}`) : void 0;
370
428
  if (old && old.token_hash === null) {
371
429
  this.sql.all(`UPDATE endpoints SET remote = ? WHERE id = ?`, remote, old.id);
430
+ this.sql.all(`UPDATE endpoints SET parent_addr = ? WHERE parent_addr = ?`, remote, `${machine}/${view.was}`);
372
431
  before = this.byId(old.id);
373
432
  renamed.set(old.id, view.was);
374
433
  }
@@ -391,6 +450,7 @@ var FederatedCore = class extends RelayCore {
391
450
  status_note: view.status?.note ?? null,
392
451
  token_hash: null,
393
452
  connections: live ? 1 : 0,
453
+ mirrored_presence: view.presence,
394
454
  last_seen: view.lastSeen ?? (live ? now : before?.last_seen ?? null),
395
455
  auth: auth ?? row.auth,
396
456
  ...view.kind === "agent" ? this.scopeColumns(machine, view.scope, row, place(view.handle)) : {}
@@ -451,6 +511,7 @@ var FederatedCore = class extends RelayCore {
451
511
  tree: root?.handle ?? null,
452
512
  parent_ep: parent?.handle ?? null,
453
513
  depth: row.depth ?? 0,
514
+ parent_addr: row.parent_addr ?? null,
454
515
  rules: Object.keys(rules).length ? rules : null,
455
516
  caps: wire,
456
517
  title: row.title ?? null,
@@ -1286,9 +1347,7 @@ var FederatedCore = class extends RelayCore {
1286
1347
  const was = this.view(before);
1287
1348
  const presence = was.presence !== view.presence;
1288
1349
  const status = before.status_state !== after.status_state || before.status_note !== after.status_note;
1289
- const other = ["kind", "harness", "model", "cwd", "labels", "title", "wake", "tree", "parent_ep", "depth", "rules", "caps", "incarnation"].some(
1290
- (field) => before[field] !== after[field]
1291
- );
1350
+ const other = ["kind", "harness", "model", "cwd", "labels", "title", "wake", "tree", "parent_ep", "parent_addr", "depth", "rules", "caps", "incarnation"].some((field) => before[field] !== after[field]);
1292
1351
  return presence || status || other ? { view, added: false, presence, status } : null;
1293
1352
  }
1294
1353
  remoteView(row) {
@@ -1304,6 +1363,7 @@ var FederatedCore = class extends RelayCore {
1304
1363
  tree: row.depth === null ? null : row.tree ? handle(row.tree) : remote ? handlePart(remote) : row.handle,
1305
1364
  parent_ep: handle(row.parent_ep),
1306
1365
  depth: row.depth,
1366
+ parent_addr: row.parent_addr ?? null,
1307
1367
  rules: row.rules ? JSON.parse(row.rules) : null,
1308
1368
  caps: row.caps ? this.wireCaps(JSON.parse(row.caps)) : null,
1309
1369
  incarnation: row.incarnation ?? null
@@ -1349,8 +1409,10 @@ var FederatedCore = class extends RelayCore {
1349
1409
  caps = merged.caps.length || Object.keys(merged.allow).length ? JSON.stringify(merged) : null;
1350
1410
  }
1351
1411
  const rules = scope.rules !== void 0 ? scope.rules : fresh ? null : void 0;
1412
+ const linked = scope.parentAddr !== void 0 ? scope.parentAddr : fresh ? null : row.parent_addr;
1352
1413
  return {
1353
1414
  ...place,
1415
+ parent_addr: linked !== null && (place.parent_ep !== null || this.loops(row, linked)) ? null : linked,
1354
1416
  rules: rules === void 0 ? row.rules : rules && (rules.in || rules.out) ? JSON.stringify(rules) : null,
1355
1417
  caps,
1356
1418
  title: scope.title !== void 0 ? scope.title : fresh ? null : row.title,
@@ -1358,6 +1420,12 @@ var FederatedCore = class extends RelayCore {
1358
1420
  incarnation: scope.incarnation ?? row.incarnation
1359
1421
  };
1360
1422
  }
1423
+ /** whether a link of `row` under `key` closes a cycle in what this relay mirrors */
1424
+ loops(row, key) {
1425
+ const parent = this.byKey(key);
1426
+ const own = this.keyOf(row);
1427
+ return parent !== void 0 && this.reachesUp(parent, /* @__PURE__ */ new Set([row.id, ...own === null ? [] : [own]]));
1428
+ }
1361
1429
  /** wire caps of `machine` with their `ep:<handle>` references as this core's row ids; an endpoint the machine does not mirror is `gone`, which admits nothing */
1362
1430
  localCaps(machine, caps) {
1363
1431
  const ref = (value) => {
@@ -1480,6 +1548,12 @@ function parseScope(view) {
1480
1548
  if (view.depth !== null && (!Number.isInteger(view.depth) || view.depth < 0 || view.depth > 1e3)) throw bad("depth");
1481
1549
  out.depth = view.depth ?? null;
1482
1550
  }
1551
+ if (given("parent_addr")) {
1552
+ const key = view.parent_addr;
1553
+ if (key !== null && (typeof key !== "string" || !REMOTE.test(key) || !MACHINE_NAME.test(machinePart(key)) || !validHandle(handlePart(key))))
1554
+ throw bad("parent_addr");
1555
+ out.parentAddr = key ?? null;
1556
+ }
1483
1557
  if (given("rules")) {
1484
1558
  if (view.rules !== null && !isRecord(view.rules)) throw bad("rules");
1485
1559
  const rules = view.rules;
@@ -3468,7 +3542,7 @@ var RelayHub = class _RelayHub {
3468
3542
  return count;
3469
3543
  });
3470
3544
  this.stored = null;
3471
- this.toBridges(name, { machine: name, endpoints: [], full: true });
3545
+ this.toBridges(name, { machine: name, endpoints: [], full: true, released: true });
3472
3546
  this.dash({ type: "endpoints", at: new Date(this.now()).toISOString(), machine: name });
3473
3547
  this.deps.log?.("relay_action", { account: this.account, machine: name, method: "machine_release", code: "ok" });
3474
3548
  return { removed: name, endpoints: dropped };
@@ -3505,7 +3579,7 @@ var RelayHub = class _RelayHub {
3505
3579
  core.record("machine_expire", null, name, { machine: name, endpoints: count });
3506
3580
  return count;
3507
3581
  });
3508
- this.toBridges(name, { machine: name, endpoints: [], full: true });
3582
+ this.toBridges(name, { machine: name, endpoints: [], full: true, released: true });
3509
3583
  this.deps.log?.("relay_action", { account: this.account, machine: name, method: "machine_expire", code: "ok", endpoints: dropped });
3510
3584
  }
3511
3585
  }
@@ -3523,7 +3597,7 @@ var RelayHub = class _RelayHub {
3523
3597
  for (const name of unbound) this.relay.record("machine_forget", null, name, { machine: name, endpoints: this.relay.dropMachine(name, { queue: true }) });
3524
3598
  return unbound;
3525
3599
  });
3526
- for (const name of forgotten) this.toBridges(name, { machine: name, endpoints: [], full: true });
3600
+ for (const name of forgotten) this.toBridges(name, { machine: name, endpoints: [], full: true, released: true });
3527
3601
  }
3528
3602
  /** the hub_state keys (`denied:<tokenId>`) of tokens revoked lately, to when; loaded once per object lifetime */
3529
3603
  deniedGrants() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/hub",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Swarm Communication Protocol federation and account hub: mirrored endpoints across machines, bridges, direct clients and cross-account delivery.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://scp.dev",
@@ -29,7 +29,7 @@
29
29
  "typecheck": "tsc -p ."
30
30
  },
31
31
  "dependencies": {
32
- "@scp-dev/core": "0.1.5"
32
+ "@scp-dev/core": "0.2.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "tsup": "^8.5.1"
package/src/federation.ts CHANGED
@@ -82,6 +82,8 @@ interface FederatedRow extends EndpointRow {
82
82
  scope_epoch: number | null
83
83
  /** this machine's own agent: the wire caps it mirrored last */
84
84
  mirrored_caps: string | null
85
+ /** another machine's row: the presence its bridge mirrored last */
86
+ mirrored_presence: Presence | null
85
87
  }
86
88
 
87
89
  /** own rules as they cross machines: selector lists per direction */
@@ -104,6 +106,8 @@ export interface MirrorScope {
104
106
  parent_ep?: string | null
105
107
  /** 0 for a root; null when not known */
106
108
  depth?: number | null
109
+ /** a parent on another machine of the account, `<machine>/<handle>`; null for none */
110
+ parent_addr?: string | null
107
111
  rules?: MirrorRules | null
108
112
  caps?: MirrorCaps | null
109
113
  /**
@@ -145,6 +149,7 @@ interface ParsedScope {
145
149
  tree?: string | null
146
150
  parent?: string | null
147
151
  depth?: number | null
152
+ parentAddr?: string | null
148
153
  rules?: MirrorRules | null
149
154
  caps?: ScopeCaps | null
150
155
  title?: string | null
@@ -290,6 +295,7 @@ export class FederatedCore extends RelayCore {
290
295
  if (!columns.includes("incarnation")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN incarnation TEXT`)
291
296
  if (!columns.includes("scope_epoch")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN scope_epoch INTEGER`)
292
297
  if (!columns.includes("mirrored_caps")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN mirrored_caps TEXT`)
298
+ if (!columns.includes("mirrored_presence")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN mirrored_presence TEXT`)
293
299
  this.sql.all(`CREATE UNIQUE INDEX IF NOT EXISTS endpoints_remote ON endpoints (remote) WHERE remote IS NOT NULL`)
294
300
  if (this.xinbox) for (const statement of XACCT_MIGRATION) this.sql.all(statement)
295
301
  }
@@ -446,6 +452,71 @@ export class FederatedCore extends RelayCore {
446
452
  return !(row as FederatedRow).remote
447
453
  }
448
454
 
455
+ /** `<machine>/<handle>` of a row of this account: a mirrored row's remote, a local one's on this machine; null for another account's or without a machine */
456
+ private keyOf(row: EndpointRow): string | null {
457
+ const remote = (row as FederatedRow).remote
458
+ if (remote) return isForeign(remote) ? null : remote
459
+ return this.machine === null ? null : `${this.machine}/${row.handle}`
460
+ }
461
+
462
+ /** the row `<machine>/<handle>` names: a local one on this machine, else a mirrored one */
463
+ private byKey(key: string): EndpointRow | undefined {
464
+ if (!REMOTE.test(key)) return undefined
465
+ const handle = handlePart(key)
466
+ if (machinePart(key) === this.machine) return validHandle(handle) ? this.byHandle(handle) : undefined
467
+ return this.byRemote(key)
468
+ }
469
+
470
+ /** whether walking up from `start` (local and mirrored parents, then a link across machines) reaches a row whose key or id is in `targets` */
471
+ private reachesUp(start: EndpointRow, targets: Set<string>): boolean {
472
+ const seen = new Set<string>()
473
+ let row: EndpointRow | undefined = start
474
+ while (row && !seen.has(row.id) && seen.size <= LIMITS.depthMax * 4) {
475
+ seen.add(row.id)
476
+ const key = this.keyOf(row)
477
+ if (targets.has(row.id) || (key !== null && targets.has(key))) return true
478
+ row = row.parent_ep ? this.byId(row.parent_ep) : row.parent_addr ? this.byKey(row.parent_addr) : undefined
479
+ }
480
+ return false
481
+ }
482
+
483
+ /** a parent on another machine is a mirrored agent of this account that neither is nor sits under the child or one of its descendants */
484
+ protected override linkAcross(row: EndpointRow, parent: EndpointRow): string {
485
+ const remote = (parent as FederatedRow).remote
486
+ if (!remote || isForeign(remote) || parent.kind !== "agent" || handlePart(remote) === "")
487
+ throw new RelayError("invalid_params", "the parent is an agent endpoint of this account")
488
+ if (row.parent_addr === remote) return remote
489
+ const below = this.sql.all<{ id: string }>(`SELECT id FROM endpoints WHERE tree = ?`, row.id).map((r) => r.id)
490
+ const key = this.keyOf(row)
491
+ if (this.reachesUp(parent, new Set([row.id, ...below, ...(key === null ? [] : [key])])))
492
+ throw new RelayError("scope_refused", `${this.address(parent)} sits under ${this.address(row)}: a session cannot go under its own descendant`)
493
+ return remote
494
+ }
495
+
496
+ protected override renamedHandle(from: string, to: string): void {
497
+ if (this.machine !== null) this.sql.all(`UPDATE endpoints SET parent_addr = ? WHERE parent_addr = ?`, `${this.machine}/${to}`, `${this.machine}/${from}`)
498
+ }
499
+
500
+ protected override linkedAcross(a: EndpointRow, b: EndpointRow): boolean {
501
+ const [ka, kb] = [this.keyOf(a), this.keyOf(b)]
502
+ return (a.parent_addr !== null && a.parent_addr === kb) || (b.parent_addr !== null && b.parent_addr === ka)
503
+ }
504
+
505
+ protected override linkedParent(row: EndpointRow, shown?: Set<string>): string | null {
506
+ const key = row.parent_addr
507
+ if (!key) return null
508
+ const parent = this.byKey(key)
509
+ if (parent) return shown && !shown.has(parent.id) ? null : this.address(parent)
510
+ return shown ? null : this.remoteAddress(key)
511
+ }
512
+
513
+ /** a live mirrored row shows the presence its machine mirrored, not one its last_seen decays to here */
514
+ protected override view(row: EndpointRow, shown?: Set<string>): EndpointView {
515
+ const view = super.view(row, shown)
516
+ const { remote, mirrored_presence: mirrored } = row as FederatedRow
517
+ return remote && row.connections > 0 && (mirrored === "online" || mirrored === "idle") ? { ...view, presence: mirrored } : view
518
+ }
519
+
449
520
  protected override address(row: EndpointRow): string {
450
521
  const remote = (row as FederatedRow).remote
451
522
  if (remote && isForeign(remote)) return remote.slice(1)
@@ -603,6 +674,7 @@ export class FederatedCore extends RelayCore {
603
674
  const old = !before && view.was !== null ? this.byRemote(`${machine}/${view.was}`) : undefined
604
675
  if (old && old.token_hash === null) {
605
676
  this.sql.all(`UPDATE endpoints SET remote = ? WHERE id = ?`, remote, old.id)
677
+ this.sql.all(`UPDATE endpoints SET parent_addr = ? WHERE parent_addr = ?`, remote, `${machine}/${view.was}`)
606
678
  before = this.byId(old.id) as FederatedRow
607
679
  renamed.set(old.id, view.was!)
608
680
  }
@@ -625,6 +697,7 @@ export class FederatedCore extends RelayCore {
625
697
  status_note: view.status?.note ?? null,
626
698
  token_hash: null,
627
699
  connections: live ? 1 : 0,
700
+ mirrored_presence: view.presence,
628
701
  last_seen: view.lastSeen ?? (live ? now : (before?.last_seen ?? null)),
629
702
  auth: auth ?? row.auth,
630
703
  ...(view.kind === "agent" ? this.scopeColumns(machine, view.scope, row, place(view.handle)) : {}),
@@ -686,6 +759,7 @@ export class FederatedCore extends RelayCore {
686
759
  tree: root?.handle ?? null,
687
760
  parent_ep: parent?.handle ?? null,
688
761
  depth: row.depth ?? 0,
762
+ parent_addr: row.parent_addr ?? null,
689
763
  rules: Object.keys(rules).length ? rules : null,
690
764
  caps: wire,
691
765
  title: row.title ?? null,
@@ -1616,9 +1690,9 @@ export class FederatedCore extends RelayCore {
1616
1690
  const was = this.view(before)
1617
1691
  const presence = was.presence !== view.presence
1618
1692
  const status = before.status_state !== after.status_state || before.status_note !== after.status_note
1619
- const other = (["kind", "harness", "model", "cwd", "labels", "title", "wake", "tree", "parent_ep", "depth", "rules", "caps", "incarnation"] as const).some(
1620
- (field) => before[field] !== (after as FederatedRow)[field],
1621
- )
1693
+ const other = (
1694
+ ["kind", "harness", "model", "cwd", "labels", "title", "wake", "tree", "parent_ep", "parent_addr", "depth", "rules", "caps", "incarnation"] as const
1695
+ ).some((field) => before[field] !== (after as FederatedRow)[field])
1622
1696
  return presence || status || other ? { view, added: false, presence, status } : null
1623
1697
  }
1624
1698
 
@@ -1635,6 +1709,7 @@ export class FederatedCore extends RelayCore {
1635
1709
  tree: row.depth === null ? null : row.tree ? handle(row.tree) : remote ? handlePart(remote) : row.handle,
1636
1710
  parent_ep: handle(row.parent_ep),
1637
1711
  depth: row.depth,
1712
+ parent_addr: row.parent_addr ?? null,
1638
1713
  rules: row.rules ? (JSON.parse(row.rules) as MirrorRules) : null,
1639
1714
  caps: row.caps ? this.wireCaps(JSON.parse(row.caps) as ScopeCaps) : null,
1640
1715
  incarnation: (row as FederatedRow).incarnation ?? null,
@@ -1684,8 +1759,10 @@ export class FederatedCore extends RelayCore {
1684
1759
  caps = merged.caps.length || Object.keys(merged.allow).length ? JSON.stringify(merged) : null
1685
1760
  }
1686
1761
  const rules = scope.rules !== undefined ? scope.rules : fresh ? null : undefined
1762
+ const linked = scope.parentAddr !== undefined ? scope.parentAddr : fresh ? null : row.parent_addr
1687
1763
  return {
1688
1764
  ...place,
1765
+ parent_addr: linked !== null && (place.parent_ep !== null || this.loops(row, linked)) ? null : linked,
1689
1766
  rules: rules === undefined ? row.rules : rules && (rules.in || rules.out) ? JSON.stringify(rules) : null,
1690
1767
  caps,
1691
1768
  title: scope.title !== undefined ? scope.title : fresh ? null : row.title,
@@ -1694,6 +1771,13 @@ export class FederatedCore extends RelayCore {
1694
1771
  }
1695
1772
  }
1696
1773
 
1774
+ /** whether a link of `row` under `key` closes a cycle in what this relay mirrors */
1775
+ private loops(row: EndpointRow, key: string): boolean {
1776
+ const parent = this.byKey(key)
1777
+ const own = this.keyOf(row)
1778
+ return parent !== undefined && this.reachesUp(parent, new Set([row.id, ...(own === null ? [] : [own])]))
1779
+ }
1780
+
1697
1781
  /** wire caps of `machine` with their `ep:<handle>` references as this core's row ids; an endpoint the machine does not mirror is `gone`, which admits nothing */
1698
1782
  private localCaps(machine: string, caps: ScopeCaps): ScopeCaps {
1699
1783
  const ref = (value: string) => {
@@ -1838,6 +1922,12 @@ function parseScope(view: Partial<MirrorView>): ParsedScope {
1838
1922
  if (view.depth !== null && (!Number.isInteger(view.depth) || (view.depth as number) < 0 || (view.depth as number) > 1000)) throw bad("depth")
1839
1923
  out.depth = view.depth ?? null
1840
1924
  }
1925
+ if (given("parent_addr")) {
1926
+ const key = view.parent_addr
1927
+ if (key !== null && (typeof key !== "string" || !REMOTE.test(key) || !MACHINE_NAME.test(machinePart(key)) || !validHandle(handlePart(key))))
1928
+ throw bad("parent_addr")
1929
+ out.parentAddr = key ?? null
1930
+ }
1841
1931
  if (given("rules")) {
1842
1932
  if (view.rules !== null && !isRecord(view.rules)) throw bad("rules")
1843
1933
  const rules = view.rules as Record<string, unknown> | null
package/src/hub.ts CHANGED
@@ -1774,7 +1774,7 @@ export class RelayHub {
1774
1774
  return count
1775
1775
  })
1776
1776
  this.stored = null
1777
- this.toBridges(name, { machine: name, endpoints: [], full: true })
1777
+ this.toBridges(name, { machine: name, endpoints: [], full: true, released: true })
1778
1778
  this.dash({ type: "endpoints", at: new Date(this.now()).toISOString(), machine: name })
1779
1779
  this.deps.log?.("relay_action", { account: this.account, machine: name, method: "machine_release", code: "ok" })
1780
1780
  return { removed: name, endpoints: dropped }
@@ -1812,7 +1812,7 @@ export class RelayHub {
1812
1812
  core.record("machine_expire", null, name, { machine: name, endpoints: count })
1813
1813
  return count
1814
1814
  })
1815
- this.toBridges(name, { machine: name, endpoints: [], full: true })
1815
+ this.toBridges(name, { machine: name, endpoints: [], full: true, released: true })
1816
1816
  this.deps.log?.("relay_action", { account: this.account, machine: name, method: "machine_expire", code: "ok", endpoints: dropped })
1817
1817
  }
1818
1818
  }
@@ -1831,7 +1831,7 @@ export class RelayHub {
1831
1831
  for (const name of unbound) this.relay.record("machine_forget", null, name, { machine: name, endpoints: this.relay.dropMachine(name, { queue: true }) })
1832
1832
  return unbound
1833
1833
  })
1834
- for (const name of forgotten) this.toBridges(name, { machine: name, endpoints: [], full: true })
1834
+ for (const name of forgotten) this.toBridges(name, { machine: name, endpoints: [], full: true, released: true })
1835
1835
  }
1836
1836
 
1837
1837
  /** the hub_state keys (`denied:<tokenId>`) of tokens revoked lately, to when; loaded once per object lifetime */