@scp-dev/hub 0.3.1 → 0.5.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
@@ -583,16 +583,41 @@ type GraphEdge = {
583
583
  count: number;
584
584
  last_at: string;
585
585
  };
586
+ /** 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 */
587
+ interface GraphMachine extends MachineRow {
588
+ actor: string | null;
589
+ }
586
590
  interface HubGraph {
587
591
  /** the window message edges count over */
588
592
  minutes: number;
589
- machines: MachineRow[];
593
+ machines: GraphMachine[];
590
594
  nodes: GraphNode[];
591
595
  edges: GraphEdge[];
592
596
  }
593
597
  /** the default and widest window of message edges; the core keeps an hour of sender and recipient pairs */
594
598
  declare const GRAPH_MINUTES = 15;
595
599
  declare const GRAPH_MINUTES_MAX = 60;
600
+ /** what a read-only dashboard stream sees as it happens: addresses, kinds and presence, never a body, note, summary or label */
601
+ type DashboardEvent = {
602
+ type: "presence";
603
+ at: string;
604
+ machine: string | null;
605
+ endpoint: string;
606
+ presence: Presence;
607
+ } | {
608
+ type: "message";
609
+ at: string;
610
+ kind: string;
611
+ from: string;
612
+ to: string[];
613
+ local?: true;
614
+ } | {
615
+ type: "endpoints";
616
+ at: string;
617
+ machine: string;
618
+ };
619
+ /** a watcher's fleet event as a dashboard sees it, field by field; null for the kinds a dashboard does not stream */
620
+ declare function dashboardEvent(event: Record<string, unknown>, at: string): DashboardEvent | null;
596
621
  interface HubSocket {
597
622
  conn: HubConn;
598
623
  send(text: string): void;
@@ -611,6 +636,8 @@ interface HubDeps {
611
636
  save(socket: HubSocket): void;
612
637
  /** structured log line, e.g. relay_action; never bodies or tokens */
613
638
  log?: (event: string, fields: Record<string, unknown>) => void;
639
+ /** live presence, endpoint and message metadata for a read-only dashboard; never a body */
640
+ dashboard?: (event: DashboardEvent) => void;
614
641
  /** overrides of {@link HUB_LIMITS}; `Infinity` disables a cap, count or byte limit, durations stay finite */
615
642
  limits?: Partial<HubLimits>;
616
643
  /** the link to other accounts' relays; without it an address of another account is not found */
@@ -646,6 +673,14 @@ declare const HUB_LIMITS: {
646
673
  type HubLimits = {
647
674
  [K in keyof typeof HUB_LIMITS]: number;
648
675
  };
676
+ /** kinds a bridge reports for a delivery its broker made on its own machine */
677
+ declare const LOCAL_EVENT_KINDS: readonly ["message", "request", "reply", "status", "handback", "spawn"];
678
+ /** events per `local_events` frame, recipients per event, and how far back an event's `at` may lie */
679
+ declare const LOCAL_EVENTS: {
680
+ readonly perFrame: 200;
681
+ readonly recipients: 64;
682
+ readonly maxAgeMs: 300000;
683
+ };
649
684
  /** the hosted relay of one account: machines, bridges, direct clients, push and replay over a FederatedCore; pure, sockets are injected */
650
685
  declare class RelayHub {
651
686
  private readonly deps;
@@ -777,6 +812,11 @@ declare class RelayHub {
777
812
  private deliverOn;
778
813
  private readonly deliverJudged;
779
814
  private delivered;
815
+ /**
816
+ * a bridge's metadata for deliveries its broker made on its own machine: counted for the graph and streamed to watchers and the
817
+ * dashboard as `local`; unknown endpoints and malformed events are skipped
818
+ */
819
+ private localEvents;
780
820
  /** the body's bytes when the account can hold them; refuses a send past the `storedBytes` limit */
781
821
  private room;
782
822
  /** bytes of message bodies held, and of the messages from other accounts */
@@ -793,6 +833,7 @@ declare class RelayHub {
793
833
  /** the machines of other members than the grant's, which a bridge shows under the account handle */
794
834
  private othersOf;
795
835
  private fleet;
836
+ private dash;
796
837
  private write;
797
838
  private save;
798
839
  private waitMs;
@@ -842,4 +883,4 @@ declare class RelayHub {
842
883
  xnotice(endpoint: string, text: string): void;
843
884
  }
844
885
 
845
- export { 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 };
886
+ 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,
@@ -497,9 +499,9 @@ var FederatedCore = class extends RelayCore {
497
499
  this.checkCap();
498
500
  row = this.byId(this.insertRemote(`${machine}/${handle}`, "agent", harness, auth, now));
499
501
  created = true;
500
- } else if (held.kind === "agent" && held.connections === 0 && held.harness === harness) {
502
+ } else if (held.kind === "agent" && held.connections === 0 && harnessFits(held.harness, harness)) {
501
503
  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) {
504
+ } 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
505
  row = held;
504
506
  joined = true;
505
507
  } else if (held.kind === "agent" && held.connections === 0 && (held.last_seen ?? held.created_at) < now - LIMITS.handleBoundS * 1e3) {
@@ -519,7 +521,7 @@ var FederatedCore = class extends RelayCore {
519
521
  const token = joined ? void 0 : `kre_${randomHex(32)}`;
520
522
  this.sql.all(
521
523
  `UPDATE endpoints SET harness = ?, model = ?, cwd = ?, parent = ?, labels = ?, token_hash = ${token ? "?" : "token_hash"}, auth = ?, connections = connections + 1, last_seen = ? WHERE id = ?`,
522
- harness,
524
+ keptHarness(row.harness, harness),
523
525
  fields.model,
524
526
  fields.cwd,
525
527
  fields.parent,
@@ -2012,6 +2014,21 @@ function heldResult(id, params) {
2012
2014
  // src/hub.ts
2013
2015
  var GRAPH_MINUTES = 15;
2014
2016
  var GRAPH_MINUTES_MAX = 60;
2017
+ var str = (v) => typeof v === "string" ? v : null;
2018
+ function dashboardEvent(event, at) {
2019
+ if (event.type === "message") {
2020
+ const from = str(event.from);
2021
+ const to = Array.isArray(event.to) ? event.to.filter((a) => typeof a === "string") : [];
2022
+ if (!from || !to.length) return null;
2023
+ return { type: "message", at, kind: str(event.kind) ?? "message", from, to, ...event.local === true ? { local: true } : {} };
2024
+ }
2025
+ if (event.type === "presence") {
2026
+ const endpoint = str(event.endpoint);
2027
+ const presence = event.presence === "online" || event.presence === "idle" || event.presence === "offline" ? event.presence : null;
2028
+ return endpoint && presence ? { type: "presence", at, machine: str(event.machine), endpoint, presence } : null;
2029
+ }
2030
+ return null;
2031
+ }
2015
2032
  var HUB_LIMITS = {
2016
2033
  machines: 16,
2017
2034
  endpoints: 512,
@@ -2041,8 +2058,14 @@ var TOUCH_MS = 1e4;
2041
2058
  var HUB_MIGRATION = [
2042
2059
  `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)`,
2043
2060
  `CREATE TABLE IF NOT EXISTS hub_state (key TEXT PRIMARY KEY, value INTEGER NOT NULL)`,
2044
- `CREATE TABLE IF NOT EXISTS machine_tombstones (name TEXT PRIMARY KEY, actor_id TEXT, user_id TEXT, at INTEGER NOT NULL)`
2061
+ `CREATE TABLE IF NOT EXISTS machine_tombstones (name TEXT PRIMARY KEY, actor_id TEXT, user_id TEXT, at INTEGER NOT NULL)`,
2062
+ `CREATE TABLE IF NOT EXISTS local_events (from_ep TEXT NOT NULL, to_ep TEXT NOT NULL, at INTEGER NOT NULL)`,
2063
+ `CREATE INDEX IF NOT EXISTS local_events_at ON local_events (at)`
2045
2064
  ];
2065
+ var LOCAL_EVENT_KINDS = ["message", "request", "reply", "status", "handback", "spawn"];
2066
+ var LOCAL_EVENTS = { perFrame: 200, recipients: 64, maxAgeMs: 3e5 };
2067
+ var LOCAL_KEEP_MS = 36e5;
2068
+ var HASH_ID = /^[0-9a-f]{8,64}$/;
2046
2069
  var OBSERVER_METHODS = /* @__PURE__ */ new Set(["list", "snapshot", "watch", "log", "machines", "machines_rm"]);
2047
2070
  var CLIENT_METHODS = /* @__PURE__ */ new Set(["whoami", "send", "ask", "ask_wait", "inbox", "ack", "status", "event", ...OBSERVER_METHODS]);
2048
2071
  var iso2 = (ms) => ms === null ? null : new Date(ms).toISOString();
@@ -2321,6 +2344,7 @@ var RelayHub = class _RelayHub {
2321
2344
  this.setState("last_validated", now);
2322
2345
  if (now - (this.state("last_sweep") ?? 0) >= SWEEP_MS) {
2323
2346
  this.relay.sweep();
2347
+ this.deps.sql.all(`DELETE FROM local_events WHERE at <= ?`, now - LOCAL_KEEP_MS);
2324
2348
  this.expireMachines(now);
2325
2349
  this.forgetTombstones(now);
2326
2350
  const denied = this.deniedGrants();
@@ -2776,6 +2800,8 @@ var RelayHub = class _RelayHub {
2776
2800
  this.fleet({ type: "activity", machine, ...row });
2777
2801
  return row;
2778
2802
  }
2803
+ case "local_events":
2804
+ return { recorded: this.localEvents(machine, params.events) };
2779
2805
  case "list":
2780
2806
  if (params.contacts === true) return { contacts: await this.contactList() };
2781
2807
  return { endpoints: core.list(listFilters(params)) };
@@ -3123,6 +3149,47 @@ var RelayHub = class _RelayHub {
3123
3149
  hop: result.hop
3124
3150
  });
3125
3151
  }
3152
+ /**
3153
+ * a bridge's metadata for deliveries its broker made on its own machine: counted for the graph and streamed to watchers and the
3154
+ * dashboard as `local`; unknown endpoints and malformed events are skipped
3155
+ */
3156
+ localEvents(machine, value) {
3157
+ if (!Array.isArray(value) || value.length > LOCAL_EVENTS.perFrame)
3158
+ throw new RelayError3("invalid_params", `events is a list of at most ${LOCAL_EVENTS.perFrame}`);
3159
+ const core = this.relay;
3160
+ const now = this.now();
3161
+ const resolve = (handle) => {
3162
+ try {
3163
+ return core.remoteEndpoint(machine, handle);
3164
+ } catch {
3165
+ return null;
3166
+ }
3167
+ };
3168
+ let recorded = 0;
3169
+ for (const raw of value) {
3170
+ if (typeof raw !== "object" || raw === null) continue;
3171
+ const event = raw;
3172
+ const kind = LOCAL_EVENT_KINDS.includes(event.kind) ? event.kind : null;
3173
+ const at = typeof event.at === "number" && Number.isFinite(event.at) ? Math.min(Math.max(event.at, now - LOCAL_EVENTS.maxAgeMs), now) : null;
3174
+ const from = resolve(event.from);
3175
+ if (!kind || at === null || !from || !Array.isArray(event.to) || event.to.length > LOCAL_EVENTS.recipients) continue;
3176
+ const to = [...new Set(event.to.map(resolve).filter((id) => id !== null && id !== from))];
3177
+ if (!to.length) continue;
3178
+ for (const ep of to) this.deps.sql.all(`INSERT INTO local_events (from_ep, to_ep, at) VALUES (?, ?, ?)`, from, ep, at);
3179
+ this.fleet({
3180
+ type: "message",
3181
+ machine,
3182
+ local: true,
3183
+ ...typeof event.id === "string" && HASH_ID.test(event.id) ? { id: event.id } : {},
3184
+ ...typeof event.thread === "string" && HASH_ID.test(event.thread) ? { thread: event.thread } : {},
3185
+ kind,
3186
+ from: core.addressOf(from),
3187
+ to: to.map((ep) => core.addressOf(ep))
3188
+ });
3189
+ recorded++;
3190
+ }
3191
+ return recorded;
3192
+ }
3126
3193
  /** the body's bytes when the account can hold them; refuses a send past the `storedBytes` limit */
3127
3194
  room(sender, body) {
3128
3195
  if (typeof body !== "string") return 0;
@@ -3170,6 +3237,7 @@ var RelayHub = class _RelayHub {
3170
3237
  this.fleet({ type: "presence", machine, endpoint: view.address, presence: view.presence, harness: view.harness, labels: view.labels });
3171
3238
  if (change.status && !change.added) this.fleet({ type: "status", machine, endpoint: view.address, status: view.status });
3172
3239
  }
3240
+ if (full || changes.some((c) => c.added || c.was !== void 0)) this.dash({ type: "endpoints", at: new Date(this.now()).toISOString(), machine });
3173
3241
  }
3174
3242
  /** a direct client's endpoint changed: the other bridges mirror it */
3175
3243
  announceEndpoint(endpoint) {
@@ -3194,6 +3262,15 @@ var RelayHub = class _RelayHub {
3194
3262
  fleet(event) {
3195
3263
  const at = new Date(this.now()).toISOString();
3196
3264
  for (const socket of this.deps.sockets()) if (socket.conn.watching) this.write(socket, { jsonrpc: "2.0", method: "fleet", params: { at, ...event } });
3265
+ const dash = this.deps.dashboard ? dashboardEvent(event, at) : null;
3266
+ if (dash) this.dash(dash);
3267
+ }
3268
+ dash(event) {
3269
+ try {
3270
+ this.deps.dashboard?.(event);
3271
+ } catch {
3272
+ this.deps.log?.("relay_dashboard_failed", { account: this.account });
3273
+ }
3197
3274
  }
3198
3275
  write(socket, value) {
3199
3276
  if (this.gone.has(socket.conn.id)) return;
@@ -3330,9 +3407,13 @@ var RelayHub = class _RelayHub {
3330
3407
  const edges = nodes.flatMap(
3331
3408
  (n) => n.parent && n.parent !== n.address && listed.has(n.parent) ? [{ kind: "spawn", from: n.parent, to: n.address }] : []
3332
3409
  );
3410
+ const since = this.now() - window * 6e4;
3333
3411
  const pairs = this.deps.sql.all(
3334
- `SELECT from_ep, to_ep, COUNT(*) AS n, MAX(at) AS last FROM pair_events WHERE at > ? GROUP BY from_ep, to_ep`,
3335
- this.now() - window * 6e4
3412
+ `SELECT from_ep, to_ep, COUNT(*) AS n, MAX(at) AS last FROM (
3413
+ SELECT from_ep, to_ep, at FROM pair_events WHERE at > ? UNION ALL SELECT from_ep, to_ep, at FROM local_events WHERE at > ?
3414
+ ) GROUP BY from_ep, to_ep`,
3415
+ since,
3416
+ since
3336
3417
  );
3337
3418
  const traffic = pairs.flatMap((row) => {
3338
3419
  const from = byId.get(row.from_ep);
@@ -3340,7 +3421,9 @@ var RelayHub = class _RelayHub {
3340
3421
  return from && to && from !== to ? [{ kind: "message", from, to, count: row.n, last_at: iso2(row.last) }] : [];
3341
3422
  });
3342
3423
  edges.push(...traffic.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to)));
3343
- return { minutes: window, machines: this.machineList(), nodes, edges };
3424
+ const actors = new Map(this.deps.sql.all(`SELECT name, actor_id FROM machines`).map((r) => [r.name, r.actor_id]));
3425
+ const machines = this.machineList().map((m) => ({ ...m, actor: actors.get(m.name) ?? null }));
3426
+ return { minutes: window, machines, nodes, edges };
3344
3427
  }
3345
3428
  machineList() {
3346
3429
  const sockets = this.deps.sockets().filter((s) => !this.gone.has(s.conn.id));
@@ -3386,6 +3469,7 @@ var RelayHub = class _RelayHub {
3386
3469
  });
3387
3470
  this.stored = null;
3388
3471
  this.toBridges(name, { machine: name, endpoints: [], full: true });
3472
+ this.dash({ type: "endpoints", at: new Date(this.now()).toISOString(), machine: name });
3389
3473
  this.deps.log?.("relay_action", { account: this.account, machine: name, method: "machine_release", code: "ok" });
3390
3474
  return { removed: name, endpoints: dropped };
3391
3475
  }
@@ -3497,6 +3581,9 @@ export {
3497
3581
  HUB_LIMITS,
3498
3582
  JUDGE_LIMITS,
3499
3583
  JudgeGate,
3584
+ LOCAL_EVENTS,
3585
+ LOCAL_EVENT_KINDS,
3500
3586
  MACHINE_NAME,
3501
- RelayHub
3587
+ RelayHub,
3588
+ dashboardEvent
3502
3589
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/hub",
3
- "version": "0.3.1",
3
+ "version": "0.5.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.4"
32
+ "@scp-dev/core": "0.1.5"
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,
@@ -734,7 +736,7 @@ export class FederatedCore extends RelayCore {
734
736
  this.checkCap()
735
737
  row = this.byId(this.insertRemote(`${machine}/${handle}`, "agent", harness, auth, now)) as FederatedRow
736
738
  created = true
737
- } else if (held.kind === "agent" && held.connections === 0 && held.harness === harness) {
739
+ } else if (held.kind === "agent" && held.connections === 0 && harnessFits(held.harness, harness)) {
738
740
  row = held
739
741
  } else if (
740
742
  exact &&
@@ -743,7 +745,7 @@ export class FederatedCore extends RelayCore {
743
745
  held.process === null &&
744
746
  held.session === null &&
745
747
  held.parent_ep === null &&
746
- held.harness === harness
748
+ harnessFits(held.harness, harness)
747
749
  ) {
748
750
  row = held
749
751
  joined = true
@@ -764,7 +766,7 @@ export class FederatedCore extends RelayCore {
764
766
  const token = joined ? undefined : `kre_${randomHex(32)}`
765
767
  this.sql.all(
766
768
  `UPDATE endpoints SET harness = ?, model = ?, cwd = ?, parent = ?, labels = ?, token_hash = ${token ? "?" : "token_hash"}, auth = ?, connections = connections + 1, last_seen = ? WHERE id = ?`,
767
- harness,
769
+ keptHarness(row.harness, harness),
768
770
  fields.model,
769
771
  fields.cwd,
770
772
  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
  }
@@ -123,6 +128,30 @@ export interface HubGraph {
123
128
  export const GRAPH_MINUTES = 15
124
129
  export const GRAPH_MINUTES_MAX = 60
125
130
 
131
+ /** what a read-only dashboard stream sees as it happens: addresses, kinds and presence, never a body, note, summary or label */
132
+ export type DashboardEvent =
133
+ | { type: "presence"; at: string; machine: string | null; endpoint: string; presence: Presence }
134
+ | { type: "message"; at: string; kind: string; from: string; to: string[]; local?: true }
135
+ | { type: "endpoints"; at: string; machine: string }
136
+
137
+ const str = (v: unknown): string | null => (typeof v === "string" ? v : null)
138
+
139
+ /** a watcher's fleet event as a dashboard sees it, field by field; null for the kinds a dashboard does not stream */
140
+ export function dashboardEvent(event: Record<string, unknown>, at: string): DashboardEvent | null {
141
+ if (event.type === "message") {
142
+ const from = str(event.from)
143
+ const to = Array.isArray(event.to) ? event.to.filter((a): a is string => typeof a === "string") : []
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 } : {}) }
146
+ }
147
+ if (event.type === "presence") {
148
+ const endpoint = str(event.endpoint)
149
+ const presence = event.presence === "online" || event.presence === "idle" || event.presence === "offline" ? event.presence : null
150
+ return endpoint && presence ? { type: "presence", at, machine: str(event.machine), endpoint, presence } : null
151
+ }
152
+ return null
153
+ }
154
+
126
155
  export interface HubSocket {
127
156
  conn: HubConn
128
157
  send(text: string): void
@@ -142,6 +171,8 @@ export interface HubDeps {
142
171
  save(socket: HubSocket): void
143
172
  /** structured log line, e.g. relay_action; never bodies or tokens */
144
173
  log?: (event: string, fields: Record<string, unknown>) => void
174
+ /** live presence, endpoint and message metadata for a read-only dashboard; never a body */
175
+ dashboard?: (event: DashboardEvent) => void
145
176
  /** overrides of {@link HUB_LIMITS}; `Infinity` disables a cap, count or byte limit, durations stay finite */
146
177
  limits?: Partial<HubLimits>
147
178
  /** the link to other accounts' relays; without it an address of another account is not found */
@@ -193,8 +224,17 @@ const HUB_MIGRATION = [
193
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)`,
194
225
  `CREATE TABLE IF NOT EXISTS hub_state (key TEXT PRIMARY KEY, value INTEGER NOT NULL)`,
195
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)`,
196
229
  ]
197
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
+
198
238
  const OBSERVER_METHODS = new Set(["list", "snapshot", "watch", "log", "machines", "machines_rm"])
199
239
  const CLIENT_METHODS = new Set(["whoami", "send", "ask", "ask_wait", "inbox", "ack", "status", "event", ...OBSERVER_METHODS])
200
240
 
@@ -517,6 +557,7 @@ export class RelayHub {
517
557
  this.setState("last_validated", now)
518
558
  if (now - (this.state("last_sweep") ?? 0) >= SWEEP_MS) {
519
559
  this.relay.sweep()
560
+ this.deps.sql.all(`DELETE FROM local_events WHERE at <= ?`, now - LOCAL_KEEP_MS)
520
561
  this.expireMachines(now)
521
562
  this.forgetTombstones(now)
522
563
  const denied = this.deniedGrants()
@@ -999,6 +1040,8 @@ export class RelayHub {
999
1040
  this.fleet({ type: "activity", machine, ...row })
1000
1041
  return row
1001
1042
  }
1043
+ case "local_events":
1044
+ return { recorded: this.localEvents(machine, params.events) }
1002
1045
  case "list":
1003
1046
  if (params.contacts === true) return { contacts: await this.contactList() }
1004
1047
  return { endpoints: core.list(listFilters(params)) }
@@ -1380,6 +1423,48 @@ export class RelayHub {
1380
1423
  })
1381
1424
  }
1382
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
+
1383
1468
  /** the body's bytes when the account can hold them; refuses a send past the `storedBytes` limit */
1384
1469
  private room(sender: string, body: unknown): number {
1385
1470
  if (typeof body !== "string") return 0
@@ -1431,6 +1516,7 @@ export class RelayHub {
1431
1516
  this.fleet({ type: "presence", machine, endpoint: view.address, presence: view.presence, harness: view.harness, labels: view.labels })
1432
1517
  if (change.status && !change.added) this.fleet({ type: "status", machine, endpoint: view.address, status: view.status })
1433
1518
  }
1519
+ if (full || changes.some((c) => c.added || c.was !== undefined)) this.dash({ type: "endpoints", at: new Date(this.now()).toISOString(), machine })
1434
1520
  }
1435
1521
 
1436
1522
  /** a direct client's endpoint changed: the other bridges mirror it */
@@ -1461,6 +1547,16 @@ export class RelayHub {
1461
1547
  private fleet(event: Record<string, unknown>): void {
1462
1548
  const at = new Date(this.now()).toISOString()
1463
1549
  for (const socket of this.deps.sockets()) if (socket.conn.watching) this.write(socket, { jsonrpc: "2.0", method: "fleet", params: { at, ...event } })
1550
+ const dash = this.deps.dashboard ? dashboardEvent(event, at) : null
1551
+ if (dash) this.dash(dash)
1552
+ }
1553
+
1554
+ private dash(event: DashboardEvent): void {
1555
+ try {
1556
+ this.deps.dashboard?.(event)
1557
+ } catch {
1558
+ this.deps.log?.("relay_dashboard_failed", { account: this.account })
1559
+ }
1464
1560
  }
1465
1561
 
1466
1562
  private write(socket: HubSocket, value: unknown): void {
@@ -1611,9 +1707,13 @@ export class RelayHub {
1611
1707
  const edges: GraphEdge[] = nodes.flatMap((n) =>
1612
1708
  n.parent && n.parent !== n.address && listed.has(n.parent) ? [{ kind: "spawn" as const, from: n.parent, to: n.address }] : [],
1613
1709
  )
1710
+ const since = this.now() - window * 60_000
1614
1711
  const pairs = this.deps.sql.all<{ from_ep: string; to_ep: string; n: number; last: number }>(
1615
- `SELECT from_ep, to_ep, COUNT(*) AS n, MAX(at) AS last FROM pair_events WHERE at > ? GROUP BY from_ep, to_ep`,
1616
- 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,
1617
1717
  )
1618
1718
  const traffic: GraphEdge[] = pairs.flatMap((row) => {
1619
1719
  const from = byId.get(row.from_ep)
@@ -1621,7 +1721,9 @@ export class RelayHub {
1621
1721
  return from && to && from !== to ? [{ kind: "message" as const, from, to, count: row.n, last_at: iso(row.last)! }] : []
1622
1722
  })
1623
1723
  edges.push(...traffic.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to)))
1624
- 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 }
1625
1727
  }
1626
1728
 
1627
1729
  private machineList(): MachineRow[] {
@@ -1673,6 +1775,7 @@ export class RelayHub {
1673
1775
  })
1674
1776
  this.stored = null
1675
1777
  this.toBridges(name, { machine: name, endpoints: [], full: true })
1778
+ this.dash({ type: "endpoints", at: new Date(this.now()).toISOString(), machine: name })
1676
1779
  this.deps.log?.("relay_action", { account: this.account, machine: name, method: "machine_release", code: "ok" })
1677
1780
  return { removed: name, endpoints: dropped }
1678
1781
  }
package/src/index.ts CHANGED
@@ -13,12 +13,17 @@ export {
13
13
  type XEndpoint,
14
14
  } from "./federation"
15
15
  export {
16
+ type DashboardEvent,
17
+ dashboardEvent,
16
18
  GRAPH_MINUTES,
17
19
  GRAPH_MINUTES_MAX,
18
20
  type GraphEdge,
21
+ type GraphMachine,
19
22
  type GraphNode,
20
23
  type HubGraph,
21
24
  HUB_LIMITS,
25
+ LOCAL_EVENT_KINDS,
26
+ LOCAL_EVENTS,
22
27
  RelayHub,
23
28
  type HubAuth,
24
29
  type HubConn,