@scp-dev/hub 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as _scp_dev_core from '@scp-dev/core';
2
- import { Envelope, RelayCore, CoreOptions, EndpointRow, PolicySubject, ScopeSubject, ScopeNode, ScopeDirection, Admission, MessageRow, EndpointView, ScopeCap, ScopeEntry, Claim, SendParams, SendResult, EndpointKind, Presence, StatusState, Verdict, JudgeModelConfig, JudgeClient, Sql, JudgePolicy, CrossAccountLink, XDelivered, XListed, ActivityRow, XRelease } 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';
3
3
 
4
4
  type RelayAuth = Envelope["from"]["auth"];
5
5
  /** a machine name: one relay host of an account */
@@ -59,6 +59,8 @@ interface MirrorView extends MirrorScope {
59
59
  presence?: Presence;
60
60
  last_seen?: string | null;
61
61
  title?: string | null;
62
+ /** an agent's {@link Wake}; a value this hub does not know is kept as none */
63
+ wake?: string | null;
62
64
  /** the handle the endpoint had before a rename: its mirror is renamed in place */
63
65
  was?: string;
64
66
  }
@@ -567,7 +569,7 @@ interface GraphNode {
567
569
  status: EndpointView["status"];
568
570
  last_seen: string | null;
569
571
  parent: string | null;
570
- wake?: string | null;
572
+ wake?: Wake | null;
571
573
  }
572
574
  /** `spawn`: parent to child; `message`: sender to recipient, with the count and newest send in the graph's window */
