@scp-dev/hub 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Envelope, RelayCore, CoreOptions, EndpointRow, PolicySubject, ScopeSubject, ScopeNode, ScopeDirection, Admission, MessageRow, EndpointView, ScopeCap, ScopeEntry, Claim, SendParams, SendResult, EndpointKind, Presence, StatusState, Sql, CrossAccountLink, XDelivered, XListed, XRelease } from '@scp-dev/core';
1
+ import { Envelope, RelayCore, CoreOptions, EndpointRow, PolicySubject, ScopeSubject, ScopeNode, ScopeDirection, Admission, MessageRow, EndpointView, ScopeCap, ScopeEntry, Claim, SendParams, SendResult, EndpointKind, Presence, StatusState, Sql, CrossAccountLink, XDelivered, XListed, ActivityRow, XRelease } from '@scp-dev/core';
2
2
 
3
3
  type RelayAuth = Envelope["from"]["auth"];
4
4
  /** a machine name: one relay host of an account */
@@ -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 */
@@ -397,6 +409,49 @@ interface HubConn {
397
409
  /** whether a human client said a terminal is attached */
398
410
  tty?: boolean;
399
411
  }
412
+ /** a bound machine, or a revoked token's name kept as a tombstone */
413
+ interface MachineRow {
414
+ name: string;
415
+ kind: string;
416
+ online: boolean;
417
+ bridge: boolean;
418
+ created_at: string | null;
419
+ last_seen: string | null;
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;
400
455
  interface HubSocket {
401
456
  conn: HubConn;
402
457
  send(text: string): void;
@@ -559,7 +614,10 @@ declare class RelayHub {
559
614
  private announce;
560
615
  /** a direct client's endpoint changed: the other bridges mirror it */
561
616
  private announceEndpoint;
617
+ /** `own` tells each bridge whether a bound machine is its member's own device */
562
618
  private toBridges;
619
+ /** the machines of other members than the grant's, which a bridge shows under the account handle */
620
+ private othersOf;
563
621
  private fleet;
564
622
  private write;
565
623
  private save;
@@ -574,8 +632,19 @@ declare class RelayHub {
574
632
  /** machines a live connection holds */
575
633
  private liveMachines;
576
634
  /** bindings, then names a revoke left behind (kind `revoked`) */
635
+ /** a read-only view of the account for a dashboard: its machines, listed endpoints and latest activity */
636
+ overview(activity?: number): {
637
+ machines: MachineRow[];
638
+ endpoints: EndpointView[];
639
+ activity: ActivityRow[];
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;
577
643
  private machineList;
578
- /** 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
+ */
579
648
  private releaseMachine;
580
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 */
581
650
  private expireMachines;
@@ -599,4 +668,4 @@ declare class RelayHub {
599
668
  xnotice(endpoint: string, text: string): void;
600
669
  }
601
670
 
602
- 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 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 } });
@@ -2721,6 +2758,43 @@ var RelayHub = class _RelayHub {
2721
2758
  return live;
2722
2759
  }
2723
2760
  /** bindings, then names a revoke left behind (kind `revoked`) */
2761
+ /** a read-only view of the account for a dashboard: its machines, listed endpoints and latest activity */
2762
+ overview(activity = 20) {
2763
+ const { endpoints, activity: rows } = this.relay.snapshot({ activity });
2764
+ return { machines: this.machineList(), endpoints, activity: rows };
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
+ }
2724
2798
  machineList() {
2725
2799
  const sockets = this.deps.sockets().filter((s) => !this.gone.has(s.conn.id));
2726
2800
  const bound = this.deps.sql.all(`SELECT name, kind, created_at, last_seen FROM machines ORDER BY name`).map((row) => ({
@@ -2734,7 +2808,10 @@ var RelayHub = class _RelayHub {
2734
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) }));
2735
2809
  return [...bound, ...revoked];
2736
2810
  }
2737
- /** 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
+ */
2738
2815
  releaseMachine(caller, name) {
2739
2816
  const core = this.relay;
2740
2817
  if (typeof name !== "string" || !MACHINE_NAME.test(name)) throw new RelayError2("invalid_params", "name is a machine name");
@@ -2742,11 +2819,12 @@ var RelayHub = class _RelayHub {
2742
2819
  const tomb = bound ? void 0 : one2(this.deps.sql, `SELECT actor_id FROM machine_tombstones WHERE name = ?`, name);
2743
2820
  if (!bound && !tomb) throw new RelayError2("not_found", `no machine ${name}`);
2744
2821
  const grant = caller.conn.grant;
2745
- 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))) {
2746
2824
  core.record("refuse", null, name, { code: "unauthorized", machine: name });
2747
2825
  throw new RelayError2(
2748
2826
  "unauthorized",
2749
- `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" : ""}`
2750
2828
  );
2751
2829
  }
2752
2830
  for (const socket of [...this.deps.sockets()]) {
@@ -2867,6 +2945,8 @@ var RelayHub = class _RelayHub {
2867
2945
  };
2868
2946
  export {
2869
2947
  FederatedCore,
2948
+ GRAPH_MINUTES,
2949
+ GRAPH_MINUTES_MAX,
2870
2950
  HUB_LIMITS,
2871
2951
  MACHINE_NAME,
2872
2952
  RelayHub
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/hub",
3
- "version": "0.1.0",
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.0"
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
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type ActivityRow,
2
3
  allowlisted,
3
4
  CAPABILITIES,
4
5
  CLI_NAME,
@@ -81,6 +82,44 @@ export interface HubConn {
81
82
  tty?: boolean
82
83
  }
83
84
 
85
+ /** a bound machine, or a revoked token's name kept as a tombstone */
86
+ export interface MachineRow {
87
+ name: string
88
+ kind: string
89
+ online: boolean
90
+ bridge: boolean
91
+ created_at: string | null
92
+ last_seen: string | null
93
+ }
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
+
84
123
  export interface HubSocket {
85
124
  conn: HubConn
86
125
  send(text: string): void
@@ -630,8 +669,11 @@ export class RelayHub {
630
669
  serverFields(params)
631
670
  const observe = params.observe === true
632
671
  const named = params.machine
633
- if ((!observe || named !== undefined) && (typeof named !== "string" || !MACHINE_NAME.test(named) || named === "me" || named === this.account)) {
634
- 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")
635
677
  }
636
678
  const bridge = params.bridge === true
637
679
  const human = params.human === true
@@ -679,7 +721,7 @@ export class RelayHub {
679
721
  this.release(other, true)
680
722
  }
681
723
  core.record("bridge", null, machine, { machine })
682
- return { ...base, endpoints: core.remoteViews({ except: machine }) }
724
+ return { ...base, endpoints: core.remoteViews({ except: machine }), others: this.othersOf(conn.grant, machine) }
683
725
  }
684
726
 
685
727
  if (one<{ kind: string }>(this.deps.sql, `SELECT kind FROM machines WHERE name = ?`, machine)?.kind === "bridge") {
@@ -1290,7 +1332,7 @@ export class RelayHub {
1290
1332
  /** a machine's endpoint changes: `endpoints` to the other bridges, presence and status to watchers */
1291
1333
  private announce(machine: string, changes: MirrorChange[], full: boolean): void {
1292
1334
  if (full || changes.length) {
1293
- 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 }))
1294
1336
  this.toBridges(machine, { machine, endpoints, full })
1295
1337
  }
1296
1338
  for (const change of changes) {
@@ -1309,12 +1351,23 @@ export class RelayHub {
1309
1351
  if (view) this.toBridges(machine, { machine, endpoints: [view], full: false })
1310
1352
  }
1311
1353
 
1312
- 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)
1313
1357
  for (const socket of this.deps.sockets()) {
1314
- 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 } })
1315
1361
  }
1316
1362
  }
