@scp-dev/hub 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +56 -2
- package/dist/index.js +95 -20
- package/package.json +2 -2
- package/src/federation.ts +46 -11
- package/src/hub.ts +88 -9
- package/src/index.ts +5 -0
package/dist/index.d.ts
CHANGED
|
@@ -58,6 +58,8 @@ interface MirrorView extends MirrorScope {
|
|
|
58
58
|
presence?: Presence;
|
|
59
59
|
last_seen?: string | null;
|
|
60
60
|
title?: string | null;
|
|
61
|
+
/** the handle the endpoint had before a rename: its mirror is renamed in place */
|
|
62
|
+
was?: string;
|
|
61
63
|
}
|
|
62
64
|
interface RemoteView extends Omit<EndpointView, "depth">, MirrorScope {
|
|
63
65
|
machine: string;
|
|
@@ -95,6 +97,8 @@ interface MirrorChange {
|
|
|
95
97
|
added: boolean;
|
|
96
98
|
presence: boolean;
|
|
97
99
|
status: boolean;
|
|
100
|
+
/** the handle a renamed mirror had */
|
|
101
|
+
was?: string;
|
|
98
102
|
}
|
|
99
103
|
interface Delivery {
|
|
100
104
|
seq: number;
|
|
@@ -110,6 +114,8 @@ interface Delivery {
|
|
|
110
114
|
declare class FederatedCore extends RelayCore {
|
|
111
115
|
readonly machine: string | null;
|
|
112
116
|
private aliases;
|
|
117
|
+
private account;
|
|
118
|
+
private others;
|
|
113
119
|
private scope;
|
|
114
120
|
private readonly defaultAuth;
|
|
115
121
|
private readonly endpointCap;
|
|
@@ -122,6 +128,12 @@ declare class FederatedCore extends RelayCore {
|
|
|
122
128
|
/** with xinbox, a delivery takes the next seq past xinbox's too, so a bridge's replay and ack cursor stay one space */
|
|
123
129
|
protected insertDelivery(messageId: string, endpoint: string, expiresAt: number): number;
|
|
124
130
|
setAliases(aliases: string[]): void;
|
|
131
|
+
/** the account handle another member's machines render under, `<account>/<machine>/<handle>`; this member's own stay `me/<machine>/<handle>` */
|
|
132
|
+
setOthers(account: string, machines: Iterable<string>): void;
|
|
133
|
+
/** whether `machine` is another member's: it renders under the account handle */
|
|
134
|
+
setOther(machine: string, other: boolean): void;
|
|
135
|
+
/** how this host renders `<machine>/<handle>` of its own account */
|
|
136
|
+
remoteAddress(remote: string): string;
|
|
125
137
|
/** runs `fn` with unqualified handles resolving on `machine`: a direct client's own namespace on the hub */
|
|
126
138
|
within<T>(machine: string | null, fn: () => T): T;
|
|
127
139
|
/** the hub-side address (`<machine>/<handle>`) an address names, another account's as it is, or null when it is on this machine */
|
|
@@ -406,6 +418,40 @@ interface MachineRow {
|
|
|
406
418
|
created_at: string | null;
|
|
407
419
|
last_seen: string | null;
|
|
408
420
|
}
|
|
421
|
+
/** an endpoint as the dashboard graph draws it */
|
|
422
|
+
interface GraphNode {
|
|
423
|
+
address: string;
|
|
424
|
+
kind: EndpointKind;
|
|
425
|
+
harness: string | null;
|
|
426
|
+
title: string | null;
|
|
427
|
+
presence: Presence;
|
|
428
|
+
status: EndpointView["status"];
|
|
429
|
+
last_seen: string | null;
|
|
430
|
+
parent: string | null;
|
|
431
|
+
wake?: string | null;
|
|
432
|
+
}
|
|
433
|
+
/** `spawn`: parent to child; `message`: sender to recipient, with the count and newest send in the graph's window */
|
|
434
|
+
type GraphEdge = {
|
|
435
|
+
kind: "spawn";
|
|
436
|
+
from: string;
|
|
437
|
+
to: string;
|
|
438
|
+
} | {
|
|
439
|
+
kind: "message";
|
|
440
|
+
from: string;
|
|
441
|
+
to: string;
|
|
442
|
+
count: number;
|
|
443
|
+
last_at: string;
|
|
444
|
+
};
|
|
445
|
+
interface HubGraph {
|
|
446
|
+
/** the window message edges count over */
|
|
447
|
+
minutes: number;
|
|
448
|
+
machines: MachineRow[];
|
|
449
|
+
nodes: GraphNode[];
|
|
450
|
+
edges: GraphEdge[];
|
|
451
|
+
}
|
|
452
|
+
/** the default and widest window of message edges; the core keeps an hour of sender and recipient pairs */
|
|
453
|
+
declare const GRAPH_MINUTES = 15;
|
|
454
|
+
declare const GRAPH_MINUTES_MAX = 60;
|
|
409
455
|
interface HubSocket {
|
|
410
456
|
conn: HubConn;
|
|
411
457
|
send(text: string): void;
|
|
@@ -568,7 +614,10 @@ declare class RelayHub {
|
|
|
568
614
|
private announce;
|
|
569
615
|
/** a direct client's endpoint changed: the other bridges mirror it */
|
|
570
616
|
private announceEndpoint;
|
|
617
|
+
/** `own` tells each bridge whether a bound machine is its member's own device */
|
|
571
618
|
private toBridges;
|
|
619
|
+
/** the machines of other members than the grant's, which a bridge shows under the account handle */
|
|
620
|
+
private othersOf;
|
|
572
621
|
private fleet;
|
|
573
622
|
private write;
|
|
574
623
|
private save;
|
|
@@ -589,8 +638,13 @@ declare class RelayHub {
|
|
|
589
638
|
endpoints: EndpointView[];
|
|
590
639
|
activity: ActivityRow[];
|
|
591
640
|
};
|
|
641
|
+
/** a read-only graph for a dashboard: listed endpoints, spawn edges among them, and message counts per sender and recipient; never a body */
|
|
642
|
+
graph(minutes?: number): HubGraph;
|
|
592
643
|
private machineList;
|
|
593
|
-
/**
|
|
644
|
+
/**
|
|
645
|
+
* only the token the machine is bound to, a full login of the member who bound it, or an admin may release it (or a revoked token's name);
|
|
646
|
+
* while no connection holds a bound machine, any credential of that member may too, so a rotated token releases what the old one bound
|
|
647
|
+
*/
|
|
594
648
|
private releaseMachine;
|
|
595
649
|
/** releases client bindings with no live connection, idle for the `clientMachineIdleS` limit, with nothing queued and no unanswered ask open; bridge bindings never expire */
|
|
596
650
|
private expireMachines;
|
|
@@ -614,4 +668,4 @@ declare class RelayHub {
|
|
|
614
668
|
xnotice(endpoint: string, text: string): void;
|
|
615
669
|
}
|
|
616
670
|
|
|
617
|
-
export { type Delivery, FederatedCore, type FederatedOptions, HUB_LIMITS, type HubAuth, type HubConn, type HubDeps, type HubGrant, type HubLimits, type HubRole, type HubSocket, MACHINE_NAME, type MachineRow, type MirrorCaps, type MirrorChange, type MirrorRules, type MirrorScope, type MirrorView, type RelayAuth, RelayHub, type RemoteView, type XEndpoint };
|
|
671
|
+
export { type Delivery, FederatedCore, type FederatedOptions, GRAPH_MINUTES, GRAPH_MINUTES_MAX, type GraphEdge, type GraphNode, HUB_LIMITS, type HubAuth, type HubConn, type HubDeps, type HubGrant, type HubGraph, type HubLimits, type HubRole, type HubSocket, MACHINE_NAME, type MachineRow, type MirrorCaps, type MirrorChange, type MirrorRules, type MirrorScope, type MirrorView, type RelayAuth, RelayHub, type RemoteView, type XEndpoint };
|
package/dist/index.js
CHANGED
|
@@ -61,6 +61,8 @@ var handlePart = (remote) => remote.slice(remote.indexOf("/") + 1);
|
|
|
61
61
|
var FederatedCore = class extends RelayCore {
|
|
62
62
|
machine;
|
|
63
63
|
aliases;
|
|
64
|
+
account = null;
|
|
65
|
+
others = /* @__PURE__ */ new Set();
|
|
64
66
|
scope = null;
|
|
65
67
|
defaultAuth;
|
|
66
68
|
endpointCap;
|
|
@@ -102,6 +104,20 @@ var FederatedCore = class extends RelayCore {
|
|
|
102
104
|
setAliases(aliases) {
|
|
103
105
|
this.aliases = new Set(aliases);
|
|
104
106
|
}
|
|
107
|
+
/** the account handle another member's machines render under, `<account>/<machine>/<handle>`; this member's own stay `me/<machine>/<handle>` */
|
|
108
|
+
setOthers(account, machines) {
|
|
109
|
+
this.account = account;
|
|
110
|
+
this.others = new Set(machines);
|
|
111
|
+
}
|
|
112
|
+
/** whether `machine` is another member's: it renders under the account handle */
|
|
113
|
+
setOther(machine, other) {
|
|
114
|
+
if (other) this.others.add(machine);
|
|
115
|
+
else this.others.delete(machine);
|
|
116
|
+
}
|
|
117
|
+
/** how this host renders `<machine>/<handle>` of its own account */
|
|
118
|
+
remoteAddress(remote) {
|
|
119
|
+
return `${this.account && this.others.has(machinePart(remote)) ? this.account : this.user}/${remote}`;
|
|
120
|
+
}
|
|
105
121
|
/** runs `fn` with unqualified handles resolving on `machine`: a direct client's own namespace on the hub */
|
|
106
122
|
within(machine, fn) {
|
|
107
123
|
const previous = this.scope;
|
|
@@ -159,7 +175,7 @@ var FederatedCore = class extends RelayCore {
|
|
|
159
175
|
return { account, machine, handle: handle === "me" ? "" : handle };
|
|
160
176
|
}
|
|
161
177
|
isLocalUser(part) {
|
|
162
|
-
return part === "me" || part === this.user || this.aliases.has(part);
|
|
178
|
+
return part === "me" || part === "local" || part === this.user || this.aliases.has(part);
|
|
163
179
|
}
|
|
164
180
|
/** whether an endpoint of another account `part` ever reached this relay, so `<part>/me` is that account's human */
|
|
165
181
|
knownAccount(part) {
|
|
@@ -198,7 +214,7 @@ var FederatedCore = class extends RelayCore {
|
|
|
198
214
|
return row;
|
|
199
215
|
}
|
|
200
216
|
addressUsers() {
|
|
201
|
-
return [this.user, "me", ...this.aliases];
|
|
217
|
+
return [this.user, "me", "local", ...this.aliases];
|
|
202
218
|
}
|
|
203
219
|
localRow(row) {
|
|
204
220
|
return !row.remote;
|
|
@@ -206,7 +222,7 @@ var FederatedCore = class extends RelayCore {
|
|
|
206
222
|
address(row) {
|
|
207
223
|
const remote = row.remote;
|
|
208
224
|
if (remote && isForeign(remote)) return remote.slice(1);
|
|
209
|
-
return remote ?
|
|
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
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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 (
|
|
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
|
|
2638
|
+
if (socket.conn.role !== "bridge" || socket.conn.machine === machine) continue;
|
|
2639
|
+
const own = bound ? { own: (bound.actor_id ?? null) === (socket.conn.grant.actorId ?? null) } : {};
|
|
2640
|
+
this.write(socket, { jsonrpc: "2.0", method: "endpoints", params: { ...params, ...own } });
|
|
2608
2641
|
}
|
|
2609
2642
|
}
|
|
2643
|
+
/** the machines of other members than the grant's, which a bridge shows under the account handle */
|
|
2644
|
+
othersOf(grant, except) {
|
|
2645
|
+
return this.deps.sql.all(`SELECT name FROM machines WHERE name != ? AND actor_id IS NOT ?`, except, grant.actorId ?? null).map((row) => row.name);
|
|
2646
|
+
}
|
|
2610
2647
|
fleet(event) {
|
|
2611
2648
|
const at = new Date(this.now()).toISOString();
|
|
2612
2649
|
for (const socket of this.deps.sockets()) if (socket.conn.watching) this.write(socket, { jsonrpc: "2.0", method: "fleet", params: { at, ...event } });
|
|
@@ -2726,6 +2763,38 @@ var RelayHub = class _RelayHub {
|
|
|
2726
2763
|
const { endpoints, activity: rows } = this.relay.snapshot({ activity });
|
|
2727
2764
|
return { machines: this.machineList(), endpoints, activity: rows };
|
|
2728
2765
|
}
|
|
2766
|
+
/** a read-only graph for a dashboard: listed endpoints, spawn edges among them, and message counts per sender and recipient; never a body */
|
|
2767
|
+
graph(minutes = GRAPH_MINUTES) {
|
|
2768
|
+
const window = Math.min(Math.max(1, Math.floor(Number.isFinite(minutes) ? minutes : GRAPH_MINUTES)), GRAPH_MINUTES_MAX);
|
|
2769
|
+
const endpoints = this.relay.list();
|
|
2770
|
+
const byId = new Map(endpoints.map((e) => [e.id, e.address]));
|
|
2771
|
+
const listed = new Set(byId.values());
|
|
2772
|
+
const nodes = endpoints.map((e) => ({
|
|
2773
|
+
address: e.address,
|
|
2774
|
+
kind: e.kind,
|
|
2775
|
+
harness: e.harness,
|
|
2776
|
+
title: e.title,
|
|
2777
|
+
presence: e.presence,
|
|
2778
|
+
status: e.status,
|
|
2779
|
+
last_seen: e.last_seen,
|
|
2780
|
+
parent: e.parent ?? null,
|
|
2781
|
+
...e.wake !== void 0 ? { wake: e.wake } : {}
|
|
2782
|
+
}));
|
|
2783
|
+
const edges = nodes.flatMap(
|
|
2784
|
+
(n) => n.parent && n.parent !== n.address && listed.has(n.parent) ? [{ kind: "spawn", from: n.parent, to: n.address }] : []
|
|
2785
|
+
);
|
|
2786
|
+
const pairs = this.deps.sql.all(
|
|
2787
|
+
`SELECT from_ep, to_ep, COUNT(*) AS n, MAX(at) AS last FROM pair_events WHERE at > ? GROUP BY from_ep, to_ep`,
|
|
2788
|
+
this.now() - window * 6e4
|
|
2789
|
+
);
|
|
2790
|
+
const traffic = pairs.flatMap((row) => {
|
|
2791
|
+
const from = byId.get(row.from_ep);
|
|
2792
|
+
const to = byId.get(row.to_ep);
|
|
2793
|
+
return from && to && from !== to ? [{ kind: "message", from, to, count: row.n, last_at: iso(row.last) }] : [];
|
|
2794
|
+
});
|
|
2795
|
+
edges.push(...traffic.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to)));
|
|
2796
|
+
return { minutes: window, machines: this.machineList(), nodes, edges };
|
|
2797
|
+
}
|
|
2729
2798
|
machineList() {
|
|
2730
2799
|
const sockets = this.deps.sockets().filter((s) => !this.gone.has(s.conn.id));
|
|
2731
2800
|
const bound = this.deps.sql.all(`SELECT name, kind, created_at, last_seen FROM machines ORDER BY name`).map((row) => ({
|
|
@@ -2739,7 +2808,10 @@ var RelayHub = class _RelayHub {
|
|
|
2739
2808
|
const revoked = this.deps.sql.all(`SELECT name, at FROM machine_tombstones WHERE name NOT IN (SELECT name FROM machines) ORDER BY name`).map((row) => ({ name: row.name, kind: "revoked", online: false, bridge: false, created_at: null, last_seen: iso(row.at) }));
|
|
2740
2809
|
return [...bound, ...revoked];
|
|
2741
2810
|
}
|
|
2742
|
-
/**
|
|
2811
|
+
/**
|
|
2812
|
+
* only the token the machine is bound to, a full login of the member who bound it, or an admin may release it (or a revoked token's name);
|
|
2813
|
+
* while no connection holds a bound machine, any credential of that member may too, so a rotated token releases what the old one bound
|
|
2814
|
+
*/
|
|
2743
2815
|
releaseMachine(caller, name) {
|
|
2744
2816
|
const core = this.relay;
|
|
2745
2817
|
if (typeof name !== "string" || !MACHINE_NAME.test(name)) throw new RelayError2("invalid_params", "name is a machine name");
|
|
@@ -2747,11 +2819,12 @@ var RelayHub = class _RelayHub {
|
|
|
2747
2819
|
const tomb = bound ? void 0 : one2(this.deps.sql, `SELECT actor_id FROM machine_tombstones WHERE name = ?`, name);
|
|
2748
2820
|
if (!bound && !tomb) throw new RelayError2("not_found", `no machine ${name}`);
|
|
2749
2821
|
const grant = caller.conn.grant;
|
|
2750
|
-
|
|
2822
|
+
const member = !!bound && bound.actor_id !== null && grant.actorId === bound.actor_id;
|
|
2823
|
+
if (!(bound?.token_id === grant.tokenId || fullOf(grant, (bound ?? tomb).actor_id) || accountAdmin(grant) || member && !this.liveMachines().has(name))) {
|
|
2751
2824
|
core.record("refuse", null, name, { code: "unauthorized", machine: name });
|
|
2752
2825
|
throw new RelayError2(
|
|
2753
2826
|
"unauthorized",
|
|
2754
|
-
`only the token machine ${name} is bound to, a full login of the member who bound it, or an org owner or admin can release it`
|
|
2827
|
+
`only the token machine ${name} is bound to, a full login of the member who bound it, or an org owner or admin can release it${member ? "; any credential of that member can once it is offline" : ""}`
|
|
2755
2828
|
);
|
|
2756
2829
|
}
|
|
2757
2830
|
for (const socket of [...this.deps.sockets()]) {
|
|
@@ -2872,6 +2945,8 @@ var RelayHub = class _RelayHub {
|
|
|
2872
2945
|
};
|
|
2873
2946
|
export {
|
|
2874
2947
|
FederatedCore,
|
|
2948
|
+
GRAPH_MINUTES,
|
|
2949
|
+
GRAPH_MINUTES_MAX,
|
|
2875
2950
|
HUB_LIMITS,
|
|
2876
2951
|
MACHINE_NAME,
|
|
2877
2952
|
RelayHub
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scp-dev/hub",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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 (
|
|
635
|
+
if (was !== undefined) changes.push({ ...(change ?? { view: this.remoteView(this.byId(id)!), added: false, presence: true, status: false }), was })
|
|
636
|
+
else if (change) changes.push(change)
|
|
603
637
|
}
|
|
604
638
|
if (options.full) {
|
|
605
639
|
const missing = this.sql.all<FederatedRow>(
|
|
@@ -1546,6 +1580,7 @@ export class FederatedCore extends RelayCore {
|
|
|
1546
1580
|
presence,
|
|
1547
1581
|
lastSeen: Number.isFinite(parsedSeen) ? parsedSeen : null,
|
|
1548
1582
|
scope: kind === "agent" ? parseScope(view) : {},
|
|
1583
|
+
was: kind === "agent" && typeof view.was === "string" && validHandle(view.was) && view.was !== handle ? view.was : null,
|
|
1549
1584
|
}
|
|
1550
1585
|
const leaked = [handle, out.model, out.cwd, out.labels, status?.note, out.scope.title].map((v) => (v ? findSecret(v) : null)).find(Boolean)
|
|
1551
1586
|
if (leaked) throw new RelayError("secret_refused", `an endpoint looks like it carries a credential (${leaked})`)
|
package/src/hub.ts
CHANGED
|
@@ -92,6 +92,34 @@ export interface MachineRow {
|
|
|
92
92
|
last_seen: string | null
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/** an endpoint as the dashboard graph draws it */
|
|
96
|
+
export interface GraphNode {
|
|
97
|
+
address: string
|
|
98
|
+
kind: EndpointKind
|
|
99
|
+
harness: string | null
|
|
100
|
+
title: string | null
|
|
101
|
+
presence: Presence
|
|
102
|
+
status: EndpointView["status"]
|
|
103
|
+
last_seen: string | null
|
|
104
|
+
parent: string | null
|
|
105
|
+
wake?: string | null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** `spawn`: parent to child; `message`: sender to recipient, with the count and newest send in the graph's window */
|
|
109
|
+
export type GraphEdge = { kind: "spawn"; from: string; to: string } | { kind: "message"; from: string; to: string; count: number; last_at: string }
|
|
110
|
+
|
|
111
|
+
export interface HubGraph {
|
|
112
|
+
/** the window message edges count over */
|
|
113
|
+
minutes: number
|
|
114
|
+
machines: MachineRow[]
|
|
115
|
+
nodes: GraphNode[]
|
|
116
|
+
edges: GraphEdge[]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** the default and widest window of message edges; the core keeps an hour of sender and recipient pairs */
|
|
120
|
+
export const GRAPH_MINUTES = 15
|
|
121
|
+
export const GRAPH_MINUTES_MAX = 60
|
|
122
|
+
|
|
95
123
|
export interface HubSocket {
|
|
96
124
|
conn: HubConn
|
|
97
125
|
send(text: string): void
|
|
@@ -641,8 +669,11 @@ export class RelayHub {
|
|
|
641
669
|
serverFields(params)
|
|
642
670
|
const observe = params.observe === true
|
|
643
671
|
const named = params.machine
|
|
644
|
-
if (
|
|
645
|
-
|
|
672
|
+
if (
|
|
673
|
+
(!observe || named !== undefined) &&
|
|
674
|
+
(typeof named !== "string" || !MACHINE_NAME.test(named) || named === "me" || named === "local" || named === this.account)
|
|
675
|
+
) {
|
|
676
|
+
throw new RelayError("invalid_params", "machine is a name of lowercase letters, digits and -, other than me, local and the account handle")
|
|
646
677
|
}
|
|
647
678
|
const bridge = params.bridge === true
|
|
648
679
|
const human = params.human === true
|
|
@@ -690,7 +721,7 @@ export class RelayHub {
|
|
|
690
721
|
this.release(other, true)
|
|
691
722
|
}
|
|
692
723
|
core.record("bridge", null, machine, { machine })
|
|
693
|
-
return { ...base, endpoints: core.remoteViews({ except: machine }) }
|
|
724
|
+
return { ...base, endpoints: core.remoteViews({ except: machine }), others: this.othersOf(conn.grant, machine) }
|
|
694
725
|
}
|
|
695
726
|
|
|
696
727
|
if (one<{ kind: string }>(this.deps.sql, `SELECT kind FROM machines WHERE name = ?`, machine)?.kind === "bridge") {
|
|
@@ -1301,7 +1332,7 @@ export class RelayHub {
|
|
|
1301
1332
|
/** a machine's endpoint changes: `endpoints` to the other bridges, presence and status to watchers */
|
|
1302
1333
|
private announce(machine: string, changes: MirrorChange[], full: boolean): void {
|
|
1303
1334
|
if (full || changes.length) {
|
|
1304
|
-
const endpoints = full ? this.relay.remoteRowsOf(machine) : changes.map((c) => c.view)
|
|
1335
|
+
const endpoints = full ? this.relay.remoteRowsOf(machine) : changes.map((c) => (c.was === undefined ? c.view : { ...c.view, was: c.was }))
|
|
1305
1336
|
this.toBridges(machine, { machine, endpoints, full })
|
|
1306
1337
|
}
|
|
1307
1338
|
for (const change of changes) {
|
|
@@ -1320,12 +1351,23 @@ export class RelayHub {
|
|
|
1320
1351
|
if (view) this.toBridges(machine, { machine, endpoints: [view], full: false })
|
|
1321
1352
|
}
|
|
1322
1353
|
|
|
1323
|
-
|
|
1354
|
+
/** `own` tells each bridge whether a bound machine is its member's own device */
|
|
1355
|
+
private toBridges(machine: string, params: Record<string, unknown>): void {
|
|
1356
|
+
const bound = one<{ actor_id: string | null }>(this.deps.sql, `SELECT actor_id FROM machines WHERE name = ?`, machine)
|
|
1324
1357
|
for (const socket of this.deps.sockets()) {
|
|
1325
|
-
if (socket.conn.role
|
|
1358
|
+
if (socket.conn.role !== "bridge" || socket.conn.machine === machine) continue
|
|
1359
|
+
const own = bound ? { own: (bound.actor_id ?? null) === (socket.conn.grant.actorId ?? null) } : {}
|
|
1360
|
+
this.write(socket, { jsonrpc: "2.0", method: "endpoints", params: { ...params, ...own } })
|
|
1326
1361
|
}
|
|
1327
1362
|
}
|
|
1328
1363
|
|
|
1364
|
+
/** the machines of other members than the grant's, which a bridge shows under the account handle */
|
|
1365
|
+
private othersOf(grant: HubGrant, except: string): string[] {
|
|
1366
|
+
return this.deps.sql
|
|
1367
|
+
.all<{ name: string }>(`SELECT name FROM machines WHERE name != ? AND actor_id IS NOT ?`, except, grant.actorId ?? null)
|
|
1368
|
+
.map((row) => row.name)
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1329
1371
|
private fleet(event: Record<string, unknown>): void {
|
|
1330
1372
|
const at = new Date(this.now()).toISOString()
|
|
1331
1373
|
for (const socket of this.deps.sockets()) if (socket.conn.watching) this.write(socket, { jsonrpc: "2.0", method: "fleet", params: { at, ...event } })
|
|
@@ -1459,6 +1501,39 @@ export class RelayHub {
|
|
|
1459
1501
|
return { machines: this.machineList(), endpoints, activity: rows }
|
|
1460
1502
|
}
|
|
1461
1503
|
|
|
1504
|
+
/** a read-only graph for a dashboard: listed endpoints, spawn edges among them, and message counts per sender and recipient; never a body */
|
|
1505
|
+
graph(minutes = GRAPH_MINUTES): HubGraph {
|
|
1506
|
+
const window = Math.min(Math.max(1, Math.floor(Number.isFinite(minutes) ? minutes : GRAPH_MINUTES)), GRAPH_MINUTES_MAX)
|
|
1507
|
+
const endpoints = this.relay.list() as (EndpointView & { wake?: string | null })[]
|
|
1508
|
+
const byId = new Map(endpoints.map((e) => [e.id, e.address]))
|
|
1509
|
+
const listed = new Set(byId.values())
|
|
1510
|
+
const nodes: GraphNode[] = endpoints.map((e) => ({
|
|
1511
|
+
address: e.address,
|
|
1512
|
+
kind: e.kind,
|
|
1513
|
+
harness: e.harness,
|
|
1514
|
+
title: e.title,
|
|
1515
|
+
presence: e.presence,
|
|
1516
|
+
status: e.status,
|
|
1517
|
+
last_seen: e.last_seen,
|
|
1518
|
+
parent: e.parent ?? null,
|
|
1519
|
+
...(e.wake !== undefined ? { wake: e.wake } : {}),
|
|
1520
|
+
}))
|
|
1521
|
+
const edges: GraphEdge[] = nodes.flatMap((n) =>
|
|
1522
|
+
n.parent && n.parent !== n.address && listed.has(n.parent) ? [{ kind: "spawn" as const, from: n.parent, to: n.address }] : [],
|
|
1523
|
+
)
|
|
1524
|
+
const pairs = this.deps.sql.all<{ from_ep: string; to_ep: string; n: number; last: number }>(
|
|
1525
|
+
`SELECT from_ep, to_ep, COUNT(*) AS n, MAX(at) AS last FROM pair_events WHERE at > ? GROUP BY from_ep, to_ep`,
|
|
1526
|
+
this.now() - window * 60_000,
|
|
1527
|
+
)
|
|
1528
|
+
const traffic: GraphEdge[] = pairs.flatMap((row) => {
|
|
1529
|
+
const from = byId.get(row.from_ep)
|
|
1530
|
+
const to = byId.get(row.to_ep)
|
|
1531
|
+
return from && to && from !== to ? [{ kind: "message" as const, from, to, count: row.n, last_at: iso(row.last)! }] : []
|
|
1532
|
+
})
|
|
1533
|
+
edges.push(...traffic.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to)))
|
|
1534
|
+
return { minutes: window, machines: this.machineList(), nodes, edges }
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1462
1537
|
private machineList(): MachineRow[] {
|
|
1463
1538
|
const sockets = this.deps.sockets().filter((s) => !this.gone.has(s.conn.id))
|
|
1464
1539
|
const bound = this.deps.sql
|
|
@@ -1477,7 +1552,10 @@ export class RelayHub {
|
|
|
1477
1552
|
return [...bound, ...revoked]
|
|
1478
1553
|
}
|
|
1479
1554
|
|
|
1480
|
-
/**
|
|
1555
|
+
/**
|
|
1556
|
+
* only the token the machine is bound to, a full login of the member who bound it, or an admin may release it (or a revoked token's name);
|
|
1557
|
+
* while no connection holds a bound machine, any credential of that member may too, so a rotated token releases what the old one bound
|
|
1558
|
+
*/
|
|
1481
1559
|
private releaseMachine(caller: HubSocket, name: unknown): unknown {
|
|
1482
1560
|
const core = this.relay
|
|
1483
1561
|
if (typeof name !== "string" || !MACHINE_NAME.test(name)) throw new RelayError("invalid_params", "name is a machine name")
|
|
@@ -1485,11 +1563,12 @@ export class RelayHub {
|
|
|
1485
1563
|
const tomb = bound ? undefined : one<{ actor_id: string | null }>(this.deps.sql, `SELECT actor_id FROM machine_tombstones WHERE name = ?`, name)
|
|
1486
1564
|
if (!bound && !tomb) throw new RelayError("not_found", `no machine ${name}`)
|
|
1487
1565
|
const grant = caller.conn.grant
|
|
1488
|
-
|
|
1566
|
+
const member = !!bound && bound.actor_id !== null && grant.actorId === bound.actor_id
|
|
1567
|
+
if (!(bound?.token_id === grant.tokenId || fullOf(grant, (bound ?? tomb)!.actor_id) || accountAdmin(grant) || (member && !this.liveMachines().has(name)))) {
|
|
1489
1568
|
core.record("refuse", null, name, { code: "unauthorized", machine: name })
|
|
1490
1569
|
throw new RelayError(
|
|
1491
1570
|
"unauthorized",
|
|
1492
|
-
`only the token machine ${name} is bound to, a full login of the member who bound it, or an org owner or admin can release it`,
|
|
1571
|
+
`only the token machine ${name} is bound to, a full login of the member who bound it, or an org owner or admin can release it${member ? "; any credential of that member can once it is offline" : ""}`,
|
|
1493
1572
|
)
|
|
1494
1573
|
}
|
|
1495
1574
|
for (const socket of [...this.deps.sockets()]) {
|