@scp-dev/hub 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as _scp_dev_core from '@scp-dev/core';
2
- import { Envelope, RelayCore, CoreOptions, EndpointRow, 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';
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, WakeDecline, 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 */
@@ -175,8 +175,12 @@ declare class FederatedCore extends RelayCore {
175
175
  protected renamedHandle(from: string, to: string): void;
176
176
  protected linkedAcross(a: EndpointRow, b: EndpointRow): boolean;
177
177
  protected linkedParent(row: EndpointRow, shown?: Set<string>): string | null;
178
- /** a live mirrored row shows the presence its machine mirrored, not one its last_seen decays to here */
178
+ /**
179
+ * a live mirrored row shows the presence its machine mirrored, not one its last_seen decays to here; a hibernated one stays hibernated with
180
+ * its hook wake after its bridge closed
181
+ */
179
182
  protected view(row: EndpointRow, shown?: Set<string>): EndpointView;
183
+ protected hibernated(row: EndpointRow): boolean;
180
184
  protected address(row: EndpointRow): string;
181
185
  protected subject(row: EndpointRow): PolicySubject;
182
186
  /** a stand-in for another account's sender is no endpoint of this relay: list, peers and snapshots leave it out */
@@ -575,6 +579,8 @@ interface MachineRow {
575
579
  bridge: boolean;
576
580
  created_at: string | null;
577
581
  last_seen: string | null;
582
+ /** a machine waker URL is set; the URL and its secret are never shown */
583
+ wake?: boolean;
578
584
  }
579
585
  /** an endpoint as the dashboard graph draws it */
580
586
  interface GraphNode {
@@ -640,6 +646,24 @@ interface HubSocket {
640
646
  send(text: string): void;
641
647
  close(code: number, reason: string): void;
642
648
  }
649
+ /** what a machine waker gets: who to wake and the message's metadata, never its body */
650
+ interface MachineWakeEvent {
651
+ scp: 1;
652
+ event: "machine_wake";
653
+ account: string;
654
+ machine: string;
655
+ endpoints: string[];
656
+ message: {
657
+ id: string;
658
+ from: string;
659
+ kind: string;
660
+ thread: string;
661
+ created_at: string;
662
+ };
663
+ /** the sender's account handle; another account's sender never chooses the credentials a wake uses */
664
+ from_account: string;
665
+ same_account: boolean;
666
+ }
643
667
  interface HubDeps {
644
668
  sql: Sql;
645
669
  /** synchronous sha256 hex */
@@ -659,6 +683,22 @@ interface HubDeps {
659
683
  limits?: Partial<HubLimits>;
660
684
  /** the link to other accounts' relays; without it an address of another account is not found */
661
685
  crossAccount?: CrossAccountLink;
686
+ /** an HTTP client for machine waker URLs; without it (and without `machineWake`) no machine waker is called */
687
+ fetch?: (url: string, init: {
688
+ method: string;
689
+ headers: Record<string, string>;
690
+ body: string;
691
+ signal?: AbortSignal;
692
+ }) => Promise<{
693
+ status: number;
694
+ text?: () => Promise<string>;
695
+ }>;
696
+ /** the embedder's own machine waker, called in-process for any machine in place of a registered URL; metadata only, never a body or secret */
697
+ machineWake?: (event: MachineWakeEvent) => Promise<{
698
+ ok: boolean;
699
+ status?: number;
700
+ declined?: WakeDecline;
701
+ }>;
662
702
  }
663
703
  /** what a configured judge needs besides its policy */
664
704
  interface HubJudgeOptions {
@@ -717,6 +757,8 @@ declare class RelayHub {
717
757
  private readonly limits;
718
758
  private judgeConfig;
719
759
  private gate;
760
+ /** per machine, when its waker may be called next and the calls that failed in a row */
761
+ private readonly machineWakes;
720
762
  constructor(deps: HubDeps);
721
763
  /** idempotent: migrates and records the account handle (the address user part, e.g. "ada"); call before handling each request */
722
764
  init(account: {
@@ -839,8 +881,18 @@ declare class RelayHub {
839
881
  /** bytes of message bodies held, and of the messages from other accounts */
840
882
  private storedNow;
841
883
  private stash;
842
- /** wakes direct waiters and pushes a delivery for a mirrored row to the subscribed bridge of its machine */
884
+ /**
885
+ * wakes direct waiters and pushes a delivery for a mirrored row to the subscribed bridge of its machine; with none subscribed, a hibernated
886
+ * row's machine waker is called
887
+ */
843
888
  private route;
889
+ /** sets or (with a null url) clears the bridge's own machine waker: an https URL (http only to this host) and the secret that signs its calls */
890
+ private setMachineWake;
891
+ /**
892
+ * calls `machine`'s waker for its hibernated `recipient`: the embedder's `machineWake`, else the registered URL, signed. At most once per
893
+ * debounce window (doubling after failures); a decline counts toward nothing and holds calls for that message and its sender. Metadata only
894
+ */
895
+ private machineWake;
844
896
  /** a machine's endpoint changes: `endpoints` to the other bridges, presence and status to watchers */
845
897
  private announce;
846
898
  /** a direct client's endpoint changed: the other bridges mirror it */
@@ -900,4 +952,4 @@ declare class RelayHub {
900
952
  xnotice(endpoint: string, text: string): void;
901
953
  }
902
954
 
903
- 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 };
955
+ 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 MachineWakeEvent, type MirrorCaps, type MirrorChange, type MirrorRules, type MirrorScope, type MirrorView, type RelayAuth, RelayHub, type RemoteView, type XEndpoint, dashboardEvent };
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  MESSAGE_KINDS,
14
14
  newId,
15
15
  one,
16
+ PRESENCES,
16
17
  randomHex,
17
18
  RelayCore,
18
19
  RelayError,
@@ -31,7 +32,7 @@ var MACHINE_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/;
31
32
  var REMOTE = /^[a-z0-9][a-z0-9._-]{0,63}\/([a-z0-9][a-z0-9._-]{0,62})?$/;
32
33
  var MESSAGE_ID = /^[a-z]{2,8}_[a-z0-9]{8,64}$/;
33
34
  var AUTHS = ["local-uid", "account", "account+bridge", "contact"];
34
- var PRESENCES = ["online", "idle", "offline"];
35
+ var HOOK_WAKE = /^hook:[a-z0-9][a-z0-9-]{0,31}$/;
35
36
  var UNKNOWN = { tree: null, parent_ep: null, depth: null };
36
37
  var REFS = /* @__PURE__ */ new Set(["human", "policy", "inherited", "gone"]);
37
38
  var INCARNATION = /^[A-Za-z0-9._:-]{1,128}$/;
@@ -274,12 +275,20 @@ var FederatedCore = class extends RelayCore {
274
275
  if (parent) return shown && !shown.has(parent.id) ? null : this.address(parent);
275
276
  return shown ? null : this.remoteAddress(key);
276
277
  }
277
- /** a live mirrored row shows the presence its machine mirrored, not one its last_seen decays to here */
278
+ /**
279
+ * a live mirrored row shows the presence its machine mirrored, not one its last_seen decays to here; a hibernated one stays hibernated with
280
+ * its hook wake after its bridge closed
281
+ */
278
282
  view(row, shown) {
279
283
  const view = super.view(row, shown);
280
284
  const { remote, mirrored_presence: mirrored } = row;
285
+ if (remote && mirrored === "hibernated")
286
+ return { ...view, presence: "hibernated", ...view.kind === "agent" && row.wake && HOOK_WAKE.test(row.wake) ? { wake: row.wake } : {} };
281
287
  return remote && row.connections > 0 && (mirrored === "online" || mirrored === "idle") ? { ...view, presence: mirrored } : view;
282
288
  }
289
+ hibernated(row) {
290
+ return super.hibernated(row) || row.remote !== null && row.mirrored_presence === "hibernated";
291
+ }
283
292
  address(row) {
284
293
  const remote = row.remote;
285
294
  if (remote && isForeign(remote)) return remote.slice(1);
@@ -439,7 +448,7 @@ var FederatedCore = class extends RelayCore {
439
448
  for (const { view, id, before } of batch) {
440
449
  const row = before ?? this.byId(id);
441
450
  if (before && view.kind === "agent") this.dropEarlierHolder(before, view.scope.incarnation);
442
- const live = view.presence !== "offline";
451
+ const live = view.presence !== "offline" && view.presence !== "hibernated";
443
452
  const next = {
444
453
  kind: view.kind,
445
454
  harness: view.harness,
@@ -1322,7 +1331,7 @@ var FederatedCore = class extends RelayCore {
1322
1331
  status = { state: view.status.state, note: cleanText(view.status.note, "note", LIMITS.noteChars) ?? "" };
1323
1332
  }
1324
1333
  const presence = view.presence ?? "offline";
1325
- if (!PRESENCES.includes(presence)) throw new RelayError("invalid_params", "presence is online, idle or offline");
1334
+ if (!PRESENCES.includes(presence)) throw new RelayError("invalid_params", "presence is online, idle, offline or hibernated");
1326
1335
  const parsedSeen = typeof view.last_seen === "string" ? Date.parse(view.last_seen) : Number.NaN;
1327
1336
  const out = {
1328
1337
  handle,
@@ -1582,7 +1591,8 @@ function parseScope(view) {
1582
1591
  throw new RelayError("too_large", `an endpoint's rules and caps are more than ${SCOPE_BYTES} bytes`);
1583
1592
  if (given("title"))
1584
1593
  out.title = view.title === null ? null : cleanText(typeof view.title === "string" ? view.title.replace(/\s+/g, " ") : view.title, "title", 64) || null;
1585
- if (given("wake")) out.wake = typeof view.wake === "string" && view.wake !== "none" && WAKES.includes(view.wake) ? view.wake : null;
1594
+ if (given("wake"))
1595
+ out.wake = typeof view.wake === "string" && view.wake !== "none" && (WAKES.includes(view.wake) || HOOK_WAKE.test(view.wake)) ? view.wake : null;
1586
1596
  if (given("incarnation")) {
1587
1597
  if (view.incarnation !== null && (typeof view.incarnation !== "string" || !INCARNATION.test(view.incarnation))) throw bad("incarnation");
1588
1598
  out.incarnation = view.incarnation ?? null;
@@ -1618,6 +1628,7 @@ import {
1618
1628
  SYSTEM_HANDLE as SYSTEM_HANDLE2,
1619
1629
  sameSecret,
1620
1630
  untilTomorrow,
1631
+ wakeDeclineOf,
1621
1632
  XCounters,
1622
1633
  XLIMITS,
1623
1634
  xlimits
@@ -2210,6 +2221,8 @@ var RelayHub = class _RelayHub {
2210
2221
  limits;
2211
2222
  judgeConfig = null;
2212
2223
  gate = null;
2224
+ /** per machine, when its waker may be called next and the calls that failed in a row */
2225
+ machineWakes = /* @__PURE__ */ new Map();
2213
2226
  /** idempotent: migrates and records the account handle (the address user part, e.g. "ada"); call before handling each request */
2214
2227
  init(account) {
2215
2228
  if (this.core && this.account === account.user) return;
@@ -2217,6 +2230,8 @@ var RelayHub = class _RelayHub {
2217
2230
  const columns = this.deps.sql.all(`PRAGMA table_info(machines)`).map((c) => c.name);
2218
2231
  if (!columns.includes("user_id")) this.deps.sql.all(`ALTER TABLE machines ADD COLUMN user_id TEXT`);
2219
2232
  if (!columns.includes("actor_id")) this.deps.sql.all(`ALTER TABLE machines ADD COLUMN actor_id TEXT`);
2233
+ if (!columns.includes("wake_url")) this.deps.sql.all(`ALTER TABLE machines ADD COLUMN wake_url TEXT`);
2234
+ if (!columns.includes("wake_secret")) this.deps.sql.all(`ALTER TABLE machines ADD COLUMN wake_secret TEXT`);
2220
2235
  const core = new FederatedCore(this.deps.sql, {
2221
2236
  user: account.user,
2222
2237
  now: this.now,
@@ -2883,6 +2898,8 @@ var RelayHub = class _RelayHub {
2883
2898
  conn.watching = true;
2884
2899
  this.save(socket);
2885
2900
  return core.snapshot({ activity: 20 });
2901
+ case "machine_wake":
2902
+ return this.setMachineWake(machine, params.url, params.secret);
2886
2903
  }
2887
2904
  throw new RelayError3("method_not_found", `no method ${method} on a bridge`);
2888
2905
  }
@@ -3287,20 +3304,117 @@ var RelayHub = class _RelayHub {
3287
3304
  stash(bytes) {
3288
3305
  if (this.stored !== null) this.stored += bytes;
3289
3306
  }
3290
- /** wakes direct waiters and pushes a delivery for a mirrored row to the subscribed bridge of its machine */
3307
+ /**
3308
+ * wakes direct waiters and pushes a delivery for a mirrored row to the subscribed bridge of its machine; with none subscribed, a hibernated
3309
+ * row's machine waker is called
3310
+ */
3291
3311
  route(seq, endpoint) {
3292
3312
  this.wake(endpoint);
3293
3313
  const delivery = this.relay.delivery(seq);
3294
3314
  if (!delivery?.endpoint || !delivery.mirrored) return;
3295
3315
  const machine = delivery.endpoint.slice(0, delivery.endpoint.indexOf("/"));
3316
+ let pushed = false;
3296
3317
  for (const socket of this.deps.sockets()) {
3297
3318
  if (socket.conn.role === "bridge" && socket.conn.subscribed && socket.conn.machine === machine) {
3298
3319
  this.write(socket, { jsonrpc: "2.0", method: "deliver", params: { seq, endpoint: delivery.endpoint, message: delivery.message } });
3320
+ pushed = true;
3299
3321
  }
3300
3322
  }
3323
+ if (!pushed && delivery.recipient) this.machineWake(machine, delivery.recipient, delivery.message);
3324
+ }
3325
+ /** sets or (with a null url) clears the bridge's own machine waker: an https URL (http only to this host) and the secret that signs its calls */
3326
+ setMachineWake(machine, url, secret) {
3327
+ if (url === null || url === void 0) {
3328
+ this.deps.sql.all(`UPDATE machines SET wake_url = NULL, wake_secret = NULL WHERE name = ?`, machine);
3329
+ this.machineWakes.delete(machine);
3330
+ this.relay.record("machine_wake_set", null, machine, { machine, set: false });
3331
+ return { set: false };
3332
+ }
3333
+ if (typeof url !== "string" || url.length > MACHINE_WAKE_URL_CHARS || !wakeUrl(url))
3334
+ throw new RelayError3("invalid_params", `url is an https URL of at most ${MACHINE_WAKE_URL_CHARS} characters (http only to localhost)`);
3335
+ if (typeof secret !== "string" || secret.length < 16 || secret.length > 256)
3336
+ throw new RelayError3("invalid_params", "secret is 16 to 256 characters, and a url needs one");
3337
+ this.deps.sql.all(`UPDATE machines SET wake_url = ?, wake_secret = ? WHERE name = ?`, url, secret, machine);
3338
+ this.machineWakes.delete(machine);
3339
+ this.relay.record("machine_wake_set", null, machine, { machine, set: true });
3340
+ return { set: true };
3341
+ }
3342
+ /**
3343
+ * calls `machine`'s waker for its hibernated `recipient`: the embedder's `machineWake`, else the registered URL, signed. At most once per
3344
+ * debounce window (doubling after failures); a decline counts toward nothing and holds calls for that message and its sender. Metadata only
3345
+ */
3346
+ machineWake(machine, recipient, message) {
3347
+ const embedded = this.deps.machineWake;
3348
+ if (!embedded && !this.deps.fetch) return;
3349
+ const now = this.now();
3350
+ const state = this.machineWakes.get(machine);
3351
+ if (state && state.next > now) return;
3352
+ if (state?.declined && (state.declined.message === message.id || state.declined.from === message.from.address)) return;
3353
+ let view;
3354
+ try {
3355
+ view = this.relay.whoami(recipient);
3356
+ } catch {
3357
+ return;
3358
+ }
3359
+ if (view.presence !== "hibernated") return;
3360
+ const waker = embedded ? null : one3(this.deps.sql, `SELECT wake_url, wake_secret FROM machines WHERE name = ?`, machine);
3361
+ if (!embedded && (!waker?.wake_url || !waker.wake_secret)) return;
3362
+ const fails = state?.fails ?? 0;
3363
+ this.machineWakes.set(machine, { next: now + LIMITS3.wakeDebounceS * 1e3, fails });
3364
+ const fromAccount = message.from.account ?? this.account;
3365
+ const event = {
3366
+ scp: 1,
3367
+ event: "machine_wake",
3368
+ account: this.account,
3369
+ machine,
3370
+ endpoints: [view.address],
3371
+ message: { id: message.id, from: message.from.address, kind: message.kind, thread: message.thread, created_at: message.created_at },
3372
+ from_account: fromAccount,
3373
+ same_account: fromAccount === this.account
3374
+ };
3375
+ const done = (ok, detail) => {
3376
+ if (detail.declined)
3377
+ this.machineWakes.set(machine, { next: now, fails, declined: { reason: detail.declined, message: message.id, from: message.from.address } });
3378
+ else {
3379
+ const next = ok ? 0 : fails + 1;
3380
+ const backoff = ok ? LIMITS3.wakeDebounceS : Math.min(LIMITS3.wakeDebounceS * 2 ** next, LIMITS3.wakeBackoffMaxS);
3381
+ this.machineWakes.set(machine, { next: now + backoff * 1e3, fails: next });
3382
+ }
3383
+ try {
3384
+ this.relay.record("machine_wake", null, machine, { machine, ok, ...detail });
3385
+ } catch {
3386
+ }
3387
+ this.deps.log?.("relay_action", {
3388
+ account: this.account,
3389
+ machine,
3390
+ method: "machine_wake",
3391
+ code: ok ? "ok" : detail.declined ? "declined" : "failed",
3392
+ ...detail
3393
+ });
3394
+ };
3395
+ void (async () => {
3396
+ if (embedded) {
3397
+ const result = await embedded(event);
3398
+ const declined2 = result.ok ? void 0 : wakeDeclineOf(result.declined) ?? void 0;
3399
+ return done(result.ok, { ...result.status !== void 0 ? { status: result.status } : {}, ...declined2 ? { declined: declined2 } : {} });
3400
+ }
3401
+ const body = JSON.stringify(event);
3402
+ const timestamp = String(now);
3403
+ const signature = await hmacHex(waker.wake_secret, `${timestamp}.${body}`);
3404
+ const response = await this.deps.fetch(waker.wake_url, {
3405
+ method: "POST",
3406
+ headers: { "content-type": "application/json", "x-scp-timestamp": timestamp, "x-scp-signature": `sha256=${signature}` },
3407
+ body,
3408
+ signal: AbortSignal.timeout(MACHINE_WAKE_TIMEOUT_MS)
3409
+ });
3410
+ const declined = response.status === 403 ? await declineOf(response) : null;
3411
+ done(response.status >= 200 && response.status < 300, { status: response.status, ...declined ? { declined } : {} });
3412
+ })().catch((error) => done(false, { error: error instanceof Error ? error.name : "error" }));
3301
3413
  }
3302
3414
  /** a machine's endpoint changes: `endpoints` to the other bridges, presence and status to watchers */
3303
3415
  announce(machine, changes, full) {
3416
+ const waking = this.machineWakes.get(machine);
3417
+ if (waking?.declined && changes.some((c) => c.presence && c.view.presence === "hibernated")) delete waking.declined;
3304
3418
  if (full || changes.length) {
3305
3419
  const endpoints = full ? this.relay.remoteRowsOf(machine) : changes.map((c) => c.was === void 0 ? c.view : { ...c.view, was: c.was });
3306
3420
  this.toBridges(machine, { machine, endpoints, full });
@@ -3501,13 +3615,16 @@ var RelayHub = class _RelayHub {
3501
3615
  }
3502
3616
  machineList() {
3503
3617
  const sockets = this.deps.sockets().filter((s) => !this.gone.has(s.conn.id));
3504
- const bound = this.deps.sql.all(`SELECT name, kind, created_at, last_seen FROM machines ORDER BY name`).map((row) => ({
3618
+ const bound = this.deps.sql.all(
3619
+ `SELECT name, kind, created_at, last_seen, wake_url FROM machines ORDER BY name`
3620
+ ).map((row) => ({
3505
3621
  name: row.name,
3506
3622
  kind: row.kind,
3507
3623
  online: sockets.some((s) => this.boundMachine(s.conn) === row.name),
3508
3624
  bridge: sockets.some((s) => s.conn.role === "bridge" && s.conn.machine === row.name),
3509
3625
  created_at: iso2(row.created_at),
3510
- last_seen: iso2(row.last_seen)
3626
+ last_seen: iso2(row.last_seen),
3627
+ wake: row.wake_url !== null
3511
3628
  }));
3512
3629
  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: iso2(row.at) }));
3513
3630
  return [...bound, ...revoked];
@@ -3648,6 +3765,32 @@ var RelayHub = class _RelayHub {
3648
3765
  this.route(this.relay.judgeNoticeTo(endpoint, text), endpoint);
3649
3766
  }
3650
3767
  };
3768
+ var MACHINE_WAKE_URL_CHARS = 2048;
3769
+ var MACHINE_WAKE_TIMEOUT_MS = 1e4;
3770
+ function wakeUrl(value) {
3771
+ let url;
3772
+ try {
3773
+ url = new URL(value);
3774
+ } catch {
3775
+ return false;
3776
+ }
3777
+ if (url.username || url.password) return false;
3778
+ if (url.protocol === "https:") return true;
3779
+ return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]");
3780
+ }
3781
+ async function hmacHex(secret, text) {
3782
+ const encoder = new TextEncoder();
3783
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
3784
+ const signature = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(text)));
3785
+ return Array.from(signature, (byte) => byte.toString(16).padStart(2, "0")).join("");
3786
+ }
3787
+ async function declineOf(response) {
3788
+ try {
3789
+ return wakeDeclineOf(JSON.parse(await response.text?.() ?? "").declined);
3790
+ } catch {
3791
+ return null;
3792
+ }
3793
+ }
3651
3794
  export {
3652
3795
  FederatedCore,
3653
3796
  GRAPH_MINUTES,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/hub",
3
- "version": "0.6.2",
3
+ "version": "0.7.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.3.1"
32
+ "@scp-dev/core": "0.5.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "tsup": "^8.5.1"
package/src/federation.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  newId,
22
22
  one,
23
23
  type PolicySubject,
24
+ PRESENCES,
24
25
  type Presence,
25
26
  randomHex,
26
27
  RelayCore,
@@ -57,7 +58,8 @@ export const MACHINE_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/
57
58
  const REMOTE = /^[a-z0-9][a-z0-9._-]{0,63}\/([a-z0-9][a-z0-9._-]{0,62})?$/
58
59
  const MESSAGE_ID = /^[a-z]{2,8}_[a-z0-9]{8,64}$/
59
60
  const AUTHS: readonly RelayAuth[] = ["local-uid", "account", "account+bridge", "contact"]
60
- const PRESENCES: readonly Presence[] = ["online", "idle", "offline"]
61
+ /** a hibernated agent's shown wake, its provider's hook */
62
+ const HOOK_WAKE = /^hook:[a-z0-9][a-z0-9-]{0,31}$/
61
63
 
62
64
  export interface FederatedOptions extends CoreOptions {
63
65
  /** this host's own machine name; addresses naming it stay local */
@@ -510,13 +512,22 @@ export class FederatedCore extends RelayCore {
510
512
  return shown ? null : this.remoteAddress(key)
511
513
  }
512
514
 
513
- /** a live mirrored row shows the presence its machine mirrored, not one its last_seen decays to here */
515
+ /**
516
+ * a live mirrored row shows the presence its machine mirrored, not one its last_seen decays to here; a hibernated one stays hibernated with
517
+ * its hook wake after its bridge closed
518
+ */
514
519
  protected override view(row: EndpointRow, shown?: Set<string>): EndpointView {
515
520
  const view = super.view(row, shown)
516
521
  const { remote, mirrored_presence: mirrored } = row as FederatedRow
522
+ if (remote && mirrored === "hibernated")
523
+ return { ...view, presence: "hibernated", ...(view.kind === "agent" && row.wake && HOOK_WAKE.test(row.wake) ? { wake: row.wake as Wake } : {}) }
517
524
  return remote && row.connections > 0 && (mirrored === "online" || mirrored === "idle") ? { ...view, presence: mirrored } : view
518
525
  }
519
526
 
527
+ protected override hibernated(row: EndpointRow): boolean {
528
+ return super.hibernated(row) || ((row as FederatedRow).remote !== null && (row as FederatedRow).mirrored_presence === "hibernated")
529
+ }
530
+
520
531
  protected override address(row: EndpointRow): string {
521
532
  const remote = (row as FederatedRow).remote
522
533
  if (remote && isForeign(remote)) return remote.slice(1)
@@ -686,7 +697,7 @@ export class FederatedCore extends RelayCore {
686
697
  for (const { view, id, before } of batch) {
687
698
  const row = before ?? (this.byId(id) as FederatedRow)
688
699
  if (before && view.kind === "agent") this.dropEarlierHolder(before, view.scope.incarnation)
689
- const live = view.presence !== "offline"
700
+ const live = view.presence !== "offline" && view.presence !== "hibernated"
690
701
  const next: Partial<FederatedRow> = {
691
702
  kind: view.kind,
692
703
  harness: view.harness,
@@ -1664,7 +1675,7 @@ export class FederatedCore extends RelayCore {
1664
1675
  status = { state: view.status.state, note: cleanText(view.status.note, "note", LIMITS.noteChars) ?? "" }
1665
1676
  }
1666
1677
  const presence = view.presence ?? "offline"
1667
- if (!PRESENCES.includes(presence)) throw new RelayError("invalid_params", "presence is online, idle or offline")
1678
+ if (!PRESENCES.includes(presence)) throw new RelayError("invalid_params", "presence is online, idle, offline or hibernated")
1668
1679
  const parsedSeen = typeof view.last_seen === "string" ? Date.parse(view.last_seen) : Number.NaN
1669
1680
  const out = {
1670
1681
  handle,
@@ -1958,7 +1969,9 @@ function parseScope(view: Partial<MirrorView>): ParsedScope {
1958
1969
  throw new RelayError("too_large", `an endpoint's rules and caps are more than ${SCOPE_BYTES} bytes`)
1959
1970
  if (given("title"))
1960
1971
  out.title = view.title === null ? null : cleanText(typeof view.title === "string" ? view.title.replace(/\s+/g, " ") : view.title, "title", 64) || null
1961
- if (given("wake")) out.wake = typeof view.wake === "string" && view.wake !== "none" && WAKES.includes(view.wake as Wake) ? (view.wake as Wake) : null
1972
+ if (given("wake"))
1973
+ out.wake =
1974
+ typeof view.wake === "string" && view.wake !== "none" && (WAKES.includes(view.wake as Wake) || HOOK_WAKE.test(view.wake)) ? (view.wake as Wake) : null
1962
1975
  if (given("incarnation")) {
1963
1976
  if (view.incarnation !== null && (typeof view.incarnation !== "string" || !INCARNATION.test(view.incarnation))) throw bad("incarnation")
1964
1977
  out.incarnation = view.incarnation ?? null
package/src/hub.ts CHANGED
@@ -39,6 +39,8 @@ import {
39
39
  sameSecret,
40
40
  untilTomorrow,
41
41
  type Wake,
42
+ type WakeDecline,
43
+ wakeDeclineOf,
42
44
  type XContact,
43
45
  XCounters,
44
46
  type XDelivered,
@@ -93,6 +95,8 @@ export interface MachineRow {
93
95
  bridge: boolean
94
96
  created_at: string | null
95
97
  last_seen: string | null
98
+ /** a machine waker URL is set; the URL and its secret are never shown */
99
+ wake?: boolean
96
100
  }
97
101
 
98
102
  /** an endpoint as the dashboard graph draws it */
@@ -158,6 +162,19 @@ export interface HubSocket {
158
162
  close(code: number, reason: string): void
159
163
  }
160
164
 
165
+ /** what a machine waker gets: who to wake and the message's metadata, never its body */
166
+ export interface MachineWakeEvent {
167
+ scp: 1
168
+ event: "machine_wake"
169
+ account: string
170
+ machine: string
171
+ endpoints: string[]
172
+ message: { id: string; from: string; kind: string; thread: string; created_at: string }
173
+ /** the sender's account handle; another account's sender never chooses the credentials a wake uses */
174
+ from_account: string
175
+ same_account: boolean
176
+ }
177
+
161
178
  export interface HubDeps {
162
179
  sql: Sql
163
180
  /** synchronous sha256 hex */
@@ -177,6 +194,13 @@ export interface HubDeps {
177
194
  limits?: Partial<HubLimits>
178
195
  /** the link to other accounts' relays; without it an address of another account is not found */
179
196
  crossAccount?: CrossAccountLink
197
+ /** an HTTP client for machine waker URLs; without it (and without `machineWake`) no machine waker is called */
198
+ fetch?: (
199
+ url: string,
200
+ init: { method: string; headers: Record<string, string>; body: string; signal?: AbortSignal },
201
+ ) => Promise<{ status: number; text?: () => Promise<string> }>
202
+ /** the embedder's own machine waker, called in-process for any machine in place of a registered URL; metadata only, never a body or secret */
203
+ machineWake?: (event: MachineWakeEvent) => Promise<{ ok: boolean; status?: number; declined?: WakeDecline }>
180
204
  }
181
205
 
182
206
  /** what a configured judge needs besides its policy */
@@ -331,6 +355,8 @@ export class RelayHub {
331
355
  private readonly limits: Readonly<HubLimits>
332
356
  private judgeConfig: { policy: JudgePolicy; options: HubJudgeOptions } | null = null
333
357
  private gate: JudgeGate | null = null
358
+ /** per machine, when its waker may be called next and the calls that failed in a row */
359
+ private readonly machineWakes = new Map<string, { next: number; fails: number; declined?: { reason: WakeDecline; message: string; from: string } }>()
334
360
 
335
361
  constructor(private readonly deps: HubDeps) {
336
362
  this.now = deps.now ?? Date.now
@@ -345,6 +371,8 @@ export class RelayHub {
345
371
  const columns = this.deps.sql.all<{ name: string }>(`PRAGMA table_info(machines)`).map((c) => c.name)
346
372
  if (!columns.includes("user_id")) this.deps.sql.all(`ALTER TABLE machines ADD COLUMN user_id TEXT`)
347
373
  if (!columns.includes("actor_id")) this.deps.sql.all(`ALTER TABLE machines ADD COLUMN actor_id TEXT`)
374
+ if (!columns.includes("wake_url")) this.deps.sql.all(`ALTER TABLE machines ADD COLUMN wake_url TEXT`)
375
+ if (!columns.includes("wake_secret")) this.deps.sql.all(`ALTER TABLE machines ADD COLUMN wake_secret TEXT`)
348
376
  const core = new FederatedCore(this.deps.sql, {
349
377
  user: account.user,
350
378
  now: this.now,
@@ -1049,6 +1077,8 @@ export class RelayHub {
1049
1077
  conn.watching = true
1050
1078
  this.save(socket)
1051
1079
  return core.snapshot({ activity: 20 })
1080
+ case "machine_wake":
1081
+ return this.setMachineWake(machine, params.url, params.secret)
1052
1082
  }
1053
1083
  throw new RelayError("method_not_found", `no method ${method} on a bridge`)
1054
1084
  }
@@ -1491,21 +1521,121 @@ export class RelayHub {
1491
1521
  if (this.stored !== null) this.stored += bytes
1492
1522
  }
1493
1523
 
1494
- /** wakes direct waiters and pushes a delivery for a mirrored row to the subscribed bridge of its machine */
1524
+ /**
1525
+ * wakes direct waiters and pushes a delivery for a mirrored row to the subscribed bridge of its machine; with none subscribed, a hibernated
1526
+ * row's machine waker is called
1527
+ */
1495
1528
  private route(seq: number, endpoint: string): void {
1496
1529
  this.wake(endpoint)
1497
1530
  const delivery = this.relay.delivery(seq)
1498
1531
  if (!delivery?.endpoint || !delivery.mirrored) return
1499
1532
  const machine = delivery.endpoint.slice(0, delivery.endpoint.indexOf("/"))
1533
+ let pushed = false
1500
1534
  for (const socket of this.deps.sockets()) {
1501
1535
  if (socket.conn.role === "bridge" && socket.conn.subscribed && socket.conn.machine === machine) {
1502
1536
  this.write(socket, { jsonrpc: "2.0", method: "deliver", params: { seq, endpoint: delivery.endpoint, message: delivery.message } })
1537
+ pushed = true
1538
+ }
1539
+ }
1540
+ if (!pushed && delivery.recipient) this.machineWake(machine, delivery.recipient, delivery.message)
1541
+ }
1542
+
1543
+ /** sets or (with a null url) clears the bridge's own machine waker: an https URL (http only to this host) and the secret that signs its calls */
1544
+ private setMachineWake(machine: string, url: unknown, secret: unknown): { set: boolean } {
1545
+ if (url === null || url === undefined) {
1546
+ this.deps.sql.all(`UPDATE machines SET wake_url = NULL, wake_secret = NULL WHERE name = ?`, machine)
1547
+ this.machineWakes.delete(machine)
1548
+ this.relay.record("machine_wake_set", null, machine, { machine, set: false })
1549
+ return { set: false }
1550
+ }
1551
+ if (typeof url !== "string" || url.length > MACHINE_WAKE_URL_CHARS || !wakeUrl(url))
1552
+ throw new RelayError("invalid_params", `url is an https URL of at most ${MACHINE_WAKE_URL_CHARS} characters (http only to localhost)`)
1553
+ if (typeof secret !== "string" || secret.length < 16 || secret.length > 256)
1554
+ throw new RelayError("invalid_params", "secret is 16 to 256 characters, and a url needs one")
1555
+ this.deps.sql.all(`UPDATE machines SET wake_url = ?, wake_secret = ? WHERE name = ?`, url, secret, machine)
1556
+ this.machineWakes.delete(machine)
1557
+ this.relay.record("machine_wake_set", null, machine, { machine, set: true })
1558
+ return { set: true }
1559
+ }
1560
+
1561
+ /**
1562
+ * calls `machine`'s waker for its hibernated `recipient`: the embedder's `machineWake`, else the registered URL, signed. At most once per
1563
+ * debounce window (doubling after failures); a decline counts toward nothing and holds calls for that message and its sender. Metadata only
1564
+ */
1565
+ private machineWake(machine: string, recipient: string, message: Envelope): void {
1566
+ const embedded = this.deps.machineWake
1567
+ if (!embedded && !this.deps.fetch) return
1568
+ const now = this.now()
1569
+ const state = this.machineWakes.get(machine)
1570
+ if (state && state.next > now) return
1571
+ if (state?.declined && (state.declined.message === message.id || state.declined.from === message.from.address)) return
1572
+ let view: EndpointView
1573
+ try {
1574
+ view = this.relay.whoami(recipient)
1575
+ } catch {
1576
+ return
1577
+ }
1578
+ if (view.presence !== "hibernated") return
1579
+ const waker = embedded
1580
+ ? null
1581
+ : one<{ wake_url: string | null; wake_secret: string | null }>(this.deps.sql, `SELECT wake_url, wake_secret FROM machines WHERE name = ?`, machine)
1582
+ if (!embedded && (!waker?.wake_url || !waker.wake_secret)) return
1583
+ const fails = state?.fails ?? 0
1584
+ this.machineWakes.set(machine, { next: now + LIMITS.wakeDebounceS * 1000, fails })
1585
+ const fromAccount = message.from.account ?? this.account
1586
+ const event: MachineWakeEvent = {
1587
+ scp: 1,
1588
+ event: "machine_wake",
1589
+ account: this.account,
1590
+ machine,
1591
+ endpoints: [view.address],
1592
+ message: { id: message.id, from: message.from.address, kind: message.kind, thread: message.thread, created_at: message.created_at },
1593
+ from_account: fromAccount,
1594
+ same_account: fromAccount === this.account,
1595
+ }
1596
+ const done = (ok: boolean, detail: { status?: number; error?: string; declined?: WakeDecline }) => {
1597
+ if (detail.declined)
1598
+ this.machineWakes.set(machine, { next: now, fails, declined: { reason: detail.declined, message: message.id, from: message.from.address } })
1599
+ else {
1600
+ const next = ok ? 0 : fails + 1
1601
+ const backoff = ok ? LIMITS.wakeDebounceS : Math.min(LIMITS.wakeDebounceS * 2 ** next, LIMITS.wakeBackoffMaxS)
1602
+ this.machineWakes.set(machine, { next: now + backoff * 1000, fails: next })
1503
1603
  }
1604
+ try {
1605
+ this.relay.record("machine_wake", null, machine, { machine, ok, ...detail })
1606
+ } catch {}
1607
+ this.deps.log?.("relay_action", {
1608
+ account: this.account,
1609
+ machine,
1610
+ method: "machine_wake",
1611
+ code: ok ? "ok" : detail.declined ? "declined" : "failed",
1612
+ ...detail,
1613
+ })
1504
1614
  }
1615
+ void (async () => {
1616
+ if (embedded) {
1617
+ const result = await embedded(event)
1618
+ const declined = result.ok ? undefined : (wakeDeclineOf(result.declined) ?? undefined)
1619
+ return done(result.ok, { ...(result.status !== undefined ? { status: result.status } : {}), ...(declined ? { declined } : {}) })
1620
+ }
1621
+ const body = JSON.stringify(event)
1622
+ const timestamp = String(now)
1623
+ const signature = await hmacHex(waker!.wake_secret!, `${timestamp}.${body}`)
1624
+ const response = await this.deps.fetch!(waker!.wake_url!, {
1625
+ method: "POST",
1626
+ headers: { "content-type": "application/json", "x-scp-timestamp": timestamp, "x-scp-signature": `sha256=${signature}` },
1627
+ body,
1628
+ signal: AbortSignal.timeout(MACHINE_WAKE_TIMEOUT_MS),
1629
+ })
1630
+ const declined = response.status === 403 ? await declineOf(response) : null
1631
+ done(response.status >= 200 && response.status < 300, { status: response.status, ...(declined ? { declined } : {}) })
1632
+ })().catch((error: unknown) => done(false, { error: error instanceof Error ? error.name : "error" }))
1505
1633
  }
1506
1634
 
1507
1635
  /** a machine's endpoint changes: `endpoints` to the other bridges, presence and status to watchers */
1508
1636
  private announce(machine: string, changes: MirrorChange[], full: boolean): void {
1637
+ const waking = this.machineWakes.get(machine)
1638
+ if (waking?.declined && changes.some((c) => c.presence && c.view.presence === "hibernated")) delete waking.declined
1509
1639
  if (full || changes.length) {
1510
1640
  const endpoints = full ? this.relay.remoteRowsOf(machine) : changes.map((c) => (c.was === undefined ? c.view : { ...c.view, was: c.was }))
1511
1641
  this.toBridges(machine, { machine, endpoints, full })
@@ -1729,7 +1859,9 @@ export class RelayHub {
1729
1859
  private machineList(): MachineRow[] {
1730
1860
  const sockets = this.deps.sockets().filter((s) => !this.gone.has(s.conn.id))
1731
1861
  const bound = this.deps.sql
1732
- .all<{ name: string; kind: string; created_at: number; last_seen: number | null }>(`SELECT name, kind, created_at, last_seen FROM machines ORDER BY name`)
1862
+ .all<{ name: string; kind: string; created_at: number; last_seen: number | null; wake_url: string | null }>(
1863
+ `SELECT name, kind, created_at, last_seen, wake_url FROM machines ORDER BY name`,
1864
+ )
1733
1865
  .map((row) => ({
1734
1866
  name: row.name,
1735
1867
  kind: row.kind,
@@ -1737,6 +1869,7 @@ export class RelayHub {
1737
1869
  bridge: sockets.some((s) => s.conn.role === "bridge" && s.conn.machine === row.name),
1738
1870
  created_at: iso(row.created_at),
1739
1871
  last_seen: iso(row.last_seen),
1872
+ wake: row.wake_url !== null,
1740
1873
  }))
1741
1874
  const revoked = this.deps.sql
1742
1875
  .all<{ name: string; at: number }>(`SELECT name, at FROM machine_tombstones WHERE name NOT IN (SELECT name FROM machines) ORDER BY name`)
@@ -1889,3 +2022,36 @@ export class RelayHub {
1889
2022
  this.route(this.relay.judgeNoticeTo(endpoint, text), endpoint)
1890
2023
  }
1891
2024
  }
2025
+
2026
+ const MACHINE_WAKE_URL_CHARS = 2048
2027
+ const MACHINE_WAKE_TIMEOUT_MS = 10_000
2028
+
2029
+ /** an https URL, or http to this host, with no credentials in it */
2030
+ function wakeUrl(value: string): boolean {
2031
+ let url: URL
2032
+ try {
2033
+ url = new URL(value)
2034
+ } catch {
2035
+ return false
2036
+ }
2037
+ if (url.username || url.password) return false
2038
+ if (url.protocol === "https:") return true
2039
+ return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]")
2040
+ }
2041
+
2042
+ /** HMAC-SHA256 of `text` under `secret`, hex, with WebCrypto (Bun and Workers both have it) */
2043
+ async function hmacHex(secret: string, text: string): Promise<string> {
2044
+ const encoder = new TextEncoder()
2045
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"])
2046
+ const signature = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(text)))
2047
+ return Array.from(signature, (byte) => byte.toString(16).padStart(2, "0")).join("")
2048
+ }
2049
+
2050
+ /** a 403 answer's `{"declined": "<reason>"}`, or null */
2051
+ async function declineOf(response: { text?: () => Promise<string> }): Promise<WakeDecline | null> {
2052
+ try {
2053
+ return wakeDeclineOf((JSON.parse((await response.text?.()) ?? "") as { declined?: unknown }).declined)
2054
+ } catch {
2055
+ return null
2056
+ }
2057
+ }
package/src/index.ts CHANGED
@@ -34,6 +34,7 @@ export {
34
34
  type HubRole,
35
35
  type HubSocket,
36
36
  type MachineRow,
37
+ type MachineWakeEvent,
37
38
  } from "./hub"
38
39
  export {
39
40
  type Deliver,