1317
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
+
1318
1371
  private fleet(event: Record<string, unknown>): void {
1319
1372
  const at = new Date(this.now()).toISOString()
1320
1373
  for (const socket of this.deps.sockets()) if (socket.conn.watching) this.write(socket, { jsonrpc: "2.0", method: "fleet", params: { at, ...event } })
@@ -1442,7 +1495,46 @@ export class RelayHub {
1442
1495
  }
1443
1496
 
1444
1497
  /** bindings, then names a revoke left behind (kind `revoked`) */
1445
- private machineList(): { name: string; kind: string; online: boolean; bridge: boolean; created_at: string | null; last_seen: string | null }[] {
1498
+ /** a read-only view of the account for a dashboard: its machines, listed endpoints and latest activity */
1499
+ overview(activity = 20): { machines: MachineRow[]; endpoints: EndpointView[]; activity: ActivityRow[] } {
1500
+ const { endpoints, activity: rows } = this.relay.snapshot({ activity })
1501
+ return { machines: this.machineList(), endpoints, activity: rows }
1502
+ }
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
+
1537
+ private machineList(): MachineRow[] {
1446
1538
  const sockets = this.deps.sockets().filter((s) => !this.gone.has(s.conn.id))
1447
1539
  const bound = this.deps.sql
1448
1540
  .all<{ name: string; kind: string; created_at: number; last_seen: number | null }>(`SELECT name, kind, created_at, last_seen FROM machines ORDER BY name`)
@@ -1460,7 +1552,10 @@ export class RelayHub {
1460
1552
  return [...bound, ...revoked]
1461
1553
  }
1462
1554
 
1463
- /** 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
+ */
1464
1559
  private releaseMachine(caller: HubSocket, name: unknown): unknown {
1465
1560
  const core = this.relay
1466
1561
  if (typeof name !== "string" || !MACHINE_NAME.test(name)) throw new RelayError("invalid_params", "name is a machine name")
@@ -1468,11 +1563,12 @@ export class RelayHub {
1468
1563
  const tomb = bound ? undefined : one<{ actor_id: string | null }>(this.deps.sql, `SELECT actor_id FROM machine_tombstones WHERE name = ?`, name)
1469
1564
  if (!bound && !tomb) throw new RelayError("not_found", `no machine ${name}`)
1470
1565
  const grant = caller.conn.grant
1471
- 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)))) {
1472
1568
  core.record("refuse", null, name, { code: "unauthorized", machine: name })
1473
1569
  throw new RelayError(
1474
1570
  "unauthorized",
1475
- `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" : ""}`,
1476
1572
  )
1477
1573
  }
1478
1574
  for (const socket of [...this.deps.sockets()]) {
package/src/index.ts CHANGED
@@ -12,4 +12,20 @@ export {
12
12
  type RemoteView,
13
13
  type XEndpoint,
14
14
  } from "./federation"
15
- export { HUB_LIMITS, RelayHub, type HubAuth, type HubConn, type HubDeps, type HubGrant, type HubLimits, type HubRole, type HubSocket } from "./hub"
15
+ export {
16
+ GRAPH_MINUTES,
17
+ GRAPH_MINUTES_MAX,
18
+ type GraphEdge,
19
+ type GraphNode,
20
+ type HubGraph,
21
+ HUB_LIMITS,
22
+ RelayHub,
23
+ type HubAuth,
24
+ type HubConn,
25
+ type HubDeps,
26
+ type HubGrant,
27
+ type HubLimits,
28
+ type HubRole,
29
+ type HubSocket,
30
+ type MachineRow,
31
+ } from "./hub"