@scp-dev/hub 0.1.0 → 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 +72 -3
- package/dist/index.js +100 -20
- package/package.json +2 -2
- package/src/federation.ts +46 -11
- package/src/hub.ts +106 -10
- package/src/index.ts +17 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Envelope, RelayCore, CoreOptions, EndpointRow, PolicySubject, ScopeSubject, ScopeNode, ScopeDirection, Admission, MessageRow, EndpointView, ScopeCap, ScopeEntry, Claim, SendParams, SendResult, EndpointKind, Presence, StatusState, Sql, CrossAccountLink, XDelivered, XListed, XRelease } from '@scp-dev/core';
|
|
1
|
+
import { Envelope, RelayCore, CoreOptions, EndpointRow, PolicySubject, ScopeSubject, ScopeNode, ScopeDirection, Admission, MessageRow, EndpointView, ScopeCap, ScopeEntry, Claim, SendParams, SendResult, EndpointKind, Presence, StatusState, Sql, CrossAccountLink, XDelivered, XListed, ActivityRow, XRelease } from '@scp-dev/core';
|
|
2
2
|
|
|
3
3
|
type RelayAuth = Envelope["from"]["auth"];
|
|
4
4
|
/** a machine name: one relay host of an account */
|
|
@@ -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 */
|
|
@@ -397,6 +409,49 @@ interface HubConn {
|
|
|
397
409
|
/** whether a human client said a terminal is attached */
|
|
398
410
|
tty?: boolean;
|
|
399
411
|
}
|
|
412
|
+
/** a bound machine, or a revoked token's name kept as a tombstone */
|
|
413
|
+
interface MachineRow {
|
|
414
|
+
name: string;
|
|
415
|
+
kind: string;
|
|
416
|
+
online: boolean;
|
|
417
|
+
bridge: boolean;
|
|
418
|
+
created_at: string | null;
|
|
419
|
+
last_seen: string | null;
|
|
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;
|
|
400
455
|
interface HubSocket {
|
|
401
456
|
conn: HubConn;
|
|
402
457
|
send(text: string): void;
|
|
@@ -559,7 +614,10 @@ declare class RelayHub {
|
|
|
559
614
|
private announce;
|
|
560
615
|
/** a direct client's endpoint changed: the other bridges mirror it */
|
|
561
616
|
private announceEndpoint;
|
|
617
|
+
/** `own` tells each bridge whether a bound machine is its member's own device */
|
|
562
618
|
private toBridges;
|
|
619
|
+
/** the machines of other members than the grant's, which a bridge shows under the account handle */
|
|
620
|
+
private othersOf;
|
|
563
621
|
private fleet;
|
|
564
622
|
private write;
|
|
565
623
|
private save;
|
|
@@ -574,8 +632,19 @@ declare class RelayHub {
|
|
|
574
632
|
/** machines a live connection holds */
|
|
575
633
|
private liveMachines;
|
|
576
634
|
/** bindings, then names a revoke left behind (kind `revoked`) */
|
|
635
|
+
/** a read-only view of the account for a dashboard: its machines, listed endpoints and latest activity */
|
|
636
|
+
overview(activity?: number): {
|
|
637
|
+
machines: MachineRow[];
|
|
638
|
+
endpoints: EndpointView[];
|
|
639
|
+
activity: ActivityRow[];
|
|
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;
|
|
577
643
|
private machineList;
|
|
578
|
-
/**
|
|
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
|
+
*/
|
|
579
648
|
private releaseMachine;
|
|
580
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 */
|
|
581
650
|
private expireMachines;
|
|
@@ -599,4 +668,4 @@ declare class RelayHub {
|
|
|
599
668
|
xnotice(endpoint: string, text: string): void;
|
|
600
669
|
}
|
|
601
670
|
|
|
602
|
-
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 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 } });
|
|
@@ -2721,6 +2758,43 @@ var RelayHub = class _RelayHub {
|
|
|
2721
2758
|
return live;
|
|
2722
2759
|
}
|
|
2723
2760
|
/** bindings, then names a revoke left behind (kind `revoked`) */
|
|
2761
|
+
/** a read-only view of the account for a dashboard: its machines, listed endpoints and latest activity */
|
|
2762
|
+
overview(activity = 20) {
|
|
2763
|
+
const { endpoints, activity: rows } = this.relay.snapshot({ activity });
|
|
2764
|
+
return { machines: this.machineList(), endpoints, activity: rows };
|
|
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
|
+
}
|
|
2724
2798
|
machineList() {
|
|
2725
2799
|
const sockets = this.deps.sockets().filter((s) => !this.gone.has(s.conn.id));
|
|
2726
2800
|
const bound = this.deps.sql.all(`SELECT name, kind, created_at, last_seen FROM machines ORDER BY name`).map((row) => ({
|
|
@@ -2734,7 +2808,10 @@ var RelayHub = class _RelayHub {
|
|
|
2734
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) }));
|
|
2735
2809
|
return [...bound, ...revoked];
|
|
2736
2810
|
}
|
|
2737
|
-
/**
|
|
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
|
+
*/
|
|
2738
2815
|
releaseMachine(caller, name) {
|
|
2739
2816
|
const core = this.relay;
|
|
2740
2817
|
if (typeof name !== "string" || !MACHINE_NAME.test(name)) throw new RelayError2("invalid_params", "name is a machine name");
|
|
@@ -2742,11 +2819,12 @@ var RelayHub = class _RelayHub {
|
|
|
2742
2819
|
const tomb = bound ? void 0 : one2(this.deps.sql, `SELECT actor_id FROM machine_tombstones WHERE name = ?`, name);
|
|
2743
2820
|
if (!bound && !tomb) throw new RelayError2("not_found", `no machine ${name}`);
|
|
2744
2821
|
const grant = caller.conn.grant;
|
|
2745
|
-
|
|
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))) {
|
|
2746
2824
|
core.record("refuse", null, name, { code: "unauthorized", machine: name });
|
|
2747
2825
|
throw new RelayError2(
|
|
2748
2826
|
"unauthorized",
|
|
2749
|
-
`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" : ""}`
|
|
2750
2828
|
);
|
|
2751
2829
|
}
|
|
2752
2830
|
for (const socket of [...this.deps.sockets()]) {
|
|
@@ -2867,6 +2945,8 @@ var RelayHub = class _RelayHub {
|
|
|
2867
2945
|
};
|
|
2868
2946
|
export {
|
|
2869
2947
|
FederatedCore,
|
|
2948
|
+
GRAPH_MINUTES,
|
|
2949
|
+
GRAPH_MINUTES_MAX,
|
|
2870
2950
|
HUB_LIMITS,
|
|
2871
2951
|
MACHINE_NAME,
|
|
2872
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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type ActivityRow,
|
|
2
3
|
allowlisted,
|
|
3
4
|
CAPABILITIES,
|
|
4
5
|
CLI_NAME,
|
|
@@ -81,6 +82,44 @@ export interface HubConn {
|
|
|
81
82
|
tty?: boolean
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
/** a bound machine, or a revoked token's name kept as a tombstone */
|
|
86
|
+
export interface MachineRow {
|
|
87
|
+
name: string
|
|
88
|
+
kind: string
|
|
89
|
+
online: boolean
|
|
90
|
+
bridge: boolean
|
|
91
|
+
created_at: string | null
|
|
92
|
+
last_seen: string | null
|
|
93
|
+
}
|
|
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
|
+
|
|
84
123
|
export interface HubSocket {
|
|
85
124
|
conn: HubConn
|
|
86
125
|
send(text: string): void
|
|
@@ -630,8 +669,11 @@ export class RelayHub {
|
|
|
630
669
|
serverFields(params)
|
|
631
670
|
const observe = params.observe === true
|
|
632
671
|
const named = params.machine
|
|
633
|
-
if (
|
|
634
|
-
|
|
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")
|
|
635
677
|
}
|
|
636
678
|
const bridge = params.bridge === true
|
|
637
679
|
const human = params.human === true
|
|
@@ -679,7 +721,7 @@ export class RelayHub {
|
|
|
679
721
|
this.release(other, true)
|
|
680
722
|
}
|
|
681
723
|
core.record("bridge", null, machine, { machine })
|
|
682
|
-
return { ...base, endpoints: core.remoteViews({ except: machine }) }
|
|
724
|
+
return { ...base, endpoints: core.remoteViews({ except: machine }), others: this.othersOf(conn.grant, machine) }
|
|
683
725
|
}
|
|
684
726
|
|
|
685
727
|
if (one<{ kind: string }>(this.deps.sql, `SELECT kind FROM machines WHERE name = ?`, machine)?.kind === "bridge") {
|
|
@@ -1290,7 +1332,7 @@ export class RelayHub {
|
|
|
1290
1332
|
/** a machine's endpoint changes: `endpoints` to the other bridges, presence and status to watchers */
|
|
1291
1333
|
private announce(machine: string, changes: MirrorChange[], full: boolean): void {
|
|
1292
1334
|
if (full || changes.length) {
|
|
1293
|
-
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 }))
|
|
1294
1336
|
this.toBridges(machine, { machine, endpoints, full })
|
|
1295
1337
|
}
|
|
1296
1338
|
for (const change of changes) {
|
|
@@ -1309,12 +1351,23 @@ export class RelayHub {
|
|
|
1309
1351
|
if (view) this.toBridges(machine, { machine, endpoints: [view], full: false })
|
|
1310
1352
|
}
|
|
1311
1353
|
|
|
1312
|
-
|
|
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)
|
|
1313
1357
|
for (const socket of this.deps.sockets()) {
|
|
1314
|
-
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 } })
|
|
1315
1361
|
}
|
|
1316
1362
|
}
|
|
1317
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
|
+
|
|
1318
1371
|
private fleet(event: Record<string, unknown>): void {
|
|
1319
1372
|
const at = new Date(this.now()).toISOString()
|
|
1320
1373
|
for (const socket of this.deps.sockets()) if (socket.conn.watching) this.write(socket, { jsonrpc: "2.0", method: "fleet", params: { at, ...event } })
|
|
@@ -1442,7 +1495,46 @@ export class RelayHub {
|
|
|
1442
1495
|
}
|
|
1443
1496
|
|
|
1444
1497
|
/** bindings, then names a revoke left behind (kind `revoked`) */
|
|
1445
|
-
|
|
1498
|
+
/** a read-only view of the account for a dashboard: its machines, listed endpoints and latest activity */
|
|
1499
|
+
overview(activity = 20): { machines: MachineRow[]; endpoints: EndpointView[]; activity: ActivityRow[] } {
|
|
1500
|
+
const { endpoints, activity: rows } = this.relay.snapshot({ activity })
|
|
1501
|
+
return { machines: this.machineList(), endpoints, activity: rows }
|
|
1502
|
+
}
|
|
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
|
+
|
|
1537
|
+
private machineList(): MachineRow[] {
|
|
1446
1538
|
const sockets = this.deps.sockets().filter((s) => !this.gone.has(s.conn.id))
|
|
1447
1539
|
const bound = this.deps.sql
|
|
1448
1540
|
.all<{ name: string; kind: string; created_at: number; last_seen: number | null }>(`SELECT name, kind, created_at, last_seen FROM machines ORDER BY name`)
|
|
@@ -1460,7 +1552,10 @@ export class RelayHub {
|
|
|
1460
1552
|
return [...bound, ...revoked]
|
|
1461
1553
|
}
|
|
1462
1554
|
|
|
1463
|
-
/**
|
|
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
|
+
*/
|
|
1464
1559
|
private releaseMachine(caller: HubSocket, name: unknown): unknown {
|
|
1465
1560
|
const core = this.relay
|
|
1466
1561
|
if (typeof name !== "string" || !MACHINE_NAME.test(name)) throw new RelayError("invalid_params", "name is a machine name")
|
|
@@ -1468,11 +1563,12 @@ export class RelayHub {
|
|
|
1468
1563
|
const tomb = bound ? undefined : one<{ actor_id: string | null }>(this.deps.sql, `SELECT actor_id FROM machine_tombstones WHERE name = ?`, name)
|
|
1469
1564
|
if (!bound && !tomb) throw new RelayError("not_found", `no machine ${name}`)
|
|
1470
1565
|
const grant = caller.conn.grant
|
|
1471
|
-
|
|
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)))) {
|
|
1472
1568
|
core.record("refuse", null, name, { code: "unauthorized", machine: name })
|
|
1473
1569
|
throw new RelayError(
|
|
1474
1570
|
"unauthorized",
|
|
1475
|
-
`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" : ""}`,
|
|
1476
1572
|
)
|
|
1477
1573
|
}
|
|
1478
1574
|
for (const socket of [...this.deps.sockets()]) {
|
package/src/index.ts
CHANGED
|
@@ -12,4 +12,20 @@ export {
|
|
|
12
12
|
type RemoteView,
|
|
13
13
|
type XEndpoint,
|
|
14
14
|
} from "./federation"
|
|
15
|
-
export {
|
|
15
|
+
export {
|
|
16
|
+
GRAPH_MINUTES,
|
|
17
|
+
GRAPH_MINUTES_MAX,
|
|
18
|
+
type GraphEdge,
|
|
19
|
+
type GraphNode,
|
|
20
|
+
type HubGraph,
|
|
21
|
+
HUB_LIMITS,
|
|
22
|
+
RelayHub,
|
|
23
|
+
type HubAuth,
|
|
24
|
+
type HubConn,
|
|
25
|
+
type HubDeps,
|
|
26
|
+
type HubGrant,
|
|
27
|
+
type HubLimits,
|
|
28
|
+
type HubRole,
|
|
29
|
+
type HubSocket,
|
|
30
|
+
type MachineRow,
|
|
31
|
+
} from "./hub"
|