@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 +23 -3
- package/dist/index.js +80 -11
- package/package.json +2 -2
- package/src/federation.ts +16 -3
- package/src/hub.ts +75 -9
- package/src/index.ts +3 -0
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,
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
3359
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
1652
|
-
|
|
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
|
-
|
|
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