@scp-dev/hub 0.1.1 → 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.
package/dist/index.d.ts CHANGED
@@ -58,6 +58,8 @@ interface MirrorView extends MirrorScope {
58
58
  presence?: Presence;
59
59
  last_seen?: string | null;
60
60
  title?: string | null;
61
+ /** the handle the endpoint had before a rename: its mirror is renamed in place */
62
+ was?: string;
61
63
  }
62
64
  interface RemoteView extends Omit<EndpointView, "depth">, MirrorScope {
63
65
  machine: string;
@@ -95,6 +97,8 @@ interface MirrorChange {
95
97
  added: boolean;
96
98
  presence: boolean;
97
99
  status: boolean;
100
+ /** the handle a renamed mirror had */
101
+ was?: string;
98
102
  }
99
103
  interface Delivery {
100
104
  seq: number;
@@ -110,6 +114,8 @@ interface Delivery {
110
114
  declare class FederatedCore extends RelayCore {
111
115
  readonly machine: string | null;
112
116
  private aliases;
117
+ private account;
118
+ private others;
113
119
  private scope;
114
120
  private readonly defaultAuth;
115
121
  private readonly endpointCap;
@@ -122,6 +128,12 @@ declare class FederatedCore extends RelayCore {
122
128
  /** with xinbox, a delivery takes the next seq past xinbox's too, so a bridge's replay and ack cursor stay one space */
123
129
  protected insertDelivery(messageId: string, endpoint: string, expiresAt: number): number;
124
130
  setAliases(aliases: string[]): void;
131
+ /** the account handle another member's machines render under, `<account>/<machine>/<handle>`; this member's own stay `me/<machine>/<handle>` */
132
+ setOthers(account: string, machines: Iterable<string>): void;
133
+ /** whether `machine` is another member's: it renders under the account handle */
134
+ setOther(machine: string, other: boolean): void;
135
+ /** how this host renders `<machine>/<handle>` of its own account */
136
+ remoteAddress(remote: string): string;
125
137
  /** runs `fn` with unqualified handles resolving on `machine`: a direct client's own namespace on the hub */
126
138
  within<T>(machine: string | null, fn: () => T): T;
127
139
  /** the hub-side address (`<machine>/<handle>`) an address names, another account's as it is, or null when it is on this machine */
@@ -406,6 +418,40 @@ interface MachineRow {
406
418
  created_at: string | null;
407
419
  last_seen: string | null;
408
420
  }
421
+ /** an endpoint as the dashboard graph draws it */
422
+ interface GraphNode {
423
+ address: string;
424
+ kind: EndpointKind;
425
+ harness: string | null;
426
+ title: string | null;
427
+ presence: Presence;
428
+ status: EndpointView["status"];
429
+ last_seen: string | null;
430
+ parent: string | null;
431
+ wake?: string | null;
432
+ }
433
+ /** `spawn`: parent to child; `message`: sender to recipient, with the count and newest send in the graph's window */
434
+ type GraphEdge = {
435
+ kind: "spawn";
436
+ from: string;
437
+ to: string;
438
+ } | {
439
+ kind: "message";
440
+ from: string;
441
+ to: string;
442
+ count: number;
443
+ last_at: string;
444
+ };
445
+ interface HubGraph {
446
+ /** the window message edges count over */
447
+ minutes: number;
448
+ machines: MachineRow[];
449
+ nodes: GraphNode[];
450
+ edges: GraphEdge[];
451
+ }
452
+ /** the default and widest window of message edges; the core keeps an hour of sender and recipient pairs */
453
+ declare const GRAPH_MINUTES = 15;
454
+ declare const GRAPH_MINUTES_MAX = 60;
409
455
  interface HubSocket {
410
456
  conn: HubConn;
411
457
  send(text: string): void;
@@ -568,7 +614,10 @@ declare class RelayHub {
568
614
  private announce;
569
615
  /** a direct client's endpoint changed: the other bridges mirror it */
570
616
  private announceEndpoint;
617
+ /** `own` tells each bridge whether a bound machine is its member's own device */
571
618
  private toBridges;
619
+ /** the machines of other members than the grant's, which a bridge shows under the account handle */
620
+ private othersOf;
572
621
  private fleet;
573
622
  private write;
574
623
  private save;
@@ -589,8 +638,13 @@ declare class RelayHub {
589
638
  endpoints: EndpointView[];
590
639
  activity: ActivityRow[];
591
640
  };
641
+ /** a read-only graph for a dashboard: listed endpoints, spawn edges among them, and message counts per sender and recipient; never a body */
642
+ graph(minutes?: number): HubGraph;
592
643
  private machineList;
593
- /** only the token the machine is bound to, a full login of the member who bound it, or an admin may release it (or a revoked token's name) */
644
+ /**
645
+ * only the token the machine is bound to, a full login of the member who bound it, or an admin may release it (or a revoked token's name);
646
+ * while no connection holds a bound machine, any credential of that member may too, so a rotated token releases what the old one bound
647
+ */
594
648
  private releaseMachine;
595
649
  /** releases client bindings with no live connection, idle for the `clientMachineIdleS` limit, with nothing queued and no unanswered ask open; bridge bindings never expire */
596
650
  private expireMachines;
@@ -614,4 +668,4 @@ declare class RelayHub {
614
668
  xnotice(endpoint: string, text: string): void;
615
669
  }
616
670
 
617
- export { type Delivery, FederatedCore, type FederatedOptions, HUB_LIMITS, type HubAuth, type HubConn, type HubDeps, type HubGrant, type HubLimits, type HubRole, type HubSocket, MACHINE_NAME, type MachineRow, type MirrorCaps, type MirrorChange, type MirrorRules, type MirrorScope, type MirrorView, type RelayAuth, RelayHub, type RemoteView, type XEndpoint };
671
+ export { type Delivery, FederatedCore, type FederatedOptions, GRAPH_MINUTES, GRAPH_MINUTES_MAX, type GraphEdge, type GraphNode, HUB_LIMITS, type HubAuth, type HubConn, type HubDeps, type HubGrant, type HubGraph, type HubLimits, type HubRole, type HubSocket, MACHINE_NAME, type MachineRow, type MirrorCaps, type MirrorChange, type MirrorRules, type MirrorScope, type MirrorView, type RelayAuth, RelayHub, type RemoteView, type XEndpoint };
package/dist/index.js CHANGED
@@ -61,6 +61,8 @@ var handlePart = (remote) => remote.slice(remote.indexOf("/") + 1);
61
61
  var FederatedCore = class extends RelayCore {
62
62
  machine;
63
63
  aliases;
64
+ account = null;
65
+ others = /* @__PURE__ */ new Set();
64
66
  scope = null;
65
67
  defaultAuth;
66
68
  endpointCap;
@@ -102,6 +104,20 @@ var FederatedCore = class extends RelayCore {
102
104
  setAliases(aliases) {
103
105
  this.aliases = new Set(aliases);
104
106
  }
107
+ /** the account handle another member's machines render under, `<account>/<machine>/<handle>`; this member's own stay `me/<machine>/<handle>` */
108
+ setOthers(account, machines) {
109
+ this.account = account;
110
+ this.others = new Set(machines);
111
+ }
112
+ /** whether `machine` is another member's: it renders under the account handle */
113
+ setOther(machine, other) {
114
+ if (other) this.others.add(machine);
115
+ else this.others.delete(machine);
116
+ }
117
+ /** how this host renders `<machine>/<handle>` of its own account */
118
+ remoteAddress(remote) {
119
+ return `${this.account && this.others.has(machinePart(remote)) ? this.account : this.user}/${remote}`;
120
+ }
105
121
  /** runs `fn` with unqualified handles resolving on `machine`: a direct client's own namespace on the hub */
106
122
  within(machine, fn) {
107
123
  const previous = this.scope;
@@ -159,7 +175,7 @@ var FederatedCore = class extends RelayCore {
159
175
  return { account, machine, handle: handle === "me" ? "" : handle };
160
176
  }
161
177
  isLocalUser(part) {
162
- return part === "me" || part === this.user || this.aliases.has(part);
178
+ return part === "me" || part === "local" || part === this.user || this.aliases.has(part);
163
179
  }
164
180
  /** whether an endpoint of another account `part` ever reached this relay, so `<part>/me` is that account's human */
165
181
  knownAccount(part) {
@@ -198,7 +214,7 @@ var FederatedCore = class extends RelayCore {
198
214
  return row;
199
215
  }
200
216
  addressUsers() {
201
- return [this.user, "me", ...this.aliases];
217
+ return [this.user, "me", "local", ...this.aliases];
202
218
  }
203
219
  localRow(row) {
204
220
  return !row.remote;
@@ -206,7 +222,7 @@ var FederatedCore = class extends RelayCore {
206
222
  address(row) {
207
223
  const remote = row.remote;
208
224
  if (remote && isForeign(remote)) return remote.slice(1);
209
- return remote ? `${this.user}/${remote}` : super.address(row);
225
+ return remote ? this.remoteAddress(remote) : super.address(row);
210
226
  }
211
227
  subject(row) {
212
228
  const remote = row.remote;
@@ -342,10 +358,17 @@ var FederatedCore = class extends RelayCore {
342
358
  const changes = [];
343
359
  const seen = options.seen ?? /* @__PURE__ */ new Set();
344
360
  const batch = [];
361
+ const renamed = /* @__PURE__ */ new Map();
345
362
  for (const view of parsed) {
346
363
  const remote = `${machine}/${view.handle}`;
347
364
  seen.add(remote);
348
- const before = this.byRemote(remote);
365
+ let before = this.byRemote(remote);
366
+ const old = !before && view.was !== null ? this.byRemote(`${machine}/${view.was}`) : void 0;
367
+ if (old && old.token_hash === null) {
368
+ this.sql.all(`UPDATE endpoints SET remote = ? WHERE id = ?`, remote, old.id);
369
+ before = this.byId(old.id);
370
+ renamed.set(old.id, view.was);
371
+ }
349
372
  if (before && before.token_hash !== null) continue;
350
373
  if (!before) this.checkCap();
351
374
  batch.push({ view, id: before?.id ?? this.insertRemote(remote, view.kind, view.harness, auth, now), before });
@@ -370,14 +393,17 @@ var FederatedCore = class extends RelayCore {
370
393
  ...view.kind === "agent" ? this.scopeColumns(machine, view.scope, row, place(view.handle)) : {}
371
394
  };
372
395
  const columns = Object.entries(next).filter(([column, value]) => row[column] !== value);
373
- if (!columns.length) continue;
374
- this.sql.all(
375
- `UPDATE endpoints SET ${columns.map(([column]) => `${column} = ?`).join(", ")} WHERE id = ?`,
376
- ...columns.map(([, value]) => value),
377
- id
378
- );
396
+ const was = renamed.get(id);
397
+ if (!columns.length && was === void 0) continue;
398
+ if (columns.length)
399
+ this.sql.all(
400
+ `UPDATE endpoints SET ${columns.map(([column]) => `${column} = ?`).join(", ")} WHERE id = ?`,
401
+ ...columns.map(([, value]) => value),
402
+ id
403
+ );
379
404
  const change = this.diff(before, this.byId(id));
380
- if (change) changes.push(change);
405
+ if (was !== void 0) changes.push({ ...change ?? { view: this.remoteView(this.byId(id)), added: false, presence: true, status: false }, was });
406
+ else if (change) changes.push(change);
381
407
  }
382
408
  if (options.full) {
383
409
  const missing = this.sql.all(
@@ -1229,7 +1255,8 @@ var FederatedCore = class extends RelayCore {
1229
1255
  status,
1230
1256
  presence,
1231
1257
  lastSeen: Number.isFinite(parsedSeen) ? parsedSeen : null,
1232
- scope: kind === "agent" ? parseScope(view) : {}
1258
+ scope: kind === "agent" ? parseScope(view) : {},
1259
+ was: kind === "agent" && typeof view.was === "string" && validHandle(view.was) && view.was !== handle ? view.was : null
1233
1260
  };
1234
1261
  const leaked = [handle, out.model, out.cwd, out.labels, status?.note, out.scope.title].map((v) => v ? findSecret(v) : null).find(Boolean);
1235
1262
  if (leaked) throw new RelayError("secret_refused", `an endpoint looks like it carries a credential (${leaked})`);
@@ -1501,6 +1528,8 @@ import {
1501
1528
  XLIMITS,
1502
1529
  xlimits
1503
1530
  } from "@scp-dev/core";
1531
+ var GRAPH_MINUTES = 15;
1532
+ var GRAPH_MINUTES_MAX = 60;
1504
1533
  var HUB_LIMITS = {
1505
1534
  machines: 16,
1506
1535
  endpoints: 512,
@@ -1969,8 +1998,8 @@ var RelayHub = class _RelayHub {
1969
1998
  serverFields(params);
1970
1999
  const observe = params.observe === true;
1971
2000
  const named = params.machine;
1972
- if ((!observe || named !== void 0) && (typeof named !== "string" || !MACHINE_NAME.test(named) || named === "me" || named === this.account)) {
1973
- throw new RelayError2("invalid_params", "machine is a name of lowercase letters, digits and -, other than me and the account handle");
2001
+ if ((!observe || named !== void 0) && (typeof named !== "string" || !MACHINE_NAME.test(named) || named === "me" || named === "local" || named === this.account)) {
2002
+ throw new RelayError2("invalid_params", "machine is a name of lowercase letters, digits and -, other than me, local and the account handle");
1974
2003
  }
1975
2004
  const bridge = params.bridge === true;
1976
2005
  const human = params.human === true;
@@ -2015,7 +2044,7 @@ var RelayHub = class _RelayHub {
2015
2044
  this.release(other, true);
2016
2045
  }
2017
2046
  core.record("bridge", null, machine, { machine });
2018
- return { ...base, endpoints: core.remoteViews({ except: machine }) };
2047
+ return { ...base, endpoints: core.remoteViews({ except: machine }), others: this.othersOf(conn.grant, machine) };
2019
2048
  }
2020
2049
  if (one2(this.deps.sql, `SELECT kind FROM machines WHERE name = ?`, machine)?.kind === "bridge") {
2021
2050
  throw new RelayError2(
@@ -2585,7 +2614,7 @@ var RelayHub = class _RelayHub {
2585
2614
  /** a machine's endpoint changes: `endpoints` to the other bridges, presence and status to watchers */
2586
2615
  announce(machine, changes, full) {
2587
2616
  if (full || changes.length) {
2588
- const endpoints = full ? this.relay.remoteRowsOf(machine) : changes.map((c) => c.view);
2617
+ const endpoints = full ? this.relay.remoteRowsOf(machine) : changes.map((c) => c.was === void 0 ? c.view : { ...c.view, was: c.was });
2589
2618
  this.toBridges(machine, { machine, endpoints, full });
2590
2619
  }
2591
2620
  for (const change of changes) {
@@ -2602,11 +2631,19 @@ var RelayHub = class _RelayHub {
2602
2631
  const view = this.relay.remoteRowsOf(machine).find((v) => v.id === endpoint);
2603
2632
  if (view) this.toBridges(machine, { machine, endpoints: [view], full: false });
2604
2633
  }
2634
+ /** `own` tells each bridge whether a bound machine is its member's own device */
2605
2635
  toBridges(machine, params) {
2636
+ const bound = one2(this.deps.sql, `SELECT actor_id FROM machines WHERE name = ?`, machine);
2606
2637
  for (const socket of this.deps.sockets()) {
2607
- if (socket.conn.role === "bridge" && socket.conn.machine !== machine) this.write(socket, { jsonrpc: "2.0", method: "endpoints", params });
2638
+ if (socket.conn.role !== "bridge" || socket.conn.machine === machine) continue;
2639
+ const own = bound ? { own: (bound.actor_id ?? null) === (socket.conn.grant.actorId ?? null) } : {};
2640
+ this.write(socket, { jsonrpc: "2.0", method: "endpoints", params: { ...params, ...own } });
2608
2641
  }
2609
2642
  }
2643
+ /** the machines of other members than the grant's, which a bridge shows under the account handle */
2644
+ othersOf(grant, except) {
2645
+ return this.deps.sql.all(`SELECT name FROM machines WHERE name != ? AND actor_id IS NOT ?`, except, grant.actorId ?? null).map((row) => row.name);
2646
+ }
2610
2647
  fleet(event) {
2611
2648
  const at = new Date(this.now()).toISOString();
2612
2649
  for (const socket of this.deps.sockets()) if (socket.conn.watching) this.write(socket, { jsonrpc: "2.0", method: "fleet", params: { at, ...event } });
@@ -2726,6 +2763,38 @@ var RelayHub = class _RelayHub {
2726
2763
  const { endpoints, activity: rows } = this.relay.snapshot({ activity });
2727
2764
  return { machines: this.machineList(), endpoints, activity: rows };
2728
2765
  }
2766
+ /** a read-only graph for a dashboard: listed endpoints, spawn edges among them, and message counts per sender and recipient; never a body */
2767
+ graph(minutes = GRAPH_MINUTES) {
2768
+ const window = Math.min(Math.max(1, Math.floor(Number.isFinite(minutes) ? minutes : GRAPH_MINUTES)), GRAPH_MINUTES_MAX);
2769
+ const endpoints = this.relay.list();
2770
+ const byId = new Map(endpoints.map((e) => [e.id, e.address]));
2771
+ const listed = new Set(byId.values());
2772
+ const nodes = endpoints.map((e) => ({
2773
+ address: e.address,
2774
+ kind: e.kind,
2775
+ harness: e.harness,
2776
+ title: e.title,
2777
+ presence: e.presence,
2778
+ status: e.status,
2779
+ last_seen: e.last_seen,
2780
+ parent: e.parent ?? null,
2781
+ ...e.wake !== void 0 ? { wake: e.wake } : {}
2782
+ }));
2783
+ const edges = nodes.flatMap(
2784
+ (n) => n.parent && n.parent !== n.address && listed.has(n.parent) ? [{ kind: "spawn", from: n.parent, to: n.address }] : []
2785
+ );
2786
+ const pairs = this.deps.sql.all(
2787
+ `SELECT from_ep, to_ep, COUNT(*) AS n, MAX(at) AS last FROM pair_events WHERE at > ? GROUP BY from_ep, to_ep`,
2788
+ this.now() - window * 6e4
2789
+ );
2790
+ const traffic = pairs.flatMap((row) => {
2791
+ const from = byId.get(row.from_ep);
2792
+ const to = byId.get(row.to_ep);
2793
+ return from && to && from !== to ? [{ kind: "message", from, to, count: row.n, last_at: iso(row.last) }] : [];
2794
+ });
2795
+ edges.push(...traffic.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to)));
2796
+ return { minutes: window, machines: this.machineList(), nodes, edges };
2797
+ }
2729
2798
  machineList() {
2730
2799
  const sockets = this.deps.sockets().filter((s) => !this.gone.has(s.conn.id));
2731
2800
  const bound = this.deps.sql.all(`SELECT name, kind, created_at, last_seen FROM machines ORDER BY name`).map((row) => ({
@@ -2739,7 +2808,10 @@ var RelayHub = class _RelayHub {
2739
2808
  const revoked = this.deps.sql.all(`SELECT name, at FROM machine_tombstones WHERE name NOT IN (SELECT name FROM machines) ORDER BY name`).map((row) => ({ name: row.name, kind: "revoked", online: false, bridge: false, created_at: null, last_seen: iso(row.at) }));
2740
2809
  return [...bound, ...revoked];
2741
2810
  }
2742
- /** only the token the machine is bound to, a full login of the member who bound it, or an admin may release it (or a revoked token's name) */
2811
+ /**
2812
+ * only the token the machine is bound to, a full login of the member who bound it, or an admin may release it (or a revoked token's name);
2813
+ * while no connection holds a bound machine, any credential of that member may too, so a rotated token releases what the old one bound
2814
+ */
2743
2815
  releaseMachine(caller, name) {
2744
2816
  const core = this.relay;
2745
2817
  if (typeof name !== "string" || !MACHINE_NAME.test(name)) throw new RelayError2("invalid_params", "name is a machine name");
@@ -2747,11 +2819,12 @@ var RelayHub = class _RelayHub {
2747
2819
  const tomb = bound ? void 0 : one2(this.deps.sql, `SELECT actor_id FROM machine_tombstones WHERE name = ?`, name);
2748
2820
  if (!bound && !tomb) throw new RelayError2("not_found", `no machine ${name}`);
2749
2821
  const grant = caller.conn.grant;
2750
- if (!(bound?.token_id === grant.tokenId || fullOf(grant, (bound ?? tomb).actor_id) || accountAdmin(grant))) {
2822
+ const member = !!bound && bound.actor_id !== null && grant.actorId === bound.actor_id;
2823
+ if (!(bound?.token_id === grant.tokenId || fullOf(grant, (bound ?? tomb).actor_id) || accountAdmin(grant) || member && !this.liveMachines().has(name))) {
2751
2824
  core.record("refuse", null, name, { code: "unauthorized", machine: name });
2752
2825
  throw new RelayError2(
2753
2826
  "unauthorized",
2754
- `only the token machine ${name} is bound to, a full login of the member who bound it, or an org owner or admin can release it`
2827
+ `only the token machine ${name} is bound to, a full login of the member who bound it, or an org owner or admin can release it${member ? "; any credential of that member can once it is offline" : ""}`
2755
2828
  );
2756
2829
  }
2757
2830
  for (const socket of [...this.deps.sockets()]) {
@@ -2872,6 +2945,8 @@ var RelayHub = class _RelayHub {
2872
2945
  };
2873
2946
  export {
2874
2947
  FederatedCore,
2948
+ GRAPH_MINUTES,
2949
+ GRAPH_MINUTES_MAX,
2875
2950
  HUB_LIMITS,
2876
2951
  MACHINE_NAME,
2877
2952
  RelayHub
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/hub",
3
- "version": "0.1.1",
3
+ "version": "0.2.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.1"
32
+ "@scp-dev/core": "0.1.2"
33
33
  },
34
34
  "devDependencies": {
35
35
  "tsup": "^8.5.1"
package/src/federation.ts CHANGED
@@ -121,6 +121,8 @@ export interface MirrorView extends MirrorScope {
121
121
  presence?: Presence
122
122
  last_seen?: string | null
123
123
  title?: string | null
124
+ /** the handle the endpoint had before a rename: its mirror is renamed in place */
125
+ was?: string
124
126
  }
125
127
 
126
128
  export interface RemoteView extends Omit<EndpointView, "depth">, MirrorScope {
@@ -154,6 +156,7 @@ type ParsedView = {
154
156
  presence: Presence
155
157
  lastSeen: number | null
156
158
  scope: ParsedScope
159
+ was: string | null
157
160
  }
158
161
 
159
162
  const UNKNOWN: Placement = { tree: null, parent_ep: null, depth: null }
@@ -213,6 +216,8 @@ export interface MirrorChange {
213
216
  added: boolean
214
217
  presence: boolean
215
218
  status: boolean
219
+ /** the handle a renamed mirror had */
220
+ was?: string
216
221
  }
217
222
 
218
223
  export interface Delivery {
@@ -250,6 +255,8 @@ const handlePart = (remote: string): string => remote.slice(remote.indexOf("/")
250
255
  export class FederatedCore extends RelayCore {
251
256
  readonly machine: string | null
252
257
  private aliases: Set<string>
258
+ private account: string | null = null
259
+ private others = new Set<string>()
253
260
  private scope: string | null = null
254
261
  private readonly defaultAuth: RelayAuth
255
262
  private readonly endpointCap: number | null
@@ -296,6 +303,23 @@ export class FederatedCore extends RelayCore {
296
303
  this.aliases = new Set(aliases)
297
304
  }
298
305
 
306
+ /** the account handle another member's machines render under, `<account>/<machine>/<handle>`; this member's own stay `me/<machine>/<handle>` */
307
+ setOthers(account: string, machines: Iterable<string>): void {
308
+ this.account = account
309
+ this.others = new Set(machines)
310
+ }
311
+
312
+ /** whether `machine` is another member's: it renders under the account handle */
313
+ setOther(machine: string, other: boolean): void {
314
+ if (other) this.others.add(machine)
315
+ else this.others.delete(machine)
316
+ }
317
+
318
+ /** how this host renders `<machine>/<handle>` of its own account */
319
+ remoteAddress(remote: string): string {
320
+ return `${this.account && this.others.has(machinePart(remote)) ? this.account : this.user}/${remote}`
321
+ }
322
+
299
323
  /** runs `fn` with unqualified handles resolving on `machine`: a direct client's own namespace on the hub */
300
324
  within<T>(machine: string | null, fn: () => T): T {
301
325
  const previous = this.scope
@@ -361,7 +385,7 @@ export class FederatedCore extends RelayCore {
361
385
  }
362
386
 
363
387
  private isLocalUser(part: string): boolean {
364
- return part === "me" || part === this.user || this.aliases.has(part)
388
+ return part === "me" || part === "local" || part === this.user || this.aliases.has(part)
365
389
  }
366
390
 
367
391
  /** whether an endpoint of another account `part` ever reached this relay, so `<part>/me` is that account's human */
@@ -408,7 +432,7 @@ export class FederatedCore extends RelayCore {
408
432
  }
409
433
 
410
434
  protected override addressUsers(): string[] {
411
- return [this.user, "me", ...this.aliases]
435
+ return [this.user, "me", "local", ...this.aliases]
412
436
  }
413
437
 
414
438
  protected override localRow(row: EndpointRow): boolean {
@@ -418,7 +442,7 @@ export class FederatedCore extends RelayCore {
418
442
  protected override address(row: EndpointRow): string {
419
443
  const remote = (row as FederatedRow).remote
420
444
  if (remote && isForeign(remote)) return remote.slice(1)
421
- return remote ? `${this.user}/${remote}` : super.address(row)
445
+ return remote ? this.remoteAddress(remote) : super.address(row)
422
446
  }
423
447
 
424
448
  protected override subject(row: EndpointRow): PolicySubject {
@@ -564,10 +588,17 @@ export class FederatedCore extends RelayCore {
564
588
  const changes: MirrorChange[] = []
565
589
  const seen = options.seen ?? new Set<string>()
566
590
  const batch: { view: ParsedView; id: string; before: FederatedRow | undefined }[] = []
591
+ const renamed = new Map<string, string>()
567
592
  for (const view of parsed) {
568
593
  const remote = `${machine}/${view.handle}`
569
594
  seen.add(remote)
570
- const before = this.byRemote(remote)
595
+ let before = this.byRemote(remote)
596
+ const old = !before && view.was !== null ? this.byRemote(`${machine}/${view.was}`) : undefined
597
+ if (old && old.token_hash === null) {
598
+ this.sql.all(`UPDATE endpoints SET remote = ? WHERE id = ?`, remote, old.id)
599
+ before = this.byId(old.id) as FederatedRow
600
+ renamed.set(old.id, view.was!)
601
+ }
571
602
  if (before && before.token_hash !== null) continue
572
603
  if (!before) this.checkCap()
573
604
  batch.push({ view, id: before?.id ?? this.insertRemote(remote, view.kind, view.harness, auth, now), before })
@@ -592,14 +623,17 @@ export class FederatedCore extends RelayCore {
592
623
  ...(view.kind === "agent" ? this.scopeColumns(machine, view.scope, row, place(view.handle)) : {}),
593
624
  }
594
625
  const columns = Object.entries(next).filter(([column, value]) => row[column as keyof FederatedRow] !== value)
595
- if (!columns.length) continue
596
- this.sql.all(
597
- `UPDATE endpoints SET ${columns.map(([column]) => `${column} = ?`).join(", ")} WHERE id = ?`,
598
- ...columns.map(([, value]) => value as SqlValue),
599
- id,
600
- )
626
+ const was = renamed.get(id)
627
+ if (!columns.length && was === undefined) continue
628
+ if (columns.length)
629
+ this.sql.all(
630
+ `UPDATE endpoints SET ${columns.map(([column]) => `${column} = ?`).join(", ")} WHERE id = ?`,
631
+ ...columns.map(([, value]) => value as SqlValue),
632
+ id,
633
+ )
601
634
  const change = this.diff(before, this.byId(id)!)
602
- if (change) changes.push(change)
635
+ if (was !== undefined) changes.push({ ...(change ?? { view: this.remoteView(this.byId(id)!), added: false, presence: true, status: false }), was })
636
+ else if (change) changes.push(change)
603
637
  }
604
638
  if (options.full) {
605
639
  const missing = this.sql.all<FederatedRow>(
@@ -1546,6 +1580,7 @@ export class FederatedCore extends RelayCore {
1546
1580
  presence,
1547
1581
  lastSeen: Number.isFinite(parsedSeen) ? parsedSeen : null,
1548
1582
  scope: kind === "agent" ? parseScope(view) : {},
1583
+ was: kind === "agent" && typeof view.was === "string" && validHandle(view.was) && view.was !== handle ? view.was : null,
1549
1584
  }
1550
1585
  const leaked = [handle, out.model, out.cwd, out.labels, status?.note, out.scope.title].map((v) => (v ? findSecret(v) : null)).find(Boolean)
1551
1586
  if (leaked) throw new RelayError("secret_refused", `an endpoint looks like it carries a credential (${leaked})`)
package/src/hub.ts CHANGED
@@ -92,6 +92,34 @@ export interface MachineRow {
92
92
  last_seen: string | null
93
93
  }
94
94
 
95
+ /** an endpoint as the dashboard graph draws it */
96
+ export interface GraphNode {
97
+ address: string
98
+ kind: EndpointKind
99
+ harness: string | null
100
+ title: string | null
101
+ presence: Presence
102
+ status: EndpointView["status"]
103
+ last_seen: string | null
104
+ parent: string | null
105
+ wake?: string | null
106
+ }
107
+
108
+ /** `spawn`: parent to child; `message`: sender to recipient, with the count and newest send in the graph's window */
109
+ export type GraphEdge = { kind: "spawn"; from: string; to: string } | { kind: "message"; from: string; to: string; count: number; last_at: string }
110
+
111
+ export interface HubGraph {
112
+ /** the window message edges count over */
113
+ minutes: number
114
+ machines: MachineRow[]
115
+ nodes: GraphNode[]
116
+ edges: GraphEdge[]
117
+ }
118
+
119
+ /** the default and widest window of message edges; the core keeps an hour of sender and recipient pairs */
120
+ export const GRAPH_MINUTES = 15
121
+ export const GRAPH_MINUTES_MAX = 60
122
+
95
123
  export interface HubSocket {
96
124
  conn: HubConn
97
125
  send(text: string): void
@@ -641,8 +669,11 @@ export class RelayHub {
641
669
  serverFields(params)
642
670
  const observe = params.observe === true
643
671
  const named = params.machine
644
- if ((!observe || named !== undefined) && (typeof named !== "string" || !MACHINE_NAME.test(named) || named === "me" || named === this.account)) {
645
- throw new RelayError("invalid_params", "machine is a name of lowercase letters, digits and -, other than me and the account handle")
672
+ if (
673
+ (!observe || named !== undefined) &&
674
+ (typeof named !== "string" || !MACHINE_NAME.test(named) || named === "me" || named === "local" || named === this.account)
675
+ ) {
676
+ throw new RelayError("invalid_params", "machine is a name of lowercase letters, digits and -, other than me, local and the account handle")
646
677
  }
647
678
  const bridge = params.bridge === true
648
679
  const human = params.human === true
@@ -690,7 +721,7 @@ export class RelayHub {
690
721
  this.release(other, true)
691
722
  }
692
723
  core.record("bridge", null, machine, { machine })
693
- return { ...base, endpoints: core.remoteViews({ except: machine }) }
724
+ return { ...base, endpoints: core.remoteViews({ except: machine }), others: this.othersOf(conn.grant, machine) }
694
725
  }
695
726
 
696
727
  if (one<{ kind: string }>(this.deps.sql, `SELECT kind FROM machines WHERE name = ?`, machine)?.kind === "bridge") {
@@ -1301,7 +1332,7 @@ export class RelayHub {
1301
1332
  /** a machine's endpoint changes: `endpoints` to the other bridges, presence and status to watchers */
1302
1333
  private announce(machine: string, changes: MirrorChange[], full: boolean): void {
1303
1334
  if (full || changes.length) {
1304
- const endpoints = full ? this.relay.remoteRowsOf(machine) : changes.map((c) => c.view)
1335
+ const endpoints = full ? this.relay.remoteRowsOf(machine) : changes.map((c) => (c.was === undefined ? c.view : { ...c.view, was: c.was }))
1305
1336
  this.toBridges(machine, { machine, endpoints, full })
1306
1337
  }
1307
1338
  for (const change of changes) {
@@ -1320,12 +1351,23 @@ export class RelayHub {
1320
1351
  if (view) this.toBridges(machine, { machine, endpoints: [view], full: false })
1321
1352
  }
1322
1353
 
1323
- private toBridges(machine: string, params: unknown): void {
1354
+ /** `own` tells each bridge whether a bound machine is its member's own device */
1355
+ private toBridges(machine: string, params: Record<string, unknown>): void {
1356
+ const bound = one<{ actor_id: string | null }>(this.deps.sql, `SELECT actor_id FROM machines WHERE name = ?`, machine)
1324
1357
  for (const socket of this.deps.sockets()) {
1325
- if (socket.conn.role === "bridge" && socket.conn.machine !== machine) this.write(socket, { jsonrpc: "2.0", method: "endpoints", params })
1358
+ if (socket.conn.role !== "bridge" || socket.conn.machine === machine) continue
1359
+ const own = bound ? { own: (bound.actor_id ?? null) === (socket.conn.grant.actorId ?? null) } : {}
1360
+ this.write(socket, { jsonrpc: "2.0", method: "endpoints", params: { ...params, ...own } })
1326
1361
  }
1327
1362
  }
1328
1363
 
1364
+ /** the machines of other members than the grant's, which a bridge shows under the account handle */
1365
+ private othersOf(grant: HubGrant, except: string): string[] {
1366
+ return this.deps.sql
1367
+ .all<{ name: string }>(`SELECT name FROM machines WHERE name != ? AND actor_id IS NOT ?`, except, grant.actorId ?? null)
1368
+ .map((row) => row.name)
1369
+ }
1370
+
1329
1371
  private fleet(event: Record<string, unknown>): void {
1330
1372
  const at = new Date(this.now()).toISOString()
1331
1373
  for (const socket of this.deps.sockets()) if (socket.conn.watching) this.write(socket, { jsonrpc: "2.0", method: "fleet", params: { at, ...event } })
@@ -1459,6 +1501,39 @@ export class RelayHub {
1459
1501
  return { machines: this.machineList(), endpoints, activity: rows }
1460
1502
  }
1461
1503
 
1504
+ /** a read-only graph for a dashboard: listed endpoints, spawn edges among them, and message counts per sender and recipient; never a body */
1505
+ graph(minutes = GRAPH_MINUTES): HubGraph {
1506
+ const window = Math.min(Math.max(1, Math.floor(Number.isFinite(minutes) ? minutes : GRAPH_MINUTES)), GRAPH_MINUTES_MAX)
1507
+ const endpoints = this.relay.list() as (EndpointView & { wake?: string | null })[]
1508
+ const byId = new Map(endpoints.map((e) => [e.id, e.address]))
1509
+ const listed = new Set(byId.values())
1510
+ const nodes: GraphNode[] = endpoints.map((e) => ({
1511
+ address: e.address,
1512
+ kind: e.kind,
1513
+ harness: e.harness,
1514
+ title: e.title,
1515
+ presence: e.presence,
1516
+ status: e.status,
1517
+ last_seen: e.last_seen,
1518
+ parent: e.parent ?? null,
1519
+ ...(e.wake !== undefined ? { wake: e.wake } : {}),
1520
+ }))
1521
+ const edges: GraphEdge[] = nodes.flatMap((n) =>
1522
+ n.parent && n.parent !== n.address && listed.has(n.parent) ? [{ kind: "spawn" as const, from: n.parent, to: n.address }] : [],
1523
+ )
1524
+ const pairs = this.deps.sql.all<{ from_ep: string; to_ep: string; n: number; last: number }>(
1525
+ `SELECT from_ep, to_ep, COUNT(*) AS n, MAX(at) AS last FROM pair_events WHERE at > ? GROUP BY from_ep, to_ep`,
1526
+ this.now() - window * 60_000,
1527
+ )
1528
+ const traffic: GraphEdge[] = pairs.flatMap((row) => {
1529
+ const from = byId.get(row.from_ep)
1530
+ const to = byId.get(row.to_ep)
1531
+ return from && to && from !== to ? [{ kind: "message" as const, from, to, count: row.n, last_at: iso(row.last)! }] : []
1532
+ })
1533
+ edges.push(...traffic.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to)))
1534
+ return { minutes: window, machines: this.machineList(), nodes, edges }
1535
+ }
1536
+
1462
1537
  private machineList(): MachineRow[] {
1463
1538
  const sockets = this.deps.sockets().filter((s) => !this.gone.has(s.conn.id))
1464
1539
  const bound = this.deps.sql
@@ -1477,7 +1552,10 @@ export class RelayHub {
1477
1552
  return [...bound, ...revoked]
1478
1553
  }
1479
1554
 
1480
- /** only the token the machine is bound to, a full login of the member who bound it, or an admin may release it (or a revoked token's name) */
1555
+ /**
1556
+ * only the token the machine is bound to, a full login of the member who bound it, or an admin may release it (or a revoked token's name);
1557
+ * while no connection holds a bound machine, any credential of that member may too, so a rotated token releases what the old one bound
1558
+ */
1481
1559
  private releaseMachine(caller: HubSocket, name: unknown): unknown {
1482
1560
  const core = this.relay
1483
1561
  if (typeof name !== "string" || !MACHINE_NAME.test(name)) throw new RelayError("invalid_params", "name is a machine name")
@@ -1485,11 +1563,12 @@ export class RelayHub {
1485
1563
  const tomb = bound ? undefined : one<{ actor_id: string | null }>(this.deps.sql, `SELECT actor_id FROM machine_tombstones WHERE name = ?`, name)
1486
1564
  if (!bound && !tomb) throw new RelayError("not_found", `no machine ${name}`)
1487
1565
  const grant = caller.conn.grant
1488
- if (!(bound?.token_id === grant.tokenId || fullOf(grant, (bound ?? tomb)!.actor_id) || accountAdmin(grant))) {
1566
+ const member = !!bound && bound.actor_id !== null && grant.actorId === bound.actor_id
1567
+ if (!(bound?.token_id === grant.tokenId || fullOf(grant, (bound ?? tomb)!.actor_id) || accountAdmin(grant) || (member && !this.liveMachines().has(name)))) {
1489
1568
  core.record("refuse", null, name, { code: "unauthorized", machine: name })
1490
1569
  throw new RelayError(
1491
1570
  "unauthorized",
1492
- `only the token machine ${name} is bound to, a full login of the member who bound it, or an org owner or admin can release it`,
1571
+ `only the token machine ${name} is bound to, a full login of the member who bound it, or an org owner or admin can release it${member ? "; any credential of that member can once it is offline" : ""}`,
1493
1572
  )
1494
1573
  }
1495
1574
  for (const socket of [...this.deps.sockets()]) {
package/src/index.ts CHANGED
@@ -13,6 +13,11 @@ export {
13
13
  type XEndpoint,
14
14
  } from "./federation"
15
15
  export {
16
+ GRAPH_MINUTES,
17
+ GRAPH_MINUTES_MAX,
18
+ type GraphEdge,
19
+ type GraphNode,
20
+ type HubGraph,
16
21
  HUB_LIMITS,
17
22
  RelayHub,
18
23
  type HubAuth,