@scp-dev/broker 0.1.6 → 0.2.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 +18 -1
- package/dist/index.js +111 -19
- package/dist/testing.d.ts +3 -1
- package/dist/testing.js +2 -1
- package/package.json +3 -3
- package/src/bridge.ts +66 -5
- package/src/server.ts +76 -6
- package/src/testing.ts +4 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ScopePreset, Claim, EndpointView, ScopeView, ScopeChange, Envelope, StopWaitResult, PeerView, WhyResult, SendParams, SendResult, ActivityRow, XListed, Selector } from '@scp-dev/core';
|
|
2
|
-
import { JudgeGate as JudgeGate$1, JudgeGateOptions, FederatedCore, Deliver, JudgeClientFactory } from '@scp-dev/hub';
|
|
2
|
+
import { LOCAL_EVENT_KINDS, JudgeGate as JudgeGate$1, JudgeGateOptions, FederatedCore, Deliver, JudgeClientFactory } from '@scp-dev/hub';
|
|
3
3
|
export { Deliver } from '@scp-dev/hub';
|
|
4
4
|
|
|
5
5
|
interface Hello {
|
|
@@ -203,6 +203,10 @@ interface BridgeConfig {
|
|
|
203
203
|
redact?: boolean | undefined;
|
|
204
204
|
/** let messages marked private leave this machine */
|
|
205
205
|
privateOk?: boolean | undefined;
|
|
206
|
+
/** report metadata of deliveries made on this machine to the hub, never bodies (default true) */
|
|
207
|
+
mirrorLocal?: boolean | undefined;
|
|
208
|
+
/** how often reported local deliveries are sent, batched (default 1 s) */
|
|
209
|
+
localFlushMs?: number | undefined;
|
|
206
210
|
/** a WebSocket constructor (tests) */
|
|
207
211
|
webSocket?: WebSocketWithHeaders | undefined;
|
|
208
212
|
/** reconnect backoff bounds (default 1 s to 60 s) */
|
|
@@ -236,6 +240,8 @@ interface Bridge {
|
|
|
236
240
|
renamed(endpoint: string, was: string): void;
|
|
237
241
|
/** a local endpoint's activity, posted to the hub */
|
|
238
242
|
activity(endpoint: string, row: ActivityRow): void;
|
|
243
|
+
/** a delivery between endpoints of this machine: its metadata is reported to the hub in the next batch */
|
|
244
|
+
local?(event: LocalDelivery): void;
|
|
239
245
|
/** the endpoints of other accounts this account's contacts let it reach */
|
|
240
246
|
contacts(): Promise<XListed[]>;
|
|
241
247
|
status(): BridgeStatus;
|
|
@@ -243,6 +249,15 @@ interface Bridge {
|
|
|
243
249
|
relink(): void;
|
|
244
250
|
stop(): Promise<void>;
|
|
245
251
|
}
|
|
252
|
+
/** a delivery the broker made on its own machine, by local endpoint ids */
|
|
253
|
+
interface LocalDelivery {
|
|
254
|
+
id: string;
|
|
255
|
+
thread: string;
|
|
256
|
+
kind: (typeof LOCAL_EVENT_KINDS)[number];
|
|
257
|
+
from: string;
|
|
258
|
+
to: string[];
|
|
259
|
+
at: number;
|
|
260
|
+
}
|
|
246
261
|
/** a WebSocket constructor that takes request headers, as Bun's and Node's do */
|
|
247
262
|
type WebSocketWithHeaders = new (url: string, options: {
|
|
248
263
|
headers: Record<string, string>;
|
|
@@ -319,6 +334,8 @@ interface BrokerOptions {
|
|
|
319
334
|
link?: LinkFactory | undefined;
|
|
320
335
|
/** how often presence that decays without a request (a hook-only endpoint) is checked for changes (default 60 s) */
|
|
321
336
|
presenceEveryMs?: number | undefined;
|
|
337
|
+
/** how often the broker checks it still owns its socket and lock, stopping once another broker or a wiped state directory took them (default 2 s) */
|
|
338
|
+
ownershipEveryMs?: number | undefined;
|
|
322
339
|
/** where a judge model's key_env is read (default the broker's environment) */
|
|
323
340
|
env?: Record<string, string | undefined> | undefined;
|
|
324
341
|
/** the judge model client for a policy's `judge.model` (default the provider's HTTP API) */
|
package/dist/index.js
CHANGED
|
@@ -524,6 +524,7 @@ async function connectBroker(dir, start, timeoutMs = 5e3) {
|
|
|
524
524
|
}
|
|
525
525
|
|
|
526
526
|
// src/bridge.ts
|
|
527
|
+
import { createHash as createHash2 } from "crypto";
|
|
527
528
|
import {
|
|
528
529
|
findSecret,
|
|
529
530
|
matches,
|
|
@@ -532,7 +533,9 @@ import {
|
|
|
532
533
|
RelayError as RelayError2,
|
|
533
534
|
XLIMITS
|
|
534
535
|
} from "@scp-dev/core";
|
|
535
|
-
import { HUB_LIMITS } from "@scp-dev/hub";
|
|
536
|
+
import { HUB_LIMITS, LOCAL_EVENTS } from "@scp-dev/hub";
|
|
537
|
+
var LOCAL_BUFFER = 1e3;
|
|
538
|
+
var idHash = (id) => createHash2("sha256").update(id).digest("hex").slice(0, 32);
|
|
536
539
|
var CONNECT_TIMEOUT_MS = 15e3;
|
|
537
540
|
var MIRROR_FRAME_BYTES = HUB_LIMITS.frameBytes - 1024;
|
|
538
541
|
var DEAD_LETTER_ATTEMPTS = 3;
|
|
@@ -578,6 +581,10 @@ function startBridge(options) {
|
|
|
578
581
|
const inflight = /* @__PURE__ */ new Set();
|
|
579
582
|
const injectFailures = /* @__PURE__ */ new Map();
|
|
580
583
|
const fromHub = /* @__PURE__ */ new WeakSet();
|
|
584
|
+
const mirrorLocal = options.mirrorLocal !== false;
|
|
585
|
+
const localFlushMs = options.localFlushMs ?? 1e3;
|
|
586
|
+
let localQueue = [];
|
|
587
|
+
let localTimer;
|
|
581
588
|
const setState = (up, error) => {
|
|
582
589
|
if (up !== connected || error !== lastError) since = (/* @__PURE__ */ new Date()).toISOString();
|
|
583
590
|
connected = up;
|
|
@@ -699,7 +706,8 @@ function startBridge(options) {
|
|
|
699
706
|
byMachine.set(view.machine, [...byMachine.get(view.machine) ?? [], view]);
|
|
700
707
|
}
|
|
701
708
|
if (full) {
|
|
702
|
-
|
|
709
|
+
core.dropMachine(machine);
|
|
710
|
+
for (const view of core.remoteViews()) if (view.machine !== account && !byMachine.has(view.machine)) core.dropMachine(view.machine);
|
|
703
711
|
}
|
|
704
712
|
for (const [name, list] of byMachine) {
|
|
705
713
|
try {
|
|
@@ -800,7 +808,8 @@ function startBridge(options) {
|
|
|
800
808
|
if (typeof params.machine !== "string" || params.machine === machine) return;
|
|
801
809
|
if (typeof params.own === "boolean") core.setOther(params.machine, !params.own);
|
|
802
810
|
try {
|
|
803
|
-
|
|
811
|
+
if (params.released === true) core.dropMachine(params.machine);
|
|
812
|
+
else core.mirror(params.machine, params.endpoints, { full: params.full === true });
|
|
804
813
|
} catch (error) {
|
|
805
814
|
log(`relay bridge: the endpoints of ${params.machine} were not applied: ${error instanceof Error ? error.message : String(error)}`);
|
|
806
815
|
}
|
|
@@ -887,21 +896,21 @@ function startBridge(options) {
|
|
|
887
896
|
if (event.machine === machine) return;
|
|
888
897
|
if (event.type === "message" && Array.isArray(event.to) && account && event.to.some((to) => typeof to === "string" && to.startsWith(`${account}/${machine}/`)))
|
|
889
898
|
return;
|
|
890
|
-
const
|
|
899
|
+
const local2 = {
|
|
891
900
|
...event,
|
|
892
901
|
...event.from !== void 0 ? { from: localAddress(event.from) } : {},
|
|
893
902
|
...event.endpoint !== void 0 ? { endpoint: localAddress(event.endpoint) } : {}
|
|
894
903
|
};
|
|
895
|
-
if (Array.isArray(event.to))
|
|
904
|
+
if (Array.isArray(event.to)) local2.to = event.to.map(localAddress);
|
|
896
905
|
if (event.type === "activity" && typeof event.machine === "string" && typeof event.endpoint === "string") {
|
|
897
906
|
try {
|
|
898
907
|
const proxy = core.remoteEndpoint(event.machine, event.endpoint.slice(event.endpoint.lastIndexOf("/") + 1));
|
|
899
908
|
const row = core.event(proxy, { kind: event.kind, tool: event.tool, summary: event.summary });
|
|
900
|
-
|
|
909
|
+
local2.id = row.id;
|
|
901
910
|
} catch {
|
|
902
911
|
}
|
|
903
912
|
}
|
|
904
|
-
hooks.fleet(
|
|
913
|
+
hooks.fleet(local2);
|
|
905
914
|
}
|
|
906
915
|
function linkUp(ms) {
|
|
907
916
|
if (connected) return Promise.resolve(true);
|
|
@@ -1017,6 +1026,30 @@ function startBridge(options) {
|
|
|
1017
1026
|
if (handle === null) return;
|
|
1018
1027
|
fireAndForget("event", { from: handle, kind: row.kind, tool: row.tool ?? void 0, summary: options.redact ? void 0 : row.summary ?? void 0 });
|
|
1019
1028
|
}
|
|
1029
|
+
function local(event) {
|
|
1030
|
+
if (!mirrorLocal || !ready || localQueue.length >= LOCAL_BUFFER) return;
|
|
1031
|
+
let from;
|
|
1032
|
+
let to;
|
|
1033
|
+
try {
|
|
1034
|
+
from = handleOf(event.from);
|
|
1035
|
+
to = event.to.flatMap((endpoint) => {
|
|
1036
|
+
const handle = handleOf(endpoint);
|
|
1037
|
+
return handle === null ? [] : [handle];
|
|
1038
|
+
});
|
|
1039
|
+
} catch {
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
if (from === null || !to.length) return;
|
|
1043
|
+
localQueue.push({ from, to: to.slice(0, LOCAL_EVENTS.recipients), kind: event.kind, at: event.at, id: idHash(event.id), thread: idHash(event.thread) });
|
|
1044
|
+
localTimer ??= setTimeout(flushLocal, localFlushMs);
|
|
1045
|
+
}
|
|
1046
|
+
function flushLocal() {
|
|
1047
|
+
localTimer = void 0;
|
|
1048
|
+
const batch = localQueue;
|
|
1049
|
+
localQueue = [];
|
|
1050
|
+
if (!ready) return;
|
|
1051
|
+
for (let i = 0; i < batch.length; i += LOCAL_EVENTS.perFrame) fireAndForget("local_events", { events: batch.slice(i, i + LOCAL_EVENTS.perFrame) });
|
|
1052
|
+
}
|
|
1020
1053
|
async function contacts() {
|
|
1021
1054
|
if (!connected) throw unavailable();
|
|
1022
1055
|
return (await request("list", { contacts: true })).contacts;
|
|
@@ -1038,6 +1071,8 @@ function startBridge(options) {
|
|
|
1038
1071
|
stopped = true;
|
|
1039
1072
|
shutDown = true;
|
|
1040
1073
|
clearTimeout(retryTimer);
|
|
1074
|
+
clearTimeout(localTimer);
|
|
1075
|
+
localQueue = [];
|
|
1041
1076
|
for (const fn of waitingForLink.splice(0)) fn();
|
|
1042
1077
|
const socket = ws;
|
|
1043
1078
|
ws = null;
|
|
@@ -1048,11 +1083,11 @@ function startBridge(options) {
|
|
|
1048
1083
|
if (socket && socket.readyState <= 1) socket.close(1e3, "the broker stopped");
|
|
1049
1084
|
}
|
|
1050
1085
|
connect();
|
|
1051
|
-
return { forward, changed, resync, renamed, activity, contacts, status, relink, stop };
|
|
1086
|
+
return { forward, changed, resync, renamed, activity, local, contacts, status, relink, stop };
|
|
1052
1087
|
}
|
|
1053
1088
|
|
|
1054
1089
|
// src/server.ts
|
|
1055
|
-
import { createHash as
|
|
1090
|
+
import { createHash as createHash4 } from "crypto";
|
|
1056
1091
|
import { chmodSync as chmodSync2, existsSync as existsSync2, lstatSync as lstatSync2, readFileSync as readFileSync6, realpathSync, rmdirSync, unlinkSync as unlinkSync2 } from "fs";
|
|
1057
1092
|
import { createServer } from "net";
|
|
1058
1093
|
import { homedir as homedir2 } from "os";
|
|
@@ -1089,11 +1124,11 @@ var JudgeGate = class extends HubJudgeGate {
|
|
|
1089
1124
|
};
|
|
1090
1125
|
|
|
1091
1126
|
// src/sessions.ts
|
|
1092
|
-
import { createHash as
|
|
1127
|
+
import { createHash as createHash3, randomUUID } from "crypto";
|
|
1093
1128
|
import { mkdirSync as mkdirSync2, readFileSync as readFileSync5, renameSync as renameSync2, rmSync, writeFileSync } from "fs";
|
|
1094
1129
|
import { join as join3 } from "path";
|
|
1095
1130
|
function markerPath(dir, session) {
|
|
1096
|
-
return join3(dir, "sessions", `${
|
|
1131
|
+
return join3(dir, "sessions", `${createHash3("sha256").update(session).digest("hex").slice(0, 32)}.json`);
|
|
1097
1132
|
}
|
|
1098
1133
|
function readSessionMarker(dir, session) {
|
|
1099
1134
|
try {
|
|
@@ -1191,6 +1226,7 @@ function openDatabase(path) {
|
|
|
1191
1226
|
// src/server.ts
|
|
1192
1227
|
var SWEEP_MS = 10 * 6e4;
|
|
1193
1228
|
var PRESENCE_MS = 6e4;
|
|
1229
|
+
var OWNERSHIP_MS = 2e3;
|
|
1194
1230
|
var OBSERVER_METHODS = /* @__PURE__ */ new Set(["list", "snapshot", "watch", "log", "policy", "policy_check", "cloud_status", "bridge_relink"]);
|
|
1195
1231
|
var SCOPE_METHODS = /* @__PURE__ */ new Set([
|
|
1196
1232
|
"rules",
|
|
@@ -1271,6 +1307,7 @@ async function startBroker(options) {
|
|
|
1271
1307
|
let judge;
|
|
1272
1308
|
let bridge = null;
|
|
1273
1309
|
let listener;
|
|
1310
|
+
let socketIno;
|
|
1274
1311
|
try {
|
|
1275
1312
|
privateDir(dirname2(socket));
|
|
1276
1313
|
db = openDatabase(join4(dir, "relay.db"));
|
|
@@ -1282,11 +1319,12 @@ async function startBroker(options) {
|
|
|
1282
1319
|
core = new FederatedCore(sql, {
|
|
1283
1320
|
...compact({ user: options.user, machine, now: options.now }),
|
|
1284
1321
|
hostname: shortHostname(),
|
|
1285
|
-
hash: (value) =>
|
|
1322
|
+
hash: (value) => createHash4("sha256").update(value).digest("hex"),
|
|
1286
1323
|
transaction: (fn) => db.transaction(fn),
|
|
1287
1324
|
alive: running
|
|
1288
1325
|
});
|
|
1289
1326
|
core.migrate();
|
|
1327
|
+
core.relabelMachine();
|
|
1290
1328
|
core.resetConnections();
|
|
1291
1329
|
core.sweep();
|
|
1292
1330
|
judge = new JudgeGate({ sql, core, now: options.now ?? Date.now, env: options.env ?? process.env, client: options.judgeClient, log, wake });
|
|
@@ -1323,6 +1361,7 @@ async function startBroker(options) {
|
|
|
1323
1361
|
});
|
|
1324
1362
|
listener.on("error", (error) => log(`relay broker: the socket listener failed: ${error.message}`));
|
|
1325
1363
|
chmodSync2(socket, 384);
|
|
1364
|
+
socketIno = lstatSync2(socket).ino;
|
|
1326
1365
|
} catch (error) {
|
|
1327
1366
|
listener?.close();
|
|
1328
1367
|
db?.close();
|
|
@@ -1346,6 +1385,22 @@ async function startBroker(options) {
|
|
|
1346
1385
|
}
|
|
1347
1386
|
for (const id of known.keys()) if (!live.has(id)) known.delete(id);
|
|
1348
1387
|
}, options.presenceEveryMs ?? PRESENCE_MS);
|
|
1388
|
+
const ownership = setInterval(() => {
|
|
1389
|
+
if (closing || ownsState()) return;
|
|
1390
|
+
log(`relay broker: ${socket} or its lock was removed or taken over; stopping`);
|
|
1391
|
+
void stop();
|
|
1392
|
+
}, options.ownershipEveryMs ?? OWNERSHIP_MS);
|
|
1393
|
+
ownership.unref?.();
|
|
1394
|
+
function ownsSocket() {
|
|
1395
|
+
return lstatSync2(socket, { throwIfNoEntry: false })?.ino === socketIno;
|
|
1396
|
+
}
|
|
1397
|
+
function ownsState() {
|
|
1398
|
+
try {
|
|
1399
|
+
return ownsSocket() && readFileSync6(lock.path, "utf8") === lock.content;
|
|
1400
|
+
} catch {
|
|
1401
|
+
return false;
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1349
1404
|
const remoteHooks = {
|
|
1350
1405
|
wake,
|
|
1351
1406
|
fleet: notifyFleet,
|
|
@@ -1434,6 +1489,20 @@ async function startBroker(options) {
|
|
|
1434
1489
|
function localViews() {
|
|
1435
1490
|
return core.list({ all: true }).filter((view) => core.machineOf(view.id) === null);
|
|
1436
1491
|
}
|
|
1492
|
+
function setWaker(s, endpoint) {
|
|
1493
|
+
const was = s.data.waker;
|
|
1494
|
+
if (was === endpoint) return;
|
|
1495
|
+
s.data.waker = endpoint;
|
|
1496
|
+
if (was) core.waker(was, false, { grace: s.data.transient === true });
|
|
1497
|
+
if (endpoint) core.waker(endpoint, true);
|
|
1498
|
+
for (const id of [was, endpoint]) if (id) presenceMoved(id);
|
|
1499
|
+
}
|
|
1500
|
+
function presenceMoved(id) {
|
|
1501
|
+
const view = core.whoami(id);
|
|
1502
|
+
if ((known.get(id) ?? "offline") === view.presence) return false;
|
|
1503
|
+
presenceChanged(view);
|
|
1504
|
+
return true;
|
|
1505
|
+
}
|
|
1437
1506
|
function presenceChanged(view) {
|
|
1438
1507
|
known.set(view.id, view.presence);
|
|
1439
1508
|
notifyFleet({ type: "presence", endpoint: view.address, presence: view.presence, harness: view.harness, labels: view.labels });
|
|
@@ -1585,6 +1654,7 @@ async function startBroker(options) {
|
|
|
1585
1654
|
s.data.closed = true;
|
|
1586
1655
|
clearTimeout(s.data.lineTimer);
|
|
1587
1656
|
connections.delete(s);
|
|
1657
|
+
if (!closing) setWaker(s, void 0);
|
|
1588
1658
|
if (s.data.endpoint && !closing && !s.data.transient && !s.data.lineage) {
|
|
1589
1659
|
const before = core.whoami(s.data.endpoint).presence;
|
|
1590
1660
|
const settled = core.disconnect(s.data.endpoint, { settle: !s.data.brief });
|
|
@@ -1878,7 +1948,8 @@ async function startBroker(options) {
|
|
|
1878
1948
|
case "wake": {
|
|
1879
1949
|
if (s.data.lineage && !s.data.own) throw new RelayError3("unauthorized", "only the endpoint's own session says how it wakes");
|
|
1880
1950
|
const changed = core.setWake(me, params.via, hookOf(s));
|
|
1881
|
-
|
|
1951
|
+
setWaker(s, params.via !== "none" && core.whoami(me).wake === params.via ? me : void 0);
|
|
1952
|
+
if (!presenceMoved(me) && changed) bridge?.changed(me);
|
|
1882
1953
|
return { changed };
|
|
1883
1954
|
}
|
|
1884
1955
|
case "rename": {
|
|
@@ -1981,13 +2052,23 @@ async function startBroker(options) {
|
|
|
1981
2052
|
}
|
|
1982
2053
|
const deliver = async (from, send, origin) => {
|
|
1983
2054
|
let result;
|
|
1984
|
-
|
|
2055
|
+
const remote = remoteSend(core, send.to);
|
|
2056
|
+
if (remote) {
|
|
1985
2057
|
if (!bridge)
|
|
1986
2058
|
throw new RelayError3("not_found", `no endpoint ${[send.to].flat().join(", ")}: this broker is not linked to a hub (${CLI_NAME} cloud enable)`);
|
|
1987
2059
|
result = await bridge.forward(from, send, origin);
|
|
1988
2060
|
} else {
|
|
1989
2061
|
result = core.send(from, send, origin);
|
|
1990
2062
|
}
|
|
2063
|
+
if (!result.duplicate && !remote)
|
|
2064
|
+
bridge?.local?.({
|
|
2065
|
+
id: result.id,
|
|
2066
|
+
thread: result.thread,
|
|
2067
|
+
kind: send.kind ?? "message",
|
|
2068
|
+
from,
|
|
2069
|
+
to: result.deliveries.map((d) => d.endpoint),
|
|
2070
|
+
at: (options.now ?? Date.now)()
|
|
2071
|
+
});
|
|
1991
2072
|
if (!result.duplicate) {
|
|
1992
2073
|
for (const delivery of result.deliveries) wake(delivery.endpoint);
|
|
1993
2074
|
notifyFleet({
|
|
@@ -2113,6 +2194,14 @@ async function startBroker(options) {
|
|
|
2113
2194
|
if (result.handback) {
|
|
2114
2195
|
const message = core.delivered(spawner, result.handback);
|
|
2115
2196
|
wake(spawner);
|
|
2197
|
+
bridge?.local?.({
|
|
2198
|
+
id: message.id,
|
|
2199
|
+
thread: message.thread,
|
|
2200
|
+
kind: "handback",
|
|
2201
|
+
from: message.from.endpoint,
|
|
2202
|
+
to: [spawner],
|
|
2203
|
+
at: (options.now ?? Date.now)()
|
|
2204
|
+
});
|
|
2116
2205
|
notifyFleet({
|
|
2117
2206
|
type: "message",
|
|
2118
2207
|
id: message.id,
|
|
@@ -2163,6 +2252,7 @@ async function startBroker(options) {
|
|
|
2163
2252
|
closing = true;
|
|
2164
2253
|
clearInterval(sweeper);
|
|
2165
2254
|
clearInterval(presenceTimer);
|
|
2255
|
+
clearInterval(ownership);
|
|
2166
2256
|
await bridge?.stop();
|
|
2167
2257
|
if (idleTimer) clearTimeout(idleTimer);
|
|
2168
2258
|
for (const set of [...waiters.values()]) for (const fn of [...set]) fn();
|
|
@@ -2170,11 +2260,13 @@ async function startBroker(options) {
|
|
|
2170
2260
|
while (pending > 0 && Date.now() < deadline) await sleep2(5);
|
|
2171
2261
|
await Promise.race([judge.settled(), sleep2(Math.max(0, deadline - Date.now()))]);
|
|
2172
2262
|
for (const s of connections) s.end();
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2263
|
+
if (ownsSocket()) {
|
|
2264
|
+
listener.close();
|
|
2265
|
+
try {
|
|
2266
|
+
unlinkSync2(socket);
|
|
2267
|
+
} catch {
|
|
2268
|
+
}
|
|
2269
|
+
} else listener.unref();
|
|
2178
2270
|
if (socket !== join4(dir, "relay.sock")) {
|
|
2179
2271
|
try {
|
|
2180
2272
|
rmdirSync(dirname2(socket));
|
package/dist/testing.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RelayHub, HubSocket } from '@scp-dev/hub';
|
|
1
|
+
import { RelayHub, HubSocket, DashboardEvent } from '@scp-dev/hub';
|
|
2
2
|
|
|
3
3
|
interface FakeToken {
|
|
4
4
|
tokenId: string;
|
|
@@ -27,6 +27,8 @@ declare function startFakeHubServer(options: {
|
|
|
27
27
|
tokens: Record<string, FakeToken>;
|
|
28
28
|
now?: (() => number) | undefined;
|
|
29
29
|
contacts?: [string, string][] | undefined;
|
|
30
|
+
/** receives every account's dashboard events */
|
|
31
|
+
dashboard?: ((account: string, event: DashboardEvent) => void) | undefined;
|
|
30
32
|
}): FakeHubServer;
|
|
31
33
|
|
|
32
34
|
export { type FakeHubServer, type FakeToken, startFakeHubServer };
|
package/dist/testing.js
CHANGED
|
@@ -37,7 +37,8 @@ function startFakeHubServer(options) {
|
|
|
37
37
|
sockets: () => [...sockets.values()],
|
|
38
38
|
save: () => {
|
|
39
39
|
},
|
|
40
|
-
...options.contacts ? { crossAccount: linkOf(name) } : {}
|
|
40
|
+
...options.contacts ? { crossAccount: linkOf(name) } : {},
|
|
41
|
+
...options.dashboard ? { dashboard: (event) => options.dashboard(name, event) } : {}
|
|
41
42
|
});
|
|
42
43
|
hub.init({ user: name });
|
|
43
44
|
found = { hub, sockets, traps: /* @__PURE__ */ new Map() };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scp-dev/broker",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Swarm Communication Protocol local broker: a unix socket JSON-RPC daemon over the relay core, its client and the bridge to an account hub.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://scp.dev",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"typecheck": "tsc -p ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@scp-dev/core": "0.1.
|
|
41
|
-
"@scp-dev/hub": "0.
|
|
40
|
+
"@scp-dev/core": "0.1.6",
|
|
41
|
+
"@scp-dev/hub": "0.5.1"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"tsup": "^8.5.1"
|
package/src/bridge.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
1
2
|
import {
|
|
2
3
|
type ActivityRow,
|
|
3
4
|
type EndpointView,
|
|
@@ -13,7 +14,7 @@ import {
|
|
|
13
14
|
XLIMITS,
|
|
14
15
|
type XListed,
|
|
15
16
|
} from "@scp-dev/core"
|
|
16
|
-
import { type FederatedCore, HUB_LIMITS, type RemoteView } from "@scp-dev/hub"
|
|
17
|
+
import { type FederatedCore, HUB_LIMITS, type LOCAL_EVENT_KINDS, LOCAL_EVENTS, type RemoteView } from "@scp-dev/hub"
|
|
17
18
|
|
|
18
19
|
/** what the broker gives the bridge: waking local waiters, telling local watchers, the local policy's state */
|
|
19
20
|
export interface BridgeHooks {
|
|
@@ -42,6 +43,10 @@ export interface BridgeConfig {
|
|
|
42
43
|
redact?: boolean | undefined
|
|
43
44
|
/** let messages marked private leave this machine */
|
|
44
45
|
privateOk?: boolean | undefined
|
|
46
|
+
/** report metadata of deliveries made on this machine to the hub, never bodies (default true) */
|
|
47
|
+
mirrorLocal?: boolean | undefined
|
|
48
|
+
/** how often reported local deliveries are sent, batched (default 1 s) */
|
|
49
|
+
localFlushMs?: number | undefined
|
|
45
50
|
/** a WebSocket constructor (tests) */
|
|
46
51
|
webSocket?: WebSocketWithHeaders | undefined
|
|
47
52
|
/** reconnect backoff bounds (default 1 s to 60 s) */
|
|
@@ -77,6 +82,8 @@ export interface Bridge {
|
|
|
77
82
|
renamed(endpoint: string, was: string): void
|
|
78
83
|
/** a local endpoint's activity, posted to the hub */
|
|
79
84
|
activity(endpoint: string, row: ActivityRow): void
|
|
85
|
+
/** a delivery between endpoints of this machine: its metadata is reported to the hub in the next batch */
|
|
86
|
+
local?(event: LocalDelivery): void
|
|
80
87
|
/** the endpoints of other accounts this account's contacts let it reach */
|
|
81
88
|
contacts(): Promise<XListed[]>
|
|
82
89
|
status(): BridgeStatus
|
|
@@ -85,6 +92,21 @@ export interface Bridge {
|
|
|
85
92
|
stop(): Promise<void>
|
|
86
93
|
}
|
|
87
94
|
|
|
95
|
+
/** a delivery the broker made on its own machine, by local endpoint ids */
|
|
96
|
+
export interface LocalDelivery {
|
|
97
|
+
id: string
|
|
98
|
+
thread: string
|
|
99
|
+
kind: (typeof LOCAL_EVENT_KINDS)[number]
|
|
100
|
+
from: string
|
|
101
|
+
to: string[]
|
|
102
|
+
at: number
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** local deliveries waiting for the next batch; past this, new ones are dropped until it is sent */
|
|
106
|
+
const LOCAL_BUFFER = 1000
|
|
107
|
+
|
|
108
|
+
const idHash = (id: string): string => createHash("sha256").update(id).digest("hex").slice(0, 32)
|
|
109
|
+
|
|
88
110
|
/** a WebSocket constructor that takes request headers, as Bun's and Node's do */
|
|
89
111
|
export type WebSocketWithHeaders = new (url: string, options: { headers: Record<string, string> }) => WebSocket
|
|
90
112
|
|
|
@@ -153,6 +175,10 @@ export function startBridge(options: BridgeOptions): Bridge {
|
|
|
153
175
|
const inflight = new Set<number>()
|
|
154
176
|
const injectFailures = new Map<number, number>()
|
|
155
177
|
const fromHub = new WeakSet<Error>()
|
|
178
|
+
const mirrorLocal = options.mirrorLocal !== false
|
|
179
|
+
const localFlushMs = options.localFlushMs ?? 1000
|
|
180
|
+
let localQueue: object[] = []
|
|
181
|
+
let localTimer: ReturnType<typeof setTimeout> | undefined
|
|
156
182
|
|
|
157
183
|
const setState = (up: boolean, error: string | null) => {
|
|
158
184
|
if (up !== connected || error !== lastError) since = new Date().toISOString()
|
|
@@ -274,14 +300,20 @@ export function startBridge(options: BridgeOptions): Bridge {
|
|
|
274
300
|
for (const fn of waitingForLink.splice(0)) fn()
|
|
275
301
|
}
|
|
276
302
|
|
|
277
|
-
/**
|
|
303
|
+
/**
|
|
304
|
+
* the other machines' endpoints, grouped by machine; with `full`, a known machine the hub no longer lists was released and its rows go,
|
|
305
|
+
* as do rows mirrored under this machine's own name from before it was renamed back to it
|
|
306
|
+
*/
|
|
278
307
|
function applyRemote(views: RemoteView[], full: boolean) {
|
|
279
308
|
const byMachine = new Map<string, RemoteView[]>()
|
|
280
309
|
for (const view of views) {
|
|
281
310
|
if (view.machine === machine) continue
|
|
282
311
|
byMachine.set(view.machine, [...(byMachine.get(view.machine) ?? []), view])
|
|
283
312
|
}
|
|
284
|
-
if (full)
|
|
313
|
+
if (full) {
|
|
314
|
+
core.dropMachine(machine)
|
|
315
|
+
for (const view of core.remoteViews()) if (view.machine !== account && !byMachine.has(view.machine)) core.dropMachine(view.machine)
|
|
316
|
+
}
|
|
285
317
|
for (const [name, list] of byMachine) {
|
|
286
318
|
try {
|
|
287
319
|
core.mirror(name, list, { full })
|
|
@@ -397,7 +429,8 @@ export function startBridge(options: BridgeOptions): Bridge {
|
|
|
397
429
|
if (typeof params.machine !== "string" || params.machine === machine) return
|
|
398
430
|
if (typeof params.own === "boolean") core.setOther(params.machine, !params.own)
|
|
399
431
|
try {
|
|
400
|
-
|
|
432
|
+
if (params.released === true) core.dropMachine(params.machine)
|
|
433
|
+
else core.mirror(params.machine, params.endpoints, { full: params.full === true })
|
|
401
434
|
} catch (error) {
|
|
402
435
|
log(`relay bridge: the endpoints of ${params.machine} were not applied: ${error instanceof Error ? error.message : String(error)}`)
|
|
403
436
|
}
|
|
@@ -636,6 +669,32 @@ export function startBridge(options: BridgeOptions): Bridge {
|
|
|
636
669
|
fireAndForget("event", { from: handle, kind: row.kind, tool: row.tool ?? undefined, summary: options.redact ? undefined : (row.summary ?? undefined) })
|
|
637
670
|
}
|
|
638
671
|
|
|
672
|
+
function local(event: LocalDelivery) {
|
|
673
|
+
if (!mirrorLocal || !ready || localQueue.length >= LOCAL_BUFFER) return
|
|
674
|
+
let from: string | null
|
|
675
|
+
let to: string[]
|
|
676
|
+
try {
|
|
677
|
+
from = handleOf(event.from)
|
|
678
|
+
to = event.to.flatMap((endpoint) => {
|
|
679
|
+
const handle = handleOf(endpoint)
|
|
680
|
+
return handle === null ? [] : [handle]
|
|
681
|
+
})
|
|
682
|
+
} catch {
|
|
683
|
+
return
|
|
684
|
+
}
|
|
685
|
+
if (from === null || !to.length) return
|
|
686
|
+
localQueue.push({ from, to: to.slice(0, LOCAL_EVENTS.recipients), kind: event.kind, at: event.at, id: idHash(event.id), thread: idHash(event.thread) })
|
|
687
|
+
localTimer ??= setTimeout(flushLocal, localFlushMs)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function flushLocal() {
|
|
691
|
+
localTimer = undefined
|
|
692
|
+
const batch = localQueue
|
|
693
|
+
localQueue = []
|
|
694
|
+
if (!ready) return
|
|
695
|
+
for (let i = 0; i < batch.length; i += LOCAL_EVENTS.perFrame) fireAndForget("local_events", { events: batch.slice(i, i + LOCAL_EVENTS.perFrame) })
|
|
696
|
+
}
|
|
697
|
+
|
|
639
698
|
async function contacts(): Promise<XListed[]> {
|
|
640
699
|
if (!connected) throw unavailable()
|
|
641
700
|
return (await request<{ contacts: XListed[] }>("list", { contacts: true })).contacts
|
|
@@ -660,6 +719,8 @@ export function startBridge(options: BridgeOptions): Bridge {
|
|
|
660
719
|
stopped = true
|
|
661
720
|
shutDown = true
|
|
662
721
|
clearTimeout(retryTimer)
|
|
722
|
+
clearTimeout(localTimer)
|
|
723
|
+
localQueue = []
|
|
663
724
|
for (const fn of waitingForLink.splice(0)) fn()
|
|
664
725
|
const socket = ws
|
|
665
726
|
ws = null
|
|
@@ -671,5 +732,5 @@ export function startBridge(options: BridgeOptions): Bridge {
|
|
|
671
732
|
}
|
|
672
733
|
|
|
673
734
|
connect()
|
|
674
|
-
return { forward, changed, resync, renamed, activity, contacts, status, relink, stop }
|
|
735
|
+
return { forward, changed, resync, renamed, activity, local, contacts, status, relink, stop }
|
|
675
736
|
}
|
package/src/server.ts
CHANGED
|
@@ -64,6 +64,8 @@ export interface BrokerOptions {
|
|
|
64
64
|
link?: LinkFactory | undefined
|
|
65
65
|
/** how often presence that decays without a request (a hook-only endpoint) is checked for changes (default 60 s) */
|
|
66
66
|
presenceEveryMs?: number | undefined
|
|
67
|
+
/** how often the broker checks it still owns its socket and lock, stopping once another broker or a wiped state directory took them (default 2 s) */
|
|
68
|
+
ownershipEveryMs?: number | undefined
|
|
67
69
|
/** where a judge model's key_env is read (default the broker's environment) */
|
|
68
70
|
env?: Record<string, string | undefined> | undefined
|
|
69
71
|
/** the judge model client for a policy's `judge.model` (default the provider's HTTP API) */
|
|
@@ -114,6 +116,8 @@ interface Connection {
|
|
|
114
116
|
process?: string
|
|
115
117
|
/** a one-shot CLI command's connection: its comings and goings are not fleet presence events */
|
|
116
118
|
brief?: boolean
|
|
119
|
+
/** the endpoint whose wake this connection set: it counts as its waker until it closes or sets none */
|
|
120
|
+
waker?: string | undefined
|
|
117
121
|
cwd?: string | undefined
|
|
118
122
|
watching: boolean
|
|
119
123
|
closed: boolean
|
|
@@ -126,6 +130,7 @@ interface Connection {
|
|
|
126
130
|
|
|
127
131
|
const SWEEP_MS = 10 * 60_000
|
|
128
132
|
const PRESENCE_MS = 60_000
|
|
133
|
+
const OWNERSHIP_MS = 2_000
|
|
129
134
|
const OBSERVER_METHODS = new Set(["list", "snapshot", "watch", "log", "policy", "policy_check", "cloud_status", "bridge_relink"])
|
|
130
135
|
const SCOPE_METHODS = new Set([
|
|
131
136
|
"rules",
|
|
@@ -219,6 +224,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
219
224
|
let judge!: JudgeGate
|
|
220
225
|
let bridge: Bridge | null = null
|
|
221
226
|
let listener!: Server
|
|
227
|
+
let socketIno: number | bigint | undefined
|
|
222
228
|
try {
|
|
223
229
|
privateDir(dirname(socket))
|
|
224
230
|
db = openDatabase(join(dir, "relay.db"))
|
|
@@ -235,6 +241,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
235
241
|
alive: running,
|
|
236
242
|
})
|
|
237
243
|
core.migrate()
|
|
244
|
+
core.relabelMachine()
|
|
238
245
|
core.resetConnections()
|
|
239
246
|
core.sweep()
|
|
240
247
|
judge = new JudgeGate({ sql, core, now: options.now ?? Date.now, env: options.env ?? process.env, client: options.judgeClient, log, wake })
|
|
@@ -271,6 +278,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
271
278
|
})
|
|
272
279
|
listener.on("error", (error) => log(`relay broker: the socket listener failed: ${error.message}`))
|
|
273
280
|
chmodSync(socket, 0o600)
|
|
281
|
+
socketIno = lstatSync(socket).ino
|
|
274
282
|
} catch (error) {
|
|
275
283
|
listener?.close()
|
|
276
284
|
db?.close()
|
|
@@ -294,6 +302,26 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
294
302
|
}
|
|
295
303
|
for (const id of known.keys()) if (!live.has(id)) known.delete(id)
|
|
296
304
|
}, options.presenceEveryMs ?? PRESENCE_MS)
|
|
305
|
+
const ownership = setInterval(() => {
|
|
306
|
+
if (closing || ownsState()) return
|
|
307
|
+
log(`relay broker: ${socket} or its lock was removed or taken over; stopping`)
|
|
308
|
+
void stop()
|
|
309
|
+
}, options.ownershipEveryMs ?? OWNERSHIP_MS)
|
|
310
|
+
ownership.unref?.()
|
|
311
|
+
|
|
312
|
+
/** whether the socket path is still this broker's listener */
|
|
313
|
+
function ownsSocket(): boolean {
|
|
314
|
+
return lstatSync(socket, { throwIfNoEntry: false })?.ino === socketIno
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** whether the socket path and the lock are still this broker's */
|
|
318
|
+
function ownsState(): boolean {
|
|
319
|
+
try {
|
|
320
|
+
return ownsSocket() && readFileSync(lock.path, "utf8") === lock.content
|
|
321
|
+
} catch {
|
|
322
|
+
return false
|
|
323
|
+
}
|
|
324
|
+
}
|
|
297
325
|
const remoteHooks: BridgeHooks = {
|
|
298
326
|
wake,
|
|
299
327
|
fleet: notifyFleet,
|
|
@@ -399,6 +427,24 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
399
427
|
return core.list({ all: true }).filter((view) => core.machineOf(view.id) === null)
|
|
400
428
|
}
|
|
401
429
|
|
|
430
|
+
/** counts `s` as the waker of `endpoint`, or of none, and reports the presence that moves */
|
|
431
|
+
function setWaker(s: Conn<Connection>, endpoint: string | undefined) {
|
|
432
|
+
const was = s.data.waker
|
|
433
|
+
if (was === endpoint) return
|
|
434
|
+
s.data.waker = endpoint
|
|
435
|
+
if (was) core.waker(was, false, { grace: s.data.transient === true })
|
|
436
|
+
if (endpoint) core.waker(endpoint, true)
|
|
437
|
+
for (const id of [was, endpoint]) if (id) presenceMoved(id)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** reports an endpoint's presence when it is not the one last reported; whether it was not */
|
|
441
|
+
function presenceMoved(id: string): boolean {
|
|
442
|
+
const view = core.whoami(id)
|
|
443
|
+
if ((known.get(id) ?? "offline") === view.presence) return false
|
|
444
|
+
presenceChanged(view)
|
|
445
|
+
return true
|
|
446
|
+
}
|
|
447
|
+
|
|
402
448
|
/** an endpoint's presence changed outside a connection's own hello or close: watchers and the other machines hear it */
|
|
403
449
|
function presenceChanged(view: EndpointView) {
|
|
404
450
|
known.set(view.id, view.presence)
|
|
@@ -583,6 +629,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
583
629
|
s.data.closed = true
|
|
584
630
|
clearTimeout(s.data.lineTimer)
|
|
585
631
|
connections.delete(s)
|
|
632
|
+
if (!closing) setWaker(s, undefined)
|
|
586
633
|
if (s.data.endpoint && !closing && !s.data.transient && !s.data.lineage) {
|
|
587
634
|
const before = core.whoami(s.data.endpoint).presence
|
|
588
635
|
const settled = core.disconnect(s.data.endpoint, { settle: !s.data.brief })
|
|
@@ -891,7 +938,8 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
891
938
|
case "wake": {
|
|
892
939
|
if (s.data.lineage && !s.data.own) throw new RelayError("unauthorized", "only the endpoint's own session says how it wakes")
|
|
893
940
|
const changed = core.setWake(me, params.via, hookOf(s))
|
|
894
|
-
|
|
941
|
+
setWaker(s, params.via !== "none" && core.whoami(me).wake === params.via ? me : undefined)
|
|
942
|
+
if (!presenceMoved(me) && changed) bridge?.changed(me)
|
|
895
943
|
return { changed }
|
|
896
944
|
}
|
|
897
945
|
case "rename": {
|
|
@@ -996,13 +1044,23 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
996
1044
|
/** a send as `from` to local endpoints or, through the bridge, other machines; wakes the recipients and tells the watchers */
|
|
997
1045
|
const deliver: Deliver = async (from, send, origin) => {
|
|
998
1046
|
let result: SendResult
|
|
999
|
-
|
|
1047
|
+
const remote = remoteSend(core, send.to)
|
|
1048
|
+
if (remote) {
|
|
1000
1049
|
if (!bridge)
|
|
1001
1050
|
throw new RelayError("not_found", `no endpoint ${[send.to].flat().join(", ")}: this broker is not linked to a hub (${CLI_NAME} cloud enable)`)
|
|
1002
1051
|
result = await bridge.forward(from, send, origin)
|
|
1003
1052
|
} else {
|
|
1004
1053
|
result = core.send(from, send, origin)
|
|
1005
1054
|
}
|
|
1055
|
+
if (!result.duplicate && !remote)
|
|
1056
|
+
bridge?.local?.({
|
|
1057
|
+
id: result.id,
|
|
1058
|
+
thread: result.thread,
|
|
1059
|
+
kind: send.kind ?? "message",
|
|
1060
|
+
from,
|
|
1061
|
+
to: result.deliveries.map((d) => d.endpoint),
|
|
1062
|
+
at: (options.now ?? Date.now)(),
|
|
1063
|
+
})
|
|
1006
1064
|
if (!result.duplicate) {
|
|
1007
1065
|
for (const delivery of result.deliveries) wake(delivery.endpoint)
|
|
1008
1066
|
notifyFleet({
|
|
@@ -1138,6 +1196,14 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
1138
1196
|
if (result.handback) {
|
|
1139
1197
|
const message = core.delivered(spawner, result.handback)!
|
|
1140
1198
|
wake(spawner)
|
|
1199
|
+
bridge?.local?.({
|
|
1200
|
+
id: message.id,
|
|
1201
|
+
thread: message.thread,
|
|
1202
|
+
kind: "handback",
|
|
1203
|
+
from: message.from.endpoint,
|
|
1204
|
+
to: [spawner],
|
|
1205
|
+
at: (options.now ?? Date.now)(),
|
|
1206
|
+
})
|
|
1141
1207
|
notifyFleet({
|
|
1142
1208
|
type: "message",
|
|
1143
1209
|
id: message.id,
|
|
@@ -1192,6 +1258,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
1192
1258
|
closing = true
|
|
1193
1259
|
clearInterval(sweeper)
|
|
1194
1260
|
clearInterval(presenceTimer)
|
|
1261
|
+
clearInterval(ownership)
|
|
1195
1262
|
await bridge?.stop()
|
|
1196
1263
|
if (idleTimer) clearTimeout(idleTimer)
|
|
1197
1264
|
for (const set of [...waiters.values()]) for (const fn of [...set]) fn()
|
|
@@ -1199,10 +1266,13 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
1199
1266
|
while (pending > 0 && Date.now() < deadline) await sleep(5)
|
|
1200
1267
|
await Promise.race([judge.settled(), sleep(Math.max(0, deadline - Date.now()))])
|
|
1201
1268
|
for (const s of connections) s.end()
|
|
1202
|
-
listener
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1269
|
+
// closing a listener unlinks its path, which another broker may own by now
|
|
1270
|
+
if (ownsSocket()) {
|
|
1271
|
+
listener.close()
|
|
1272
|
+
try {
|
|
1273
|
+
unlinkSync(socket)
|
|
1274
|
+
} catch {}
|
|
1275
|
+
} else listener.unref()
|
|
1206
1276
|
if (socket !== join(dir, "relay.sock")) {
|
|
1207
1277
|
try {
|
|
1208
1278
|
rmdirSync(dirname(socket))
|
package/src/testing.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite"
|
|
2
2
|
import type { Server, ServerWebSocket } from "bun"
|
|
3
3
|
import { type CrossAccountLink, PASS_GATE, sha256Hex, type Sql, type SqlValue, type XContact } from "@scp-dev/core"
|
|
4
|
-
import { type HubGrant, type HubSocket, RelayHub } from "@scp-dev/hub"
|
|
4
|
+
import { type DashboardEvent, type HubGrant, type HubSocket, RelayHub } from "@scp-dev/hub"
|
|
5
5
|
|
|
6
6
|
export interface FakeToken {
|
|
7
7
|
tokenId: string
|
|
@@ -40,6 +40,8 @@ export function startFakeHubServer(options: {
|
|
|
40
40
|
tokens: Record<string, FakeToken>
|
|
41
41
|
now?: (() => number) | undefined
|
|
42
42
|
contacts?: [string, string][] | undefined
|
|
43
|
+
/** receives every account's dashboard events */
|
|
44
|
+
dashboard?: ((account: string, event: DashboardEvent) => void) | undefined
|
|
43
45
|
}): FakeHubServer {
|
|
44
46
|
const accounts = new Map<string, Account>()
|
|
45
47
|
|
|
@@ -77,6 +79,7 @@ export function startFakeHubServer(options: {
|
|
|
77
79
|
sockets: () => [...sockets.values()],
|
|
78
80
|
save: () => {},
|
|
79
81
|
...(options.contacts ? { crossAccount: linkOf(name) } : {}),
|
|
82
|
+
...(options.dashboard ? { dashboard: (event: DashboardEvent) => options.dashboard!(name, event) } : {}),
|
|
80
83
|
})
|
|
81
84
|
hub.init({ user: name })
|
|
82
85
|
found = { hub, sockets, traps: new Map() }
|