573
575
  type GraphEdge = {
@@ -591,6 +593,26 @@ interface HubGraph {
591
593
  /** the default and widest window of message edges; the core keeps an hour of sender and recipient pairs */
592
594
  declare const GRAPH_MINUTES = 15;
593
595
  declare const GRAPH_MINUTES_MAX = 60;
596
+ /** what a read-only dashboard stream sees as it happens: addresses, kinds and presence, never a body, note, summary or label */
597
+ type DashboardEvent = {
598
+ type: "presence";
599
+ at: string;
600
+ machine: string | null;
601
+ endpoint: string;
602
+ presence: Presence;
603
+ } | {
604
+ type: "message";
605
+ at: string;
606
+ kind: string;
607
+ from: string;
608
+ to: string[];
609
+ } | {
610
+ type: "endpoints";
611
+ at: string;
612
+ machine: string;
613
+ };
614
+ /** a watcher's fleet event as a dashboard sees it, field by field; null for the kinds a dashboard does not stream */
615
+ declare function dashboardEvent(event: Record<string, unknown>, at: string): DashboardEvent | null;
594
616
  interface HubSocket {
595
617
  conn: HubConn;
596
618
  send(text: string): void;
@@ -609,6 +631,8 @@ interface HubDeps {
609
631
  save(socket: HubSocket): void;
610
632
  /** structured log line, e.g. relay_action; never bodies or tokens */
611
633
  log?: (event: string, fields: Record<string, unknown>) => void;
634
+ /** live presence, endpoint and message metadata for a read-only dashboard; never a body */
635
+ dashboard?: (event: DashboardEvent) => void;
612
636
  /** overrides of {@link HUB_LIMITS}; `Infinity` disables a cap, count or byte limit, durations stay finite */
613
637
  limits?: Partial<HubLimits>;
614
638
  /** the link to other accounts' relays; without it an address of another account is not found */
@@ -791,6 +815,7 @@ declare class RelayHub {
791
815
  /** the machines of other members than the grant's, which a bridge shows under the account handle */
792
816
  private othersOf;
793
817
  private fleet;
818
+ private dash;
794
819
  private write;
795
820
  private save;
796
821
  private waitMs;
@@ -840,4 +865,4 @@ declare class RelayHub {
840
865
  xnotice(endpoint: string, text: string): void;
841
866
  }
842
867
 
843
- 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 };
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 };
package/dist/index.js CHANGED
@@ -22,7 +22,8 @@ import {
22
22
  unreachable,
23
23
  validHandle,
24
24
  validHarness,
25
- validSelector
25
+ validSelector,
26
+ WAKES
26
27
  } from "@scp-dev/core";
27
28
  var MACHINE_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/;
28
29
  var REMOTE = /^[a-z0-9][a-z0-9._-]{0,63}\/([a-z0-9][a-z0-9._-]{0,62})?$/;
@@ -1283,7 +1284,7 @@ var FederatedCore = class extends RelayCore {
1283
1284
  const was = this.view(before);
1284
1285
  const presence = was.presence !== view.presence;
1285
1286
  const status = before.status_state !== after.status_state || before.status_note !== after.status_note;
1286
- const other = ["kind", "harness", "model", "cwd", "labels", "title", "tree", "parent_ep", "depth", "rules", "caps", "incarnation"].some(
1287
+ const other = ["kind", "harness", "model", "cwd", "labels", "title", "wake", "tree", "parent_ep", "depth", "rules", "caps", "incarnation"].some(
1287
1288
  (field) => before[field] !== after[field]
1288
1289
  );
1289
1290
  return presence || status || other ? { view, added: false, presence, status } : null;
@@ -1351,6 +1352,7 @@ var FederatedCore = class extends RelayCore {
1351
1352
  rules: rules === void 0 ? row.rules : rules && (rules.in || rules.out) ? JSON.stringify(rules) : null,
1352
1353
  caps,
1353
1354
  title: scope.title !== void 0 ? scope.title : fresh ? null : row.title,
1355
+ wake: scope.wake !== void 0 ? scope.wake : fresh ? null : row.wake,
1354
1356
  incarnation: scope.incarnation ?? row.incarnation
1355
1357
  };
1356
1358
  }
@@ -1504,6 +1506,7 @@ function parseScope(view) {
1504
1506
  throw new RelayError("too_large", `an endpoint's rules and caps are more than ${SCOPE_BYTES} bytes`);
1505
1507
  if (given("title"))
1506
1508
  out.title = view.title === null ? null : cleanText(typeof view.title === "string" ? view.title.replace(/\s+/g, " ") : view.title, "title", 64) || null;
1509
+ if (given("wake")) out.wake = typeof view.wake === "string" && view.wake !== "none" && WAKES.includes(view.wake) ? view.wake : null;
1507
1510
  if (given("incarnation")) {
1508
1511
  if (view.incarnation !== null && (typeof view.incarnation !== "string" || !INCARNATION.test(view.incarnation))) throw bad("incarnation");
1509
1512
  out.incarnation = view.incarnation ?? null;
@@ -2009,6 +2012,20 @@ function heldResult(id, params) {
2009
2012
  // src/hub.ts
2010
2013
  var GRAPH_MINUTES = 15;
2011
2014
  var GRAPH_MINUTES_MAX = 60;
2015
+ var str = (v) => typeof v === "string" ? v : null;
2016
+ function dashboardEvent(event, at) {
2017
+ if (event.type === "message") {
2018
+ const from = str(event.from);
2019
+ 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;
2021
+ }
2022
+ if (event.type === "presence") {
2023
+ const endpoint = str(event.endpoint);
2024
+ const presence = event.presence === "online" || event.presence === "idle" || event.presence === "offline" ? event.presence : null;
2025
+ return endpoint && presence ? { type: "presence", at, machine: str(event.machine), endpoint, presence } : null;
2026
+ }
2027
+ return null;
2028
+ }
2012
2029
  var HUB_LIMITS = {
2013
2030
  machines: 16,
2014
2031
  endpoints: 512,
@@ -3167,6 +3184,7 @@ var RelayHub = class _RelayHub {
3167
3184
  this.fleet({ type: "presence", machine, endpoint: view.address, presence: view.presence, harness: view.harness, labels: view.labels });
3168
3185
  if (change.status && !change.added) this.fleet({ type: "status", machine, endpoint: view.address, status: view.status });
3169
3186
  }
3187
+ if (full || changes.some((c) => c.added || c.was !== void 0)) this.dash({ type: "endpoints", at: new Date(this.now()).toISOString(), machine });
3170
3188
  }
3171
3189
  /** a direct client's endpoint changed: the other bridges mirror it */
3172
3190
  announceEndpoint(endpoint) {
@@ -3191,6 +3209,15 @@ var RelayHub = class _RelayHub {
3191
3209
  fleet(event) {
3192
3210
  const at = new Date(this.now()).toISOString();
3193
3211
  for (const socket of this.deps.sockets()) if (socket.conn.watching) this.write(socket, { jsonrpc: "2.0", method: "fleet", params: { at, ...event } });
3212
+ const dash = this.deps.dashboard ? dashboardEvent(event, at) : null;
3213
+ if (dash) this.dash(dash);
3214
+ }
3215
+ dash(event) {
3216
+ try {
3217
+ this.deps.dashboard?.(event);
3218
+ } catch {
3219
+ this.deps.log?.("relay_dashboard_failed", { account: this.account });
3220
+ }
3194
3221
  }
3195
3222
  write(socket, value) {
3196
3223
  if (this.gone.has(socket.conn.id)) return;
@@ -3383,6 +3410,7 @@ var RelayHub = class _RelayHub {
3383
3410
  });
3384
3411
  this.stored = null;
3385
3412
  this.toBridges(name, { machine: name, endpoints: [], full: true });
3413
+ this.dash({ type: "endpoints", at: new Date(this.now()).toISOString(), machine: name });
3386
3414
  this.deps.log?.("relay_action", { account: this.account, machine: name, method: "machine_release", code: "ok" });
3387
3415
  return { removed: name, endpoints: dropped };
3388
3416
  }
@@ -3495,5 +3523,6 @@ export {
3495
3523
  JUDGE_LIMITS,
3496
3524
  JudgeGate,
3497
3525
  MACHINE_NAME,
3498
- RelayHub
3526
+ RelayHub,
3527
+ dashboardEvent
3499
3528
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/hub",
3
- "version": "0.3.0",
3
+ "version": "0.4.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.3"
32
+ "@scp-dev/core": "0.1.4"
33
33
  },
34
34
  "devDependencies": {
35
35
  "tsup": "^8.5.1"
package/src/federation.ts CHANGED
@@ -43,6 +43,8 @@ import {
43
43
  validHandle,
44
44
  validHarness,
45
45
  validSelector,
46
+ type Wake,
47
+ WAKES,
46
48
  } from "@scp-dev/core"
47
49
 
48
50
  export type RelayAuth = Envelope["from"]["auth"]
@@ -121,6 +123,8 @@ export interface MirrorView extends MirrorScope {
121
123
  presence?: Presence
122
124
  last_seen?: string | null
123
125
  title?: string | null
126
+ /** an agent's {@link Wake}; a value this hub does not know is kept as none */
127
+ wake?: string | null
124
128
  /** the handle the endpoint had before a rename: its mirror is renamed in place */
125
129
  was?: string
126
130
  }
@@ -142,6 +146,7 @@ interface ParsedScope {
142
146
  rules?: MirrorRules | null
143
147
  caps?: ScopeCaps | null
144
148
  title?: string | null
149
+ wake?: Wake | null
145
150
  incarnation?: string | null
146
151
  }
147
152
 
@@ -1609,7 +1614,7 @@ export class FederatedCore extends RelayCore {
1609
1614
  const was = this.view(before)
1610
1615
  const presence = was.presence !== view.presence
1611
1616
  const status = before.status_state !== after.status_state || before.status_note !== after.status_note
1612
- const other = (["kind", "harness", "model", "cwd", "labels", "title", "tree", "parent_ep", "depth", "rules", "caps", "incarnation"] as const).some(
1617
+ const other = (["kind", "harness", "model", "cwd", "labels", "title", "wake", "tree", "parent_ep", "depth", "rules", "caps", "incarnation"] as const).some(
1613
1618
  (field) => before[field] !== (after as FederatedRow)[field],
1614
1619
  )
1615
1620
  return presence || status || other ? { view, added: false, presence, status } : null
@@ -1682,6 +1687,7 @@ export class FederatedCore extends RelayCore {
1682
1687
  rules: rules === undefined ? row.rules : rules && (rules.in || rules.out) ? JSON.stringify(rules) : null,
1683
1688
  caps,
1684
1689
  title: scope.title !== undefined ? scope.title : fresh ? null : row.title,
1690
+ wake: scope.wake !== undefined ? scope.wake : fresh ? null : row.wake,
1685
1691
  incarnation: scope.incarnation ?? row.incarnation,
1686
1692
  }
1687
1693
  }
@@ -1860,6 +1866,7 @@ function parseScope(view: Partial<MirrorView>): ParsedScope {
1860
1866
  throw new RelayError("too_large", `an endpoint's rules and caps are more than ${SCOPE_BYTES} bytes`)
1861
1867
  if (given("title"))
1862
1868
  out.title = view.title === null ? null : cleanText(typeof view.title === "string" ? view.title.replace(/\s+/g, " ") : view.title, "title", 64) || null
1869
+ if (given("wake")) out.wake = typeof view.wake === "string" && view.wake !== "none" && WAKES.includes(view.wake as Wake) ? (view.wake as Wake) : null
1863
1870
  if (given("incarnation")) {
1864
1871
  if (view.incarnation !== null && (typeof view.incarnation !== "string" || !INCARNATION.test(view.incarnation))) throw bad("incarnation")
1865
1872
  out.incarnation = view.incarnation ?? null
package/src/hub.ts CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  SYSTEM_HANDLE,
39
39
  sameSecret,
40
40
  untilTomorrow,
41
+ type Wake,
41
42
  type XContact,
42
43
  XCounters,
43
44
  type XDelivered,
@@ -104,7 +105,7 @@ export interface GraphNode {
104
105
  status: EndpointView["status"]
105
106
  last_seen: string | null
106
107
  parent: string | null
107
- wake?: string | null
108
+ wake?: Wake | null
108
109
  }
109
110
 
110
111
  /** `spawn`: parent to child; `message`: sender to recipient, with the count and newest send in the graph's window */
@@ -122,6 +123,29 @@ export interface HubGraph {
122
123
  export const GRAPH_MINUTES = 15
123
124
  export const GRAPH_MINUTES_MAX = 60
124
125
 
126
+ /** what a read-only dashboard stream sees as it happens: addresses, kinds and presence, never a body, note, summary or label */
127
+ export type DashboardEvent =
128
+ | { type: "presence"; at: string; machine: string | null; endpoint: string; presence: Presence }
129
+ | { type: "message"; at: string; kind: string; from: string; to: string[] }
130
+ | { type: "endpoints"; at: string; machine: string }
131
+
132
+ const str = (v: unknown): string | null => (typeof v === "string" ? v : null)
133
+
134
+ /** a watcher's fleet event as a dashboard sees it, field by field; null for the kinds a dashboard does not stream */
135
+ export function dashboardEvent(event: Record<string, unknown>, at: string): DashboardEvent | null {
136
+ if (event.type === "message") {
137
+ const from = str(event.from)
138
+ 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
140
+ }
141
+ if (event.type === "presence") {
142
+ const endpoint = str(event.endpoint)
143
+ const presence = event.presence === "online" || event.presence === "idle" || event.presence === "offline" ? event.presence : null
144
+ return endpoint && presence ? { type: "presence", at, machine: str(event.machine), endpoint, presence } : null
145
+ }
146
+ return null
147
+ }
148
+
125
149
  export interface HubSocket {
126
150
  conn: HubConn
127
151
  send(text: string): void
@@ -141,6 +165,8 @@ export interface HubDeps {
141
165
  save(socket: HubSocket): void
142
166
  /** structured log line, e.g. relay_action; never bodies or tokens */
143
167
  log?: (event: string, fields: Record<string, unknown>) => void
168
+ /** live presence, endpoint and message metadata for a read-only dashboard; never a body */
169
+ dashboard?: (event: DashboardEvent) => void
144
170
  /** overrides of {@link HUB_LIMITS}; `Infinity` disables a cap, count or byte limit, durations stay finite */
145
171
  limits?: Partial<HubLimits>
146
172
  /** the link to other accounts' relays; without it an address of another account is not found */
@@ -1430,6 +1456,7 @@ export class RelayHub {
1430
1456
  this.fleet({ type: "presence", machine, endpoint: view.address, presence: view.presence, harness: view.harness, labels: view.labels })
1431
1457
  if (change.status && !change.added) this.fleet({ type: "status", machine, endpoint: view.address, status: view.status })
1432
1458
  }
1459
+ if (full || changes.some((c) => c.added || c.was !== undefined)) this.dash({ type: "endpoints", at: new Date(this.now()).toISOString(), machine })
1433
1460
  }
1434
1461
 
1435
1462
  /** a direct client's endpoint changed: the other bridges mirror it */
@@ -1460,6 +1487,16 @@ export class RelayHub {
1460
1487
  private fleet(event: Record<string, unknown>): void {
1461
1488
  const at = new Date(this.now()).toISOString()
1462
1489
  for (const socket of this.deps.sockets()) if (socket.conn.watching) this.write(socket, { jsonrpc: "2.0", method: "fleet", params: { at, ...event } })
1490
+ const dash = this.deps.dashboard ? dashboardEvent(event, at) : null
1491
+ if (dash) this.dash(dash)
1492
+ }
1493
+
1494
+ private dash(event: DashboardEvent): void {
1495
+ try {
1496
+ this.deps.dashboard?.(event)
1497
+ } catch {
1498
+ this.deps.log?.("relay_dashboard_failed", { account: this.account })
1499
+ }
1463
1500
  }
1464
1501
 
1465
1502
  private write(socket: HubSocket, value: unknown): void {
@@ -1593,7 +1630,7 @@ export class RelayHub {
1593
1630
  /** a read-only graph for a dashboard: listed endpoints, spawn edges among them, and message counts per sender and recipient; never a body */
1594
1631
  graph(minutes = GRAPH_MINUTES): HubGraph {
1595
1632
  const window = Math.min(Math.max(1, Math.floor(Number.isFinite(minutes) ? minutes : GRAPH_MINUTES)), GRAPH_MINUTES_MAX)
1596
- const endpoints = this.relay.list() as (EndpointView & { wake?: string | null })[]
1633
+ const endpoints = this.relay.list()
1597
1634
  const byId = new Map(endpoints.map((e) => [e.id, e.address]))
1598
1635
  const listed = new Set(byId.values())
1599
1636
  const nodes: GraphNode[] = endpoints.map((e) => ({
@@ -1672,6 +1709,7 @@ export class RelayHub {
1672
1709
  })
1673
1710
  this.stored = null
1674
1711
  this.toBridges(name, { machine: name, endpoints: [], full: true })
1712
+ this.dash({ type: "endpoints", at: new Date(this.now()).toISOString(), machine: name })
1675
1713
  this.deps.log?.("relay_action", { account: this.account, machine: name, method: "machine_release", code: "ok" })
1676
1714
  return { removed: name, endpoints: dropped }
1677
1715
  }
package/src/index.ts CHANGED
@@ -13,6 +13,8 @@ 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,