@scp-dev/hub 0.4.0 → 0.5.1

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 */
@@ -162,6 +162,8 @@ declare class FederatedCore extends RelayCore {
162
162
  protected resolve(address: unknown): EndpointRow;
163
163
  protected addressUsers(): string[];
164
164
  protected localRow(row: EndpointRow): boolean;
165
+ /** a live mirrored row shows the presence its machine mirrored, not one its last_seen decays to here */
166
+ protected view(row: EndpointRow, shown?: Set<string>): EndpointView;
165
167
  protected address(row: EndpointRow): string;
166
168
  protected subject(row: EndpointRow): PolicySubject;
167
169
  /** a stand-in for another account's sender is no endpoint of this relay: list, peers and snapshots leave it out */
@@ -583,10 +585,14 @@ type GraphEdge = {
583
585
  count: number;
584
586
  last_at: string;
585
587
  };
588
+ /** a machine as the graph draws it: `actor` is the member who bound it, null for the personal account's owner or a revoked name */
589
+ interface GraphMachine extends MachineRow {
590
+ actor: string | null;
591
+ }
586
592
  interface HubGraph {
587
593
  /** the window message edges count over */
588
594
  minutes: number;
589
- machines: MachineRow[];
595
+ machines: GraphMachine[];
590
596
  nodes: GraphNode[];
591
597
  edges: GraphEdge[];
592
598
  }
@@ -606,6 +612,7 @@ type DashboardEvent = {
606
612
  kind: string;
607
613
  from: string;
608
614
  to: string[];
615
+ local?: true;
609
616
  } | {
610
617
  type: "endpoints";
611
618
  at: string;
@@ -668,6 +675,14 @@ declare const HUB_LIMITS: {
668
675
  type HubLimits = {
669
676
  [K in keyof typeof HUB_LIMITS]: number;
670
677
  };
678
+ /** kinds a bridge reports for a delivery its broker made on its own machine */
679
+ declare const LOCAL_EVENT_KINDS: readonly ["message", "request", "reply", "status", "handback", "spawn"];
680
+ /** events per `local_events` frame, recipients per event, and how far back an event's `at` may lie */
681
+ declare const LOCAL_EVENTS: {
682
+ readonly perFrame: 200;
683
+ readonly recipients: 64;
684
+ readonly maxAgeMs: 300000;
685
+ };
671
686
  /** the hosted relay of one account: machines, bridges, direct clients, push and replay over a FederatedCore; pure, sockets are injected */
672
687
  declare class RelayHub {
673
688
  private readonly deps;
@@ -799,6 +814,11 @@ declare class RelayHub {
799
814
  private deliverOn;
800
815
  private readonly deliverJudged;
801
816
  private delivered;
817
+ /**
818
+ * a bridge's metadata for deliveries its broker made on its own machine: counted for the graph and streamed to watchers and the
819
+ * dashboard as `local`; unknown endpoints and malformed events are skipped
820
+ */
821
+ private localEvents;
802
822
  /** the body's bytes when the account can hold them; refuses a send past the `storedBytes` limit */
803
823
  private room;
804
824
  /** bytes of message bodies held, and of the messages from other accounts */
@@ -865,4 +885,4 @@ declare class RelayHub {
865
885
  xnotice(endpoint: string, text: string): void;
866
886
  }
867
887
 
868
- export { type DashboardEvent, type Deliver, type Delivery, FederatedCore, type FederatedOptions, GRAPH_MINUTES, GRAPH_MINUTES_MAX, type GraphEdge, type GraphNode, HUB_LIMITS, type HoldView, type HubAuth, type HubConn, type HubDeps, type HubGrant, type HubGraph, type HubJudgeOptions, type HubLimits, type HubRole, type HubSocket, JUDGE_LIMITS, type JudgeClientFactory, JudgeGate, type JudgeGateOptions, type JudgeLimits, type Origin as JudgeOrigin, MACHINE_NAME, type MachineRow, type MirrorCaps, type MirrorChange, type MirrorRules, type MirrorScope, type MirrorView, type RelayAuth, RelayHub, type RemoteView, type XEndpoint, dashboardEvent };
888
+ export { type DashboardEvent, type Deliver, type Delivery, FederatedCore, type FederatedOptions, GRAPH_MINUTES, GRAPH_MINUTES_MAX, type GraphEdge, type GraphMachine, type GraphNode, HUB_LIMITS, type HoldView, type HubAuth, type HubConn, type HubDeps, type HubGrant, type HubGraph, type HubJudgeOptions, type HubLimits, type HubRole, type HubSocket, JUDGE_LIMITS, type JudgeClientFactory, JudgeGate, type JudgeGateOptions, type JudgeLimits, type Origin as JudgeOrigin, LOCAL_EVENTS, LOCAL_EVENT_KINDS, MACHINE_NAME, type MachineRow, type MirrorCaps, type MirrorChange, type MirrorRules, type MirrorScope, type MirrorView, type RelayAuth, RelayHub, type RemoteView, type XEndpoint, dashboardEvent };
package/dist/index.js CHANGED
@@ -20,6 +20,8 @@ import {
20
20
  SYSTEM_HANDLE,
21
21
  seqList,
22
22
  unreachable,
23
+ harnessFits,
24
+ keptHarness,
23
25
  validHandle,
24
26
  validHarness,
25
27
  validSelector,
@@ -88,6 +90,7 @@ var FederatedCore = class extends RelayCore {
88
90
  if (!columns.includes("incarnation")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN incarnation TEXT`);
89
91
  if (!columns.includes("scope_epoch")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN scope_epoch INTEGER`);
90
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`);
91
94
  this.sql.all(`CREATE UNIQUE INDEX IF NOT EXISTS endpoints_remote ON endpoints (remote) WHERE remote IS NOT NULL`);
92
95
  if (this.xinbox) for (const statement of XACCT_MIGRATION) this.sql.all(statement);
93
96
  }
@@ -220,6 +223,12 @@ var FederatedCore = class extends RelayCore {
220
223
  localRow(row) {
221
224
  return !row.remote;
222
225
  }
226
+ /** a live mirrored row shows the presence its machine mirrored, not one its last_seen decays to here */
227
+ view(row, shown) {
228
+ const view = super.view(row, shown);
229
+ const { remote, mirrored_presence: mirrored } = row;
230
+ return remote && row.connections > 0 && (mirrored === "online" || mirrored === "idle") ? { ...view, presence: mirrored } : view;
231
+ }
223
232
  address(row) {
224
233
  const remote = row.remote;
225
234
  if (remote && isForeign(remote)) return remote.slice(1);
@@ -389,6 +398,7 @@ var FederatedCore = class extends RelayCore {
389
398
  status_note: view.status?.note ?? null,
390
399
  token_hash: null,
391
400
  connections: live ? 1 : 0,
401
+ mirrored_presence: view.presence,
392
402
  last_seen: view.lastSeen ?? (live ? now : before?.last_seen ?? null),
393
403
  auth: auth ?? row.auth,
394
404
  ...view.kind === "agent" ? this.scopeColumns(machine, view.scope, row, place(view.handle)) : {}
@@ -497,9 +507,9 @@ var FederatedCore = class extends RelayCore {
497
507
  this.checkCap();
498
508
  row = this.byId(this.insertRemote(`${machine}/${handle}`, "agent", harness, auth, now));
499
509
  created = true;
500
- } else if (held.kind === "agent" && held.connections === 0 && held.harness === harness) {
510
+ } else if (held.kind === "agent" && held.connections === 0 && harnessFits(held.harness, harness)) {
501
511
  row = held;
502
- } else if (exact && held.kind === "agent" && held.token_hash !== null && held.process === null && held.session === null && held.parent_ep === null && held.harness === harness) {
512
+ } else if (exact && held.kind === "agent" && held.token_hash !== null && held.process === null && held.session === null && held.parent_ep === null && harnessFits(held.harness, harness)) {
503
513
  row = held;
504
514
  joined = true;
505
515
  } else if (held.kind === "agent" && held.connections === 0 && (held.last_seen ?? held.created_at) < now - LIMITS.handleBoundS * 1e3) {
@@ -519,7 +529,7 @@ var FederatedCore = class extends RelayCore {
519
529
  const token = joined ? void 0 : `kre_${randomHex(32)}`;
520
530
  this.sql.all(
521
531
  `UPDATE endpoints SET harness = ?, model = ?, cwd = ?, parent = ?, labels = ?, token_hash = ${token ? "?" : "token_hash"}, auth = ?, connections = connections + 1, last_seen = ? WHERE id = ?`,
522
- harness,
532
+ keptHarness(row.harness, harness),
523
533
  fields.model,
524
534
  fields.cwd,
525
535
  fields.parent,
@@ -2017,7 +2027,8 @@ function dashboardEvent(event, at) {
2017
2027
  if (event.type === "message") {
2018
2028
  const from = str(event.from);
2019
2029
  const to = Array.isArray(event.to) ? event.to.filter((a) => typeof a === "string") : [];
2020
- return from && to.length ? { type: "message", at, kind: str(event.kind) ?? "message", from, to } : null;
2030
+ if (!from || !to.length) return null;
2031
+ return { type: "message", at, kind: str(event.kind) ?? "message", from, to, ...event.local === true ? { local: true } : {} };
2021
2032
  }
2022
2033
  if (event.type === "presence") {
2023
2034
  const endpoint = str(event.endpoint);
@@ -2055,8 +2066,14 @@ var TOUCH_MS = 1e4;
2055
2066
  var HUB_MIGRATION = [
2056
2067
  `CREATE TABLE IF NOT EXISTS machines (name TEXT PRIMARY KEY, token_id TEXT NOT NULL, kind TEXT NOT NULL, created_at INTEGER NOT NULL, last_seen INTEGER)`,
2057
2068
  `CREATE TABLE IF NOT EXISTS hub_state (key TEXT PRIMARY KEY, value INTEGER NOT NULL)`,
2058
- `CREATE TABLE IF NOT EXISTS machine_tombstones (name TEXT PRIMARY KEY, actor_id TEXT, user_id TEXT, at INTEGER NOT NULL)`
2069
+ `CREATE TABLE IF NOT EXISTS machine_tombstones (name TEXT PRIMARY KEY, actor_id TEXT, user_id TEXT, at INTEGER NOT NULL)`,
2070
+ `CREATE TABLE IF NOT EXISTS local_events (from_ep TEXT NOT NULL, to_ep TEXT NOT NULL, at INTEGER NOT NULL)`,
2071
+ `CREATE INDEX IF NOT EXISTS local_events_at ON local_events (at)`
2059
2072
  ];
2073
+ var LOCAL_EVENT_KINDS = ["message", "request", "reply", "status", "handback", "spawn"];
2074
+ var LOCAL_EVENTS = { perFrame: 200, recipients: 64, maxAgeMs: 3e5 };
2075
+ var LOCAL_KEEP_MS = 36e5;
2076
+ var HASH_ID = /^[0-9a-f]{8,64}$/;
2060
2077
  var OBSERVER_METHODS = /* @__PURE__ */ new Set(["list", "snapshot", "watch", "log", "machines", "machines_rm"]);
2061
2078
  var CLIENT_METHODS = /* @__PURE__ */ new Set(["whoami", "send", "ask", "ask_wait", "inbox", "ack", "status", "event", ...OBSERVER_METHODS]);
2062
2079
  var iso2 = (ms) => ms === null ? null : new Date(ms).toISOString();
@@ -2335,6 +2352,7 @@ var RelayHub = class _RelayHub {
2335
2352
  this.setState("last_validated", now);
2336
2353
  if (now - (this.state("last_sweep") ?? 0) >= SWEEP_MS) {
2337
2354
  this.relay.sweep();
2355
+ this.deps.sql.all(`DELETE FROM local_events WHERE at <= ?`, now - LOCAL_KEEP_MS);
2338
2356
  this.expireMachines(now);
2339
2357
  this.forgetTombstones(now);
2340
2358
  const denied = this.deniedGrants();
@@ -2790,6 +2808,8 @@ var RelayHub = class _RelayHub {
2790
2808
  this.fleet({ type: "activity", machine, ...row });
2791
2809
  return row;
2792
2810
  }
2811
+ case "local_events":
2812
+ return { recorded: this.localEvents(machine, params.events) };
2793
2813
  case "list":
2794
2814
  if (params.contacts === true) return { contacts: await this.contactList() };
2795
2815
  return { endpoints: core.list(listFilters(params)) };
@@ -3137,6 +3157,47 @@ var RelayHub = class _RelayHub {
3137
3157
  hop: result.hop
3138
3158
  });
3139
3159
  }
3160
+ /**
3161
+ * a bridge's metadata for deliveries its broker made on its own machine: counted for the graph and streamed to watchers and the
3162
+ * dashboard as `local`; unknown endpoints and malformed events are skipped
3163
+ */
3164
+ localEvents(machine, value) {
3165
+ if (!Array.isArray(value) || value.length > LOCAL_EVENTS.perFrame)
3166
+ throw new RelayError3("invalid_params", `events is a list of at most ${LOCAL_EVENTS.perFrame}`);
3167
+ const core = this.relay;
3168
+ const now = this.now();
3169
+ const resolve = (handle) => {
3170
+ try {
3171
+ return core.remoteEndpoint(machine, handle);
3172
+ } catch {
3173
+ return null;
3174
+ }
3175
+ };
3176
+ let recorded = 0;
3177
+ for (const raw of value) {
3178
+ if (typeof raw !== "object" || raw === null) continue;
3179
+ const event = raw;
3180
+ const kind = LOCAL_EVENT_KINDS.includes(event.kind) ? event.kind : null;
3181
+ const at = typeof event.at === "number" && Number.isFinite(event.at) ? Math.min(Math.max(event.at, now - LOCAL_EVENTS.maxAgeMs), now) : null;
3182
+ const from = resolve(event.from);
3183
+ if (!kind || at === null || !from || !Array.isArray(event.to) || event.to.length > LOCAL_EVENTS.recipients) continue;
3184
+ const to = [...new Set(event.to.map(resolve).filter((id) => id !== null && id !== from))];
3185
+ if (!to.length) continue;
3186
+ for (const ep of to) this.deps.sql.all(`INSERT INTO local_events (from_ep, to_ep, at) VALUES (?, ?, ?)`, from, ep, at);
3187
+ this.fleet({
3188
+ type: "message",
3189
+ machine,
3190
+ local: true,
3191
+ ...typeof event.id === "string" && HASH_ID.test(event.id) ? { id: event.id } : {},
3192
+ ...typeof event.thread === "string" && HASH_ID.test(event.thread) ? { thread: event.thread } : {},
3193
+ kind,
3194
+ from: core.addressOf(from),
3195
+ to: to.map((ep) => core.addressOf(ep))
3196
+ });
3197
+ recorded++;
3198
+ }
3199
+ return recorded;
3200
+ }
3140
3201
  /** the body's bytes when the account can hold them; refuses a send past the `storedBytes` limit */
3141
3202
  room(sender, body) {
3142
3203
  if (typeof body !== "string") return 0;
@@ -3354,9 +3415,13 @@ var RelayHub = class _RelayHub {
3354
3415
  const edges = nodes.flatMap(
3355
3416
  (n) => n.parent && n.parent !== n.address && listed.has(n.parent) ? [{ kind: "spawn", from: n.parent, to: n.address }] : []
3356
3417
  );
3418
+ const since = this.now() - window * 6e4;
3357
3419
  const pairs = this.deps.sql.all(
3358
- `SELECT from_ep, to_ep, COUNT(*) AS n, MAX(at) AS last FROM pair_events WHERE at > ? GROUP BY from_ep, to_ep`,
3359
- this.now() - window * 6e4
3420
+ `SELECT from_ep, to_ep, COUNT(*) AS n, MAX(at) AS last FROM (
3421
+ SELECT from_ep, to_ep, at FROM pair_events WHERE at > ? UNION ALL SELECT from_ep, to_ep, at FROM local_events WHERE at > ?
3422
+ ) GROUP BY from_ep, to_ep`,
3423
+ since,
3424
+ since
3360
3425
  );
3361
3426
  const traffic = pairs.flatMap((row) => {
3362
3427
  const from = byId.get(row.from_ep);
@@ -3364,7 +3429,9 @@ var RelayHub = class _RelayHub {
3364
3429
  return from && to && from !== to ? [{ kind: "message", from, to, count: row.n, last_at: iso2(row.last) }] : [];
3365
3430
  });
3366
3431
  edges.push(...traffic.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to)));
3367
- return { minutes: window, machines: this.machineList(), nodes, edges };
3432
+ const actors = new Map(this.deps.sql.all(`SELECT name, actor_id FROM machines`).map((r) => [r.name, r.actor_id]));
3433
+ const machines = this.machineList().map((m) => ({ ...m, actor: actors.get(m.name) ?? null }));
3434
+ return { minutes: window, machines, nodes, edges };
3368
3435
  }
3369
3436
  machineList() {
3370
3437
  const sockets = this.deps.sockets().filter((s) => !this.gone.has(s.conn.id));
@@ -3409,7 +3476,7 @@ var RelayHub = class _RelayHub {
3409
3476
  return count;
3410
3477
  });
3411
3478
  this.stored = null;
3412
- this.toBridges(name, { machine: name, endpoints: [], full: true });
3479
+ this.toBridges(name, { machine: name, endpoints: [], full: true, released: true });
3413
3480
  this.dash({ type: "endpoints", at: new Date(this.now()).toISOString(), machine: name });
3414
3481
  this.deps.log?.("relay_action", { account: this.account, machine: name, method: "machine_release", code: "ok" });
3415
3482
  return { removed: name, endpoints: dropped };
@@ -3446,7 +3513,7 @@ var RelayHub = class _RelayHub {
3446
3513
  core.record("machine_expire", null, name, { machine: name, endpoints: count });
3447
3514
  return count;
3448
3515
  });
3449
- this.toBridges(name, { machine: name, endpoints: [], full: true });
3516
+ this.toBridges(name, { machine: name, endpoints: [], full: true, released: true });
3450
3517
  this.deps.log?.("relay_action", { account: this.account, machine: name, method: "machine_expire", code: "ok", endpoints: dropped });
3451
3518
  }
3452
3519
  }
@@ -3464,7 +3531,7 @@ var RelayHub = class _RelayHub {
3464
3531
  for (const name of unbound) this.relay.record("machine_forget", null, name, { machine: name, endpoints: this.relay.dropMachine(name, { queue: true }) });
3465
3532
  return unbound;
3466
3533
  });
3467
- for (const name of forgotten) this.toBridges(name, { machine: name, endpoints: [], full: true });
3534
+ for (const name of forgotten) this.toBridges(name, { machine: name, endpoints: [], full: true, released: true });
3468
3535
  }
3469
3536
  /** the hub_state keys (`denied:<tokenId>`) of tokens revoked lately, to when; loaded once per object lifetime */
3470
3537
  deniedGrants() {
@@ -3522,6 +3589,8 @@ export {
3522
3589
  HUB_LIMITS,
3523
3590
  JUDGE_LIMITS,
3524
3591
  JudgeGate,
3592
+ LOCAL_EVENTS,
3593
+ LOCAL_EVENT_KINDS,
3525
3594
  MACHINE_NAME,
3526
3595
  RelayHub,
3527
3596
  dashboardEvent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/hub",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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.4"
32
+ "@scp-dev/core": "0.1.6"
33
33
  },
34
34
  "devDependencies": {
35
35
  "tsup": "^8.5.1"
package/src/federation.ts CHANGED
@@ -40,6 +40,8 @@ import {
40
40
  SYSTEM_HANDLE,
41
41
  seqList,
42
42
  unreachable,
43
+ harnessFits,
44
+ keptHarness,
43
45
  validHandle,
44
46
  validHarness,
45
47
  validSelector,
@@ -80,6 +82,8 @@ interface FederatedRow extends EndpointRow {
80
82
  scope_epoch: number | null
81
83
  /** this machine's own agent: the wire caps it mirrored last */
82
84
  mirrored_caps: string | null
85
+ /** another machine's row: the presence its bridge mirrored last */
86
+ mirrored_presence: Presence | null
83
87
  }
84
88
 
85
89
  /** own rules as they cross machines: selector lists per direction */
@@ -288,6 +292,7 @@ export class FederatedCore extends RelayCore {
288
292
  if (!columns.includes("incarnation")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN incarnation TEXT`)
289
293
  if (!columns.includes("scope_epoch")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN scope_epoch INTEGER`)
290
294
  if (!columns.includes("mirrored_caps")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN mirrored_caps TEXT`)
295
+ if (!columns.includes("mirrored_presence")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN mirrored_presence TEXT`)
291
296
  this.sql.all(`CREATE UNIQUE INDEX IF NOT EXISTS endpoints_remote ON endpoints (remote) WHERE remote IS NOT NULL`)
292
297
  if (this.xinbox) for (const statement of XACCT_MIGRATION) this.sql.all(statement)
293
298
  }
@@ -444,6 +449,13 @@ export class FederatedCore extends RelayCore {
444
449
  return !(row as FederatedRow).remote
445
450
  }
446
451
 
452
+ /** a live mirrored row shows the presence its machine mirrored, not one its last_seen decays to here */
453
+ protected override view(row: EndpointRow, shown?: Set<string>): EndpointView {
454
+ const view = super.view(row, shown)
455
+ const { remote, mirrored_presence: mirrored } = row as FederatedRow
456
+ return remote && row.connections > 0 && (mirrored === "online" || mirrored === "idle") ? { ...view, presence: mirrored } : view
457
+ }
458
+
447
459
  protected override address(row: EndpointRow): string {
448
460
  const remote = (row as FederatedRow).remote
449
461
  if (remote && isForeign(remote)) return remote.slice(1)
@@ -623,6 +635,7 @@ export class FederatedCore extends RelayCore {
623
635
  status_note: view.status?.note ?? null,
624
636
  token_hash: null,
625
637
  connections: live ? 1 : 0,
638
+ mirrored_presence: view.presence,
626
639
  last_seen: view.lastSeen ?? (live ? now : (before?.last_seen ?? null)),
627
640
  auth: auth ?? row.auth,
628
641
  ...(view.kind === "agent" ? this.scopeColumns(machine, view.scope, row, place(view.handle)) : {}),
@@ -734,7 +747,7 @@ export class FederatedCore extends RelayCore {
734
747
  this.checkCap()
735
748
  row = this.byId(this.insertRemote(`${machine}/${handle}`, "agent", harness, auth, now)) as FederatedRow
736
749
  created = true
737
- } else if (held.kind === "agent" && held.connections === 0 && held.harness === harness) {
750
+ } else if (held.kind === "agent" && held.connections === 0 && harnessFits(held.harness, harness)) {
738
751
  row = held
739
752
  } else if (
740
753
  exact &&
@@ -743,7 +756,7 @@ export class FederatedCore extends RelayCore {
743
756
  held.process === null &&
744
757
  held.session === null &&
745
758
  held.parent_ep === null &&
746
- held.harness === harness
759
+ harnessFits(held.harness, harness)
747
760
  ) {
748
761
  row = held
749
762
  joined = true
@@ -764,7 +777,7 @@ export class FederatedCore extends RelayCore {
764
777
  const token = joined ? undefined : `kre_${randomHex(32)}`
765
778
  this.sql.all(
766
779
  `UPDATE endpoints SET harness = ?, model = ?, cwd = ?, parent = ?, labels = ?, token_hash = ${token ? "?" : "token_hash"}, auth = ?, connections = connections + 1, last_seen = ? WHERE id = ?`,
767
- harness,
780
+ keptHarness(row.harness, harness),
768
781
  fields.model,
769
782
  fields.cwd,
770
783
  fields.parent,
package/src/hub.ts CHANGED
@@ -111,10 +111,15 @@ export interface GraphNode {
111
111
  /** `spawn`: parent to child; `message`: sender to recipient, with the count and newest send in the graph's window */
112
112
  export type GraphEdge = { kind: "spawn"; from: string; to: string } | { kind: "message"; from: string; to: string; count: number; last_at: string }
113
113
 
114
+ /** a machine as the graph draws it: `actor` is the member who bound it, null for the personal account's owner or a revoked name */
115
+ export interface GraphMachine extends MachineRow {
116
+ actor: string | null
117
+ }
118
+
114
119
  export interface HubGraph {
115
120
  /** the window message edges count over */
116
121
  minutes: number
117
- machines: MachineRow[]
122
+ machines: GraphMachine[]
118
123
  nodes: GraphNode[]
119
124
  edges: GraphEdge[]
120
125
  }
@@ -126,7 +131,7 @@ export const GRAPH_MINUTES_MAX = 60
126
131
  /** what a read-only dashboard stream sees as it happens: addresses, kinds and presence, never a body, note, summary or label */
127
132
  export type DashboardEvent =
128
133
  | { type: "presence"; at: string; machine: string | null; endpoint: string; presence: Presence }
129
- | { type: "message"; at: string; kind: string; from: string; to: string[] }
134
+ | { type: "message"; at: string; kind: string; from: string; to: string[]; local?: true }
130
135
  | { type: "endpoints"; at: string; machine: string }
131
136
 
132
137
  const str = (v: unknown): string | null => (typeof v === "string" ? v : null)
@@ -136,7 +141,8 @@ export function dashboardEvent(event: Record<string, unknown>, at: string): Dash
136
141
  if (event.type === "message") {
137
142
  const from = str(event.from)
138
143
  const to = Array.isArray(event.to) ? event.to.filter((a): a is string => typeof a === "string") : []
139
- return from && to.length ? { type: "message", at, kind: str(event.kind) ?? "message", from, to } : null
144
+ if (!from || !to.length) return null
145
+ return { type: "message", at, kind: str(event.kind) ?? "message", from, to, ...(event.local === true ? { local: true as const } : {}) }
140
146
  }
141
147
  if (event.type === "presence") {
142
148
  const endpoint = str(event.endpoint)
@@ -218,8 +224,17 @@ const HUB_MIGRATION = [
218
224
  `CREATE TABLE IF NOT EXISTS machines (name TEXT PRIMARY KEY, token_id TEXT NOT NULL, kind TEXT NOT NULL, created_at INTEGER NOT NULL, last_seen INTEGER)`,
219
225
  `CREATE TABLE IF NOT EXISTS hub_state (key TEXT PRIMARY KEY, value INTEGER NOT NULL)`,
220
226
  `CREATE TABLE IF NOT EXISTS machine_tombstones (name TEXT PRIMARY KEY, actor_id TEXT, user_id TEXT, at INTEGER NOT NULL)`,
227
+ `CREATE TABLE IF NOT EXISTS local_events (from_ep TEXT NOT NULL, to_ep TEXT NOT NULL, at INTEGER NOT NULL)`,
228
+ `CREATE INDEX IF NOT EXISTS local_events_at ON local_events (at)`,
221
229
  ]
222
230
 
231
+ /** kinds a bridge reports for a delivery its broker made on its own machine */
232
+ export const LOCAL_EVENT_KINDS = ["message", "request", "reply", "status", "handback", "spawn"] as const
233
+ /** events per `local_events` frame, recipients per event, and how far back an event's `at` may lie */
234
+ export const LOCAL_EVENTS = { perFrame: 200, recipients: 64, maxAgeMs: 300_000 } as const
235
+ const LOCAL_KEEP_MS = 3_600_000
236
+ const HASH_ID = /^[0-9a-f]{8,64}$/
237
+
223
238
  const OBSERVER_METHODS = new Set(["list", "snapshot", "watch", "log", "machines", "machines_rm"])
224
239
  const CLIENT_METHODS = new Set(["whoami", "send", "ask", "ask_wait", "inbox", "ack", "status", "event", ...OBSERVER_METHODS])
225
240
 
@@ -542,6 +557,7 @@ export class RelayHub {
542
557
  this.setState("last_validated", now)
543
558
  if (now - (this.state("last_sweep") ?? 0) >= SWEEP_MS) {
544
559
  this.relay.sweep()
560
+ this.deps.sql.all(`DELETE FROM local_events WHERE at <= ?`, now - LOCAL_KEEP_MS)
545
561
  this.expireMachines(now)
546
562
  this.forgetTombstones(now)
547
563
  const denied = this.deniedGrants()
@@ -1024,6 +1040,8 @@ export class RelayHub {
1024
1040
  this.fleet({ type: "activity", machine, ...row })
1025
1041
  return row
1026
1042
  }
1043
+ case "local_events":
1044
+ return { recorded: this.localEvents(machine, params.events) }
1027
1045
  case "list":
1028
1046
  if (params.contacts === true) return { contacts: await this.contactList() }
1029
1047
  return { endpoints: core.list(listFilters(params)) }
@@ -1405,6 +1423,48 @@ export class RelayHub {
1405
1423
  })
1406
1424
  }
1407
1425
 
1426
+ /**
1427
+ * a bridge's metadata for deliveries its broker made on its own machine: counted for the graph and streamed to watchers and the
1428
+ * dashboard as `local`; unknown endpoints and malformed events are skipped
1429
+ */
1430
+ private localEvents(machine: string, value: unknown): number {
1431
+ if (!Array.isArray(value) || value.length > LOCAL_EVENTS.perFrame)
1432
+ throw new RelayError("invalid_params", `events is a list of at most ${LOCAL_EVENTS.perFrame}`)
1433
+ const core = this.relay
1434
+ const now = this.now()
1435
+ const resolve = (handle: unknown): string | null => {
1436
+ try {
1437
+ return core.remoteEndpoint(machine, handle)
1438
+ } catch {
1439
+ return null
1440
+ }
1441
+ }
1442
+ let recorded = 0
1443
+ for (const raw of value) {
1444
+ if (typeof raw !== "object" || raw === null) continue
1445
+ const event = raw as Params
1446
+ const kind = (LOCAL_EVENT_KINDS as readonly unknown[]).includes(event.kind) ? (event.kind as string) : null
1447
+ const at = typeof event.at === "number" && Number.isFinite(event.at) ? Math.min(Math.max(event.at, now - LOCAL_EVENTS.maxAgeMs), now) : null
1448
+ const from = resolve(event.from)
1449
+ if (!kind || at === null || !from || !Array.isArray(event.to) || event.to.length > LOCAL_EVENTS.recipients) continue
1450
+ const to = [...new Set(event.to.map(resolve).filter((id): id is string => id !== null && id !== from))]
1451
+ if (!to.length) continue
1452
+ for (const ep of to) this.deps.sql.all(`INSERT INTO local_events (from_ep, to_ep, at) VALUES (?, ?, ?)`, from, ep, at)
1453
+ this.fleet({
1454
+ type: "message",
1455
+ machine,
1456
+ local: true,
1457
+ ...(typeof event.id === "string" && HASH_ID.test(event.id) ? { id: event.id } : {}),
1458
+ ...(typeof event.thread === "string" && HASH_ID.test(event.thread) ? { thread: event.thread } : {}),
1459
+ kind,
1460
+ from: core.addressOf(from),
1461
+ to: to.map((ep) => core.addressOf(ep)),
1462
+ })
1463
+ recorded++
1464
+ }
1465
+ return recorded
1466
+ }
1467
+
1408
1468
  /** the body's bytes when the account can hold them; refuses a send past the `storedBytes` limit */
1409
1469
  private room(sender: string, body: unknown): number {
1410
1470
  if (typeof body !== "string") return 0
@@ -1647,9 +1707,13 @@ export class RelayHub {
1647
1707
  const edges: GraphEdge[] = nodes.flatMap((n) =>
1648
1708
  n.parent && n.parent !== n.address && listed.has(n.parent) ? [{ kind: "spawn" as const, from: n.parent, to: n.address }] : [],
1649
1709
  )
1710
+ const since = this.now() - window * 60_000
1650
1711
  const pairs = this.deps.sql.all<{ from_ep: string; to_ep: string; n: number; last: number }>(
1651
- `SELECT from_ep, to_ep, COUNT(*) AS n, MAX(at) AS last FROM pair_events WHERE at > ? GROUP BY from_ep, to_ep`,
1652
- this.now() - window * 60_000,
1712
+ `SELECT from_ep, to_ep, COUNT(*) AS n, MAX(at) AS last FROM (
1713
+ SELECT from_ep, to_ep, at FROM pair_events WHERE at > ? UNION ALL SELECT from_ep, to_ep, at FROM local_events WHERE at > ?
1714
+ ) GROUP BY from_ep, to_ep`,
1715
+ since,
1716
+ since,
1653
1717
  )
1654
1718
  const traffic: GraphEdge[] = pairs.flatMap((row) => {
1655
1719
  const from = byId.get(row.from_ep)
@@ -1657,7 +1721,9 @@ export class RelayHub {
1657
1721
  return from && to && from !== to ? [{ kind: "message" as const, from, to, count: row.n, last_at: iso(row.last)! }] : []
1658
1722
  })
1659
1723
  edges.push(...traffic.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to)))
1660
- return { minutes: window, machines: this.machineList(), nodes, edges }
1724
+ const actors = new Map(this.deps.sql.all<{ name: string; actor_id: string | null }>(`SELECT name, actor_id FROM machines`).map((r) => [r.name, r.actor_id]))
1725
+ const machines = this.machineList().map((m) => ({ ...m, actor: actors.get(m.name) ?? null }))
1726
+ return { minutes: window, machines, nodes, edges }
1661
1727
  }
1662
1728
 
1663
1729
  private machineList(): MachineRow[] {
@@ -1708,7 +1774,7 @@ export class RelayHub {
1708
1774
  return count
1709
1775
  })
1710
1776
  this.stored = null
1711
- this.toBridges(name, { machine: name, endpoints: [], full: true })
1777
+ this.toBridges(name, { machine: name, endpoints: [], full: true, released: true })
1712
1778
  this.dash({ type: "endpoints", at: new Date(this.now()).toISOString(), machine: name })
1713
1779
  this.deps.log?.("relay_action", { account: this.account, machine: name, method: "machine_release", code: "ok" })
1714
1780
  return { removed: name, endpoints: dropped }
@@ -1746,7 +1812,7 @@ export class RelayHub {
1746
1812
  core.record("machine_expire", null, name, { machine: name, endpoints: count })
1747
1813
  return count
1748
1814
  })
1749
- this.toBridges(name, { machine: name, endpoints: [], full: true })
1815
+ this.toBridges(name, { machine: name, endpoints: [], full: true, released: true })
1750
1816
  this.deps.log?.("relay_action", { account: this.account, machine: name, method: "machine_expire", code: "ok", endpoints: dropped })
1751
1817
  }
1752
1818
  }
@@ -1765,7 +1831,7 @@ export class RelayHub {
1765
1831
  for (const name of unbound) this.relay.record("machine_forget", null, name, { machine: name, endpoints: this.relay.dropMachine(name, { queue: true }) })
1766
1832
  return unbound
1767
1833
  })
1768
- 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 })
1769
1835
  }
1770
1836
 
1771
1837
  /** the hub_state keys (`denied:<tokenId>`) of tokens revoked lately, to when; loaded once per object lifetime */
package/src/index.ts CHANGED
@@ -18,9 +18,12 @@ export {
18
18
  GRAPH_MINUTES,
19
19
  GRAPH_MINUTES_MAX,
20
20
  type GraphEdge,
21
+ type GraphMachine,
21
22
  type GraphNode,
22
23
  type HubGraph,
23
24
  HUB_LIMITS,
25
+ LOCAL_EVENT_KINDS,
26
+ LOCAL_EVENTS,
24
27
  RelayHub,
25
28
  type HubAuth,
26
29
  type HubConn,