@scp-dev/hub 0.2.0 → 0.3.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 +177 -3
- package/dist/index.js +668 -119
- package/package.json +2 -2
- package/src/federation.ts +24 -1
- package/src/hub.ts +102 -12
- package/src/index.ts +11 -0
- package/src/judge-gate.ts +575 -0
package/dist/index.js
CHANGED
|
@@ -22,7 +22,8 @@ import {
|
|
|
22
22
|
unreachable,
|
|
23
23
|
validHandle,
|
|
24
24
|
validHarness,
|
|
25
|
-
validSelector
|
|
25
|
+
validSelector,
|
|
26
|
+
WAKES
|
|
26
27
|
} from "@scp-dev/core";
|
|
27
28
|
var MACHINE_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
28
29
|
var REMOTE = /^[a-z0-9][a-z0-9._-]{0,63}\/([a-z0-9][a-z0-9._-]{0,62})?$/;
|
|
@@ -770,6 +771,21 @@ var FederatedCore = class extends RelayCore {
|
|
|
770
771
|
this.audit("forward", from, result.id, { to: result.deliveries.map((d) => d.address), kind, hop: result.hop, thread: result.thread });
|
|
771
772
|
});
|
|
772
773
|
}
|
|
774
|
+
/**
|
|
775
|
+
* records that a forwarded message reached mirrored endpoints of other machines, unacknowledged, as a hub's delivery rows would: their
|
|
776
|
+
* replies then join its thread here. For links that deliver machine to machine; a hub bridge never needs it
|
|
777
|
+
*/
|
|
778
|
+
recordRemoteDelivery(messageId, endpoints) {
|
|
779
|
+
this.tx(() => {
|
|
780
|
+
const message = one(this.sql, `SELECT expires_at FROM messages WHERE id = ?`, messageId);
|
|
781
|
+
if (!message) return;
|
|
782
|
+
for (const endpoint of endpoints) {
|
|
783
|
+
if (this.machineOf(endpoint) === null) continue;
|
|
784
|
+
if (one(this.sql, `SELECT 1 FROM deliveries WHERE message_id = ? AND endpoint = ?`, messageId, endpoint)) continue;
|
|
785
|
+
this.insertDelivery(messageId, endpoint, message.expires_at);
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
}
|
|
773
789
|
/** the earlier result of a forwarded send with this client_id, or null; a different message under the same client_id is refused */
|
|
774
790
|
forwarded(from, params) {
|
|
775
791
|
const clientId = cleanText(params.client_id, "client_id", 128);
|
|
@@ -1268,7 +1284,7 @@ var FederatedCore = class extends RelayCore {
|
|
|
1268
1284
|
const was = this.view(before);
|
|
1269
1285
|
const presence = was.presence !== view.presence;
|
|
1270
1286
|
const status = before.status_state !== after.status_state || before.status_note !== after.status_note;
|
|
1271
|
-
const other = ["kind", "harness", "model", "cwd", "labels", "title", "tree", "parent_ep", "depth", "rules", "caps", "incarnation"].some(
|
|
1287
|
+
const other = ["kind", "harness", "model", "cwd", "labels", "title", "wake", "tree", "parent_ep", "depth", "rules", "caps", "incarnation"].some(
|
|
1272
1288
|
(field) => before[field] !== after[field]
|
|
1273
1289
|
);
|
|
1274
1290
|
return presence || status || other ? { view, added: false, presence, status } : null;
|
|
@@ -1336,6 +1352,7 @@ var FederatedCore = class extends RelayCore {
|
|
|
1336
1352
|
rules: rules === void 0 ? row.rules : rules && (rules.in || rules.out) ? JSON.stringify(rules) : null,
|
|
1337
1353
|
caps,
|
|
1338
1354
|
title: scope.title !== void 0 ? scope.title : fresh ? null : row.title,
|
|
1355
|
+
wake: scope.wake !== void 0 ? scope.wake : fresh ? null : row.wake,
|
|
1339
1356
|
incarnation: scope.incarnation ?? row.incarnation
|
|
1340
1357
|
};
|
|
1341
1358
|
}
|
|
@@ -1489,6 +1506,7 @@ function parseScope(view) {
|
|
|
1489
1506
|
throw new RelayError("too_large", `an endpoint's rules and caps are more than ${SCOPE_BYTES} bytes`);
|
|
1490
1507
|
if (given("title"))
|
|
1491
1508
|
out.title = view.title === null ? null : cleanText(typeof view.title === "string" ? view.title.replace(/\s+/g, " ") : view.title, "title", 64) || null;
|
|
1509
|
+
if (given("wake")) out.wake = typeof view.wake === "string" && view.wake !== "none" && WAKES.includes(view.wake) ? view.wake : null;
|
|
1492
1510
|
if (given("incarnation")) {
|
|
1493
1511
|
if (view.incarnation !== null && (typeof view.incarnation !== "string" || !INCARNATION.test(view.incarnation))) throw bad("incarnation");
|
|
1494
1512
|
out.incarnation = view.incarnation ?? null;
|
|
@@ -1504,20 +1522,20 @@ import {
|
|
|
1504
1522
|
cleanText as cleanText2,
|
|
1505
1523
|
dayStart,
|
|
1506
1524
|
declaredLimits,
|
|
1507
|
-
findSecret as
|
|
1525
|
+
findSecret as findSecret3,
|
|
1508
1526
|
gateRefusal,
|
|
1509
1527
|
HUMAN_HANDLE as HUMAN_HANDLE2,
|
|
1510
1528
|
honestSender,
|
|
1511
1529
|
joinRedacted,
|
|
1512
1530
|
judgeDetailForAgents,
|
|
1513
|
-
LIMITS as
|
|
1531
|
+
LIMITS as LIMITS3,
|
|
1514
1532
|
MESSAGE_KINDS as MESSAGE_KINDS2,
|
|
1515
|
-
newId as
|
|
1516
|
-
one as
|
|
1533
|
+
newId as newId3,
|
|
1534
|
+
one as one3,
|
|
1517
1535
|
PREAMBLE,
|
|
1518
1536
|
PROTOCOL,
|
|
1519
1537
|
parseXMessage,
|
|
1520
|
-
RelayError as
|
|
1538
|
+
RelayError as RelayError3,
|
|
1521
1539
|
randomHex as randomHex2,
|
|
1522
1540
|
replyGrant,
|
|
1523
1541
|
SERVER_FIELDS,
|
|
@@ -1528,6 +1546,470 @@ import {
|
|
|
1528
1546
|
XLIMITS,
|
|
1529
1547
|
xlimits
|
|
1530
1548
|
} from "@scp-dev/core";
|
|
1549
|
+
|
|
1550
|
+
// src/judge-gate.ts
|
|
1551
|
+
import {
|
|
1552
|
+
applyRedactions,
|
|
1553
|
+
DEFAULT_JUDGE_BUDGET,
|
|
1554
|
+
findSecret as findSecret2,
|
|
1555
|
+
HttpJudgeClient,
|
|
1556
|
+
JUDGE_PROMPT_VERSION,
|
|
1557
|
+
judgeBoundary,
|
|
1558
|
+
LIMITS as LIMITS2,
|
|
1559
|
+
mergeVerdicts,
|
|
1560
|
+
newId as newId2,
|
|
1561
|
+
newJudgeStats,
|
|
1562
|
+
one as one2,
|
|
1563
|
+
RelayError as RelayError2,
|
|
1564
|
+
runJudge,
|
|
1565
|
+
sha256Hex,
|
|
1566
|
+
VerdictCache
|
|
1567
|
+
} from "@scp-dev/core";
|
|
1568
|
+
var JUDGE_LIMITS = {
|
|
1569
|
+
endpointPerMinute: LIMITS2.pairPerMinute,
|
|
1570
|
+
endpointPerHour: LIMITS2.pairPerHour,
|
|
1571
|
+
treePerMinute: LIMITS2.pairPerMinute * 2,
|
|
1572
|
+
treePerHour: LIMITS2.pairPerHour * 2,
|
|
1573
|
+
heldPerSender: 100
|
|
1574
|
+
};
|
|
1575
|
+
var iso = (ms) => ms === null ? null : new Date(ms).toISOString();
|
|
1576
|
+
var JudgeGate = class {
|
|
1577
|
+
constructor(o) {
|
|
1578
|
+
this.o = o;
|
|
1579
|
+
this.limits = { ...JUDGE_LIMITS, ...o.limits };
|
|
1580
|
+
o.sql.all(
|
|
1581
|
+
`CREATE TABLE IF NOT EXISTS judge_holds (id TEXT PRIMARY KEY, from_ep TEXT NOT NULL, params TEXT NOT NULL, origin TEXT NOT NULL, verdict TEXT NOT NULL,
|
|
1582
|
+
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, state TEXT NOT NULL, by TEXT, resolved_at INTEGER)`
|
|
1583
|
+
);
|
|
1584
|
+
o.sql.all(
|
|
1585
|
+
`CREATE TABLE IF NOT EXISTS judge_sent (from_ep TEXT NOT NULL, client_id TEXT NOT NULL, fingerprint TEXT NOT NULL, result TEXT NOT NULL, created_at INTEGER NOT NULL,
|
|
1586
|
+
PRIMARY KEY (from_ep, client_id))`
|
|
1587
|
+
);
|
|
1588
|
+
o.sql.all(`UPDATE judge_holds SET state = 'held' WHERE state = 'releasing'`);
|
|
1589
|
+
}
|
|
1590
|
+
o;
|
|
1591
|
+
stats = newJudgeStats();
|
|
1592
|
+
policy;
|
|
1593
|
+
version = "";
|
|
1594
|
+
client = null;
|
|
1595
|
+
cache = new VerdictCache();
|
|
1596
|
+
shadows = /* @__PURE__ */ new Set();
|
|
1597
|
+
/** judged sends in the last hour per budget: `ep:<id>`, `tree:<root id>` and `*` for the whole broker */
|
|
1598
|
+
spent = /* @__PURE__ */ new Map();
|
|
1599
|
+
limits;
|
|
1600
|
+
/** the policy's judge section; a model client only when its key is readable, else the prefilter alone */
|
|
1601
|
+
configure(policy) {
|
|
1602
|
+
this.policy = policy;
|
|
1603
|
+
this.version = policy ? sha256Hex(JSON.stringify(policy)) : "";
|
|
1604
|
+
this.client = null;
|
|
1605
|
+
const model = policy?.model;
|
|
1606
|
+
if (!policy || policy.mode === "off" || !model) return;
|
|
1607
|
+
const key = this.keyReader(model);
|
|
1608
|
+
if (!key) {
|
|
1609
|
+
this.o.log(
|
|
1610
|
+
`relay broker: the judge model's key (${model.key_env ? `key_env ${model.key_env}` : `key_file ${model.key_file}`}) is not readable; judging with the prefilter only`
|
|
1611
|
+
);
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
this.client = (this.o.client ?? ((m, k) => new HttpJudgeClient({ model: m.id, effort: m.effort, key: k })))(model, key);
|
|
1615
|
+
}
|
|
1616
|
+
keyReader(model) {
|
|
1617
|
+
if (model.key_env) {
|
|
1618
|
+
const name = model.key_env;
|
|
1619
|
+
return this.o.env[name] ? async () => this.o.env[name] ?? "" : null;
|
|
1620
|
+
}
|
|
1621
|
+
return this.o.keyFile?.(model.key_file) ?? null;
|
|
1622
|
+
}
|
|
1623
|
+
/** resolves once every shadow judgement started so far finished */
|
|
1624
|
+
async settled() {
|
|
1625
|
+
await Promise.all([...this.shadows]);
|
|
1626
|
+
}
|
|
1627
|
+
/** a send or ask through the judge; an unjudged one goes straight to `deliver` */
|
|
1628
|
+
async send(from, params, origin, deliver) {
|
|
1629
|
+
const policy = this.policy;
|
|
1630
|
+
const body = params.body;
|
|
1631
|
+
if (!policy || policy.mode === "off" || typeof body !== "string" || new TextEncoder().encode(body).length > LIMITS2.bodyBytes || findSecret2(body))
|
|
1632
|
+
return deliver(from, params, origin);
|
|
1633
|
+
const targets = Array.isArray(params.to) ? params.to : [params.to];
|
|
1634
|
+
if (targets.some((to) => this.o.core.foreignAccount(to) !== null)) {
|
|
1635
|
+
if (policy.mode === "enforce") {
|
|
1636
|
+
this.o.core.auditJudge("judge_skipped", from, null, {
|
|
1637
|
+
side: "local",
|
|
1638
|
+
boundary: "other account",
|
|
1639
|
+
prompt_version: JUDGE_PROMPT_VERSION,
|
|
1640
|
+
categories: [],
|
|
1641
|
+
sha256: sha256Hex(body),
|
|
1642
|
+
verdict: "skipped",
|
|
1643
|
+
reason: "other_account_judged_by_hub",
|
|
1644
|
+
to: targets.map(String)
|
|
1645
|
+
});
|
|
1646
|
+
}
|
|
1647
|
+
return deliver(from, params, origin);
|
|
1648
|
+
}
|
|
1649
|
+
if (policy.mode === "shadow") {
|
|
1650
|
+
const result = await deliver(from, params, origin);
|
|
1651
|
+
if (!result.duplicate) this.shadow(policy, from, params, origin, result.id);
|
|
1652
|
+
return result;
|
|
1653
|
+
}
|
|
1654
|
+
const { groups, tree } = this.boundaries(policy, from, params, origin);
|
|
1655
|
+
if (!groups.size) return deliver(from, params, origin);
|
|
1656
|
+
const ttl = params.ttl_s ?? LIMITS2.ttlDefaultS;
|
|
1657
|
+
if (!Number.isInteger(ttl) || ttl < 1 || ttl > LIMITS2.ttlMaxS) throw new RelayError2("invalid_params", `ttl_s must be 1 to ${LIMITS2.ttlMaxS}`);
|
|
1658
|
+
const seen = params.client_id === void 0 ? void 0 : this.seen(from, params);
|
|
1659
|
+
if (seen) return seen;
|
|
1660
|
+
const waiting = one2(this.o.sql, `SELECT COUNT(*) AS n FROM judge_holds WHERE from_ep = ? AND state IN ('held', 'releasing')`, from).n;
|
|
1661
|
+
if (waiting >= this.limits.heldPerSender)
|
|
1662
|
+
throw new RelayError2("rate_limited", `${waiting} of your messages wait for review by your human; send more once they released or refused some`, 3600);
|
|
1663
|
+
this.spend(policy, from, tree);
|
|
1664
|
+
const verdict = await this.judge(policy, from, body, groups);
|
|
1665
|
+
const detail = this.detail(verdict, body, groups);
|
|
1666
|
+
switch (verdict.verdict) {
|
|
1667
|
+
case "refuse":
|
|
1668
|
+
this.o.core.auditJudge("judge_refuse", from, null, detail);
|
|
1669
|
+
throw new RelayError2(
|
|
1670
|
+
"judge_refused",
|
|
1671
|
+
`the relay judge refused this message (local; ${verdict.categories.join(", ") || "no category"}): ${verdict.reason}`,
|
|
1672
|
+
void 0,
|
|
1673
|
+
{
|
|
1674
|
+
side: "local",
|
|
1675
|
+
categories: verdict.categories,
|
|
1676
|
+
reason: verdict.reason
|
|
1677
|
+
}
|
|
1678
|
+
);
|
|
1679
|
+
case "hold":
|
|
1680
|
+
return this.hold(from, params, origin, verdict, groups, detail);
|
|
1681
|
+
case "redact": {
|
|
1682
|
+
const applied = applyRedactions(body, verdict.redactions);
|
|
1683
|
+
const result = await deliver(from, { ...params, body: applied.text }, origin);
|
|
1684
|
+
const redacted = { n: applied.n, kinds: applied.kinds };
|
|
1685
|
+
this.o.core.auditJudge(verdict.source === "error" ? "judge_skipped" : "judge_redact", from, result.id, { ...detail, redacted });
|
|
1686
|
+
return this.remember(from, params, { ...result, redacted });
|
|
1687
|
+
}
|
|
1688
|
+
case "pass": {
|
|
1689
|
+
const result = await deliver(from, params, origin);
|
|
1690
|
+
if (verdict.source === "error") this.o.core.auditJudge("judge_skipped", from, result.id, detail);
|
|
1691
|
+
return this.remember(from, params, result);
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
/** an earlier judged send or waiting hold under the send's client_id: the same message is answered as before, another one refused as core refuses it */
|
|
1696
|
+
seen(from, params) {
|
|
1697
|
+
const clientId = params.client_id;
|
|
1698
|
+
const held = one2(
|
|
1699
|
+
this.o.sql,
|
|
1700
|
+
`SELECT id, params FROM judge_holds WHERE from_ep = ? AND json_extract(params, '$.client_id') = ? AND state IN ('held', 'releasing')`,
|
|
1701
|
+
from,
|
|
1702
|
+
clientId
|
|
1703
|
+
);
|
|
1704
|
+
const sent = held ? void 0 : one2(
|
|
1705
|
+
this.o.sql,
|
|
1706
|
+
`SELECT fingerprint, result FROM judge_sent WHERE from_ep = ? AND client_id = ? AND created_at > ?`,
|
|
1707
|
+
from,
|
|
1708
|
+
clientId,
|
|
1709
|
+
this.o.now() - LIMITS2.idempotencyS * 1e3
|
|
1710
|
+
);
|
|
1711
|
+
const before = held ? fingerprint(JSON.parse(held.params)) : sent?.fingerprint;
|
|
1712
|
+
if (before === void 0) return void 0;
|
|
1713
|
+
if (before !== fingerprint(params)) throw new RelayError2("invalid_params", `client_id ${clientId} was already used for a different message`);
|
|
1714
|
+
return held ? heldResult(held.id, params) : { ...JSON.parse(sent.result), duplicate: true };
|
|
1715
|
+
}
|
|
1716
|
+
remember(from, params, result) {
|
|
1717
|
+
if (params.client_id !== void 0 && !result.duplicate) {
|
|
1718
|
+
this.o.sql.all(
|
|
1719
|
+
`INSERT OR REPLACE INTO judge_sent (from_ep, client_id, fingerprint, result, created_at) VALUES (?, ?, ?, ?, ?)`,
|
|
1720
|
+
from,
|
|
1721
|
+
params.client_id,
|
|
1722
|
+
fingerprint(params),
|
|
1723
|
+
JSON.stringify(result),
|
|
1724
|
+
this.o.now()
|
|
1725
|
+
);
|
|
1726
|
+
}
|
|
1727
|
+
return result;
|
|
1728
|
+
}
|
|
1729
|
+
/**
|
|
1730
|
+
* a judged send counts before any model call against its sender's, its lineage tree's and the whole broker's budget, since refused and
|
|
1731
|
+
* held sends never reach the send's own limits, and a new handle or a helper process must not bring a fresh budget
|
|
1732
|
+
*/
|
|
1733
|
+
spend(policy, from, tree) {
|
|
1734
|
+
const now = this.o.now();
|
|
1735
|
+
const global = policy.budget ?? DEFAULT_JUDGE_BUDGET;
|
|
1736
|
+
const budgets = [
|
|
1737
|
+
[`ep:${from}`, this.limits.endpointPerMinute, this.limits.endpointPerHour, "from this endpoint"],
|
|
1738
|
+
[`tree:${tree}`, this.limits.treePerMinute, this.limits.treePerHour, "from this lineage tree"],
|
|
1739
|
+
["*", global.per_minute, global.per_hour, "on this broker"]
|
|
1740
|
+
];
|
|
1741
|
+
const recent = budgets.map(([key]) => (this.spent.get(key) ?? []).filter((at) => at > now - 36e5));
|
|
1742
|
+
budgets.forEach(([key, perMinute, perHour, what], i) => {
|
|
1743
|
+
for (const [limit, windowS] of [
|
|
1744
|
+
[perMinute, 60],
|
|
1745
|
+
[perHour, 3600]
|
|
1746
|
+
]) {
|
|
1747
|
+
const inWindow = recent[i].filter((at) => at > now - windowS * 1e3);
|
|
1748
|
+
if (inWindow.length >= limit) {
|
|
1749
|
+
this.spent.set(key, recent[i]);
|
|
1750
|
+
throw new RelayError2(
|
|
1751
|
+
"rate_limited",
|
|
1752
|
+
`${limit} judged messages ${what} in ${windowS} s`,
|
|
1753
|
+
Math.max(1, Math.ceil((inWindow[0] + windowS * 1e3 - now) / 1e3))
|
|
1754
|
+
);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
});
|
|
1758
|
+
for (const [i, [key]] of budgets.entries()) this.spent.set(key, [...recent[i], now]);
|
|
1759
|
+
}
|
|
1760
|
+
/** how many budgets are tracked (idle ones are evicted by the sweep) */
|
|
1761
|
+
budgetKeys() {
|
|
1762
|
+
return this.spent.size;
|
|
1763
|
+
}
|
|
1764
|
+
/** the held messages, newest first, without bodies */
|
|
1765
|
+
holds(state) {
|
|
1766
|
+
const rows = state ? this.o.sql.all(`SELECT * FROM judge_holds WHERE state = ? ORDER BY created_at DESC, rowid DESC LIMIT 200`, state) : this.o.sql.all(`SELECT * FROM judge_holds ORDER BY created_at DESC, rowid DESC LIMIT 200`);
|
|
1767
|
+
return rows.map((row) => this.view(row));
|
|
1768
|
+
}
|
|
1769
|
+
/** one hold with its body and the judge's redactions while it waits */
|
|
1770
|
+
show(id) {
|
|
1771
|
+
const row = this.row(id);
|
|
1772
|
+
const params = JSON.parse(row.params);
|
|
1773
|
+
const verdict = JSON.parse(row.verdict);
|
|
1774
|
+
return { ...this.view(row), ...typeof params.body === "string" ? { body: params.body } : {}, redactions: verdict.redactions };
|
|
1775
|
+
}
|
|
1776
|
+
/** delivers a held message as its sender, as stored or redacted: scope, policy and limits are checked again by the send itself */
|
|
1777
|
+
async release(id, options, deliver) {
|
|
1778
|
+
const row = one2(
|
|
1779
|
+
this.o.sql,
|
|
1780
|
+
`UPDATE judge_holds SET state = 'releasing' WHERE id = ? AND state = 'held' AND expires_at > ? RETURNING *`,
|
|
1781
|
+
id,
|
|
1782
|
+
this.o.now()
|
|
1783
|
+
);
|
|
1784
|
+
if (!row) throw new RelayError2("not_found", `no held message ${id}`);
|
|
1785
|
+
const params = JSON.parse(row.params);
|
|
1786
|
+
const verdict = JSON.parse(row.verdict);
|
|
1787
|
+
let sent = params;
|
|
1788
|
+
let redacted;
|
|
1789
|
+
if (options.redacted) {
|
|
1790
|
+
const applied = applyRedactions(params.body, verdict.redactions);
|
|
1791
|
+
if (!applied.n) {
|
|
1792
|
+
this.o.sql.all(`UPDATE judge_holds SET state = 'held' WHERE id = ?`, id);
|
|
1793
|
+
throw new RelayError2("invalid_params", `hold ${id} has no redactions: release it as it is or refuse it`);
|
|
1794
|
+
}
|
|
1795
|
+
sent = { ...params, body: applied.text };
|
|
1796
|
+
redacted = { n: applied.n, kinds: applied.kinds };
|
|
1797
|
+
}
|
|
1798
|
+
let result;
|
|
1799
|
+
try {
|
|
1800
|
+
result = await deliver(row.from_ep, sent, JSON.parse(row.origin));
|
|
1801
|
+
} catch (error) {
|
|
1802
|
+
this.o.sql.all(`UPDATE judge_holds SET state = 'held' WHERE id = ?`, id);
|
|
1803
|
+
throw error;
|
|
1804
|
+
}
|
|
1805
|
+
this.remember(row.from_ep, params, { ...result, ...redacted ? { redacted } : {} });
|
|
1806
|
+
this.resolve(row, "released", options.by);
|
|
1807
|
+
this.o.core.auditJudge("judge_override", row.from_ep, id, {
|
|
1808
|
+
...this.storedDetail(row, verdict),
|
|
1809
|
+
by: options.by,
|
|
1810
|
+
action: redacted ? "release_redacted" : "release",
|
|
1811
|
+
...redacted ? { redacted } : {}
|
|
1812
|
+
});
|
|
1813
|
+
this.tell(
|
|
1814
|
+
row.from_ep,
|
|
1815
|
+
`relay: your message held for review as ${id} was released by your human${redacted ? " with redactions" : ""} and delivered as ${result.id}`
|
|
1816
|
+
);
|
|
1817
|
+
return { ...result, ...redacted ? { redacted } : {} };
|
|
1818
|
+
}
|
|
1819
|
+
/** refuses a held message: it is never delivered, and its sender hears the human's reason */
|
|
1820
|
+
refuse(id, reason, by) {
|
|
1821
|
+
this.expire(this.o.now());
|
|
1822
|
+
const row = one2(this.o.sql, `SELECT * FROM judge_holds WHERE id = ? AND state = 'held'`, id);
|
|
1823
|
+
if (!row) throw new RelayError2("not_found", `no held message ${id}`);
|
|
1824
|
+
const verdict = JSON.parse(row.verdict);
|
|
1825
|
+
this.resolve(row, "refused", by);
|
|
1826
|
+
this.o.core.auditJudge("judge_override", row.from_ep, id, { ...this.storedDetail(row, verdict), by, action: "refuse", reason });
|
|
1827
|
+
this.tell(row.from_ep, `relay: your message held for review as ${id} was refused by your human: ${reason}`);
|
|
1828
|
+
return this.view(this.row(id));
|
|
1829
|
+
}
|
|
1830
|
+
/** expires holds past their message TTL (body purged, sender told) and drops resolved ones past the audit window */
|
|
1831
|
+
sweep() {
|
|
1832
|
+
const now = this.o.now();
|
|
1833
|
+
this.expire(now);
|
|
1834
|
+
this.o.sql.all(`DELETE FROM judge_holds WHERE state IN ('released', 'refused', 'expired') AND resolved_at <= ?`, now - LIMITS2.auditKeepS * 1e3);
|
|
1835
|
+
this.o.sql.all(`DELETE FROM judge_sent WHERE created_at <= ?`, now - LIMITS2.idempotencyS * 1e3);
|
|
1836
|
+
for (const [key, times] of this.spent) if (!times.length || times[times.length - 1] <= now - 36e5) this.spent.delete(key);
|
|
1837
|
+
}
|
|
1838
|
+
expire(now) {
|
|
1839
|
+
for (const row of this.o.sql.all(`SELECT * FROM judge_holds WHERE state = 'held' AND expires_at <= ?`, now)) {
|
|
1840
|
+
const verdict = JSON.parse(row.verdict);
|
|
1841
|
+
this.resolve(row, "expired", null);
|
|
1842
|
+
this.o.core.auditJudge("judge_expire", row.from_ep, row.id, this.storedDetail(row, verdict));
|
|
1843
|
+
this.tell(row.from_ep, `relay: your message held for review as ${row.id} expired without review and was not delivered`);
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
/** the recipients by the boundary their message crosses; throws as send does for a target the sender cannot reach */
|
|
1847
|
+
boundaries(policy, from, params, origin) {
|
|
1848
|
+
const subjects = () => this.o.core.judgeSubjects(from, params, origin);
|
|
1849
|
+
const { sender, recipients, tree } = this.o.within ? this.o.within(origin, subjects) : subjects();
|
|
1850
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1851
|
+
for (const recipient of recipients) {
|
|
1852
|
+
const label = judgeBoundary(policy, sender, recipient);
|
|
1853
|
+
if (label !== null) groups.set(label, [...groups.get(label) ?? [], recipient.address]);
|
|
1854
|
+
}
|
|
1855
|
+
return { groups, tree };
|
|
1856
|
+
}
|
|
1857
|
+
async judge(policy, from, body, groups) {
|
|
1858
|
+
const verdicts = await Promise.all(
|
|
1859
|
+
[...groups].map(
|
|
1860
|
+
([boundary, to]) => runJudge({
|
|
1861
|
+
body,
|
|
1862
|
+
side: "local",
|
|
1863
|
+
boundary,
|
|
1864
|
+
configVersion: this.version,
|
|
1865
|
+
client: this.client,
|
|
1866
|
+
cache: this.cache,
|
|
1867
|
+
timeoutMs: policy.timeout_s * 1e3,
|
|
1868
|
+
failMode: policy.fail,
|
|
1869
|
+
...this.client && policy.model ? { model: policy.model.id } : {},
|
|
1870
|
+
from: this.o.core.addressOf(from),
|
|
1871
|
+
to,
|
|
1872
|
+
now: this.o.now,
|
|
1873
|
+
stats: this.stats
|
|
1874
|
+
})
|
|
1875
|
+
)
|
|
1876
|
+
);
|
|
1877
|
+
return mergeVerdicts(verdicts);
|
|
1878
|
+
}
|
|
1879
|
+
shadow(policy, from, params, origin, ref) {
|
|
1880
|
+
const task = (async () => {
|
|
1881
|
+
try {
|
|
1882
|
+
const { groups, tree } = this.boundaries(policy, from, params, origin);
|
|
1883
|
+
if (!groups.size) return;
|
|
1884
|
+
this.spend(policy, from, tree);
|
|
1885
|
+
const body = params.body;
|
|
1886
|
+
const verdict = await this.judge(policy, from, body, groups);
|
|
1887
|
+
this.o.core.auditJudge("judge_shadow", from, ref, this.detail(verdict, body, groups));
|
|
1888
|
+
} catch (error) {
|
|
1889
|
+
this.o.log(`relay broker: a shadow judgement failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1890
|
+
}
|
|
1891
|
+
})();
|
|
1892
|
+
this.shadows.add(task);
|
|
1893
|
+
void task.finally(() => this.shadows.delete(task));
|
|
1894
|
+
}
|
|
1895
|
+
hold(from, params, origin, verdict, groups, detail) {
|
|
1896
|
+
const now = this.o.now();
|
|
1897
|
+
const id = newId2("hold", now);
|
|
1898
|
+
const stored2 = { ...params, client_id: params.client_id ?? id };
|
|
1899
|
+
const to = [...groups.values()].flat();
|
|
1900
|
+
const record = { ...verdict, boundary: [...groups.keys()].join(" | "), to };
|
|
1901
|
+
this.o.sql.all(
|
|
1902
|
+
`INSERT INTO judge_holds (id, from_ep, params, origin, verdict, created_at, expires_at, state) VALUES (?, ?, ?, ?, ?, ?, ?, 'held')`,
|
|
1903
|
+
id,
|
|
1904
|
+
from,
|
|
1905
|
+
JSON.stringify(stored2),
|
|
1906
|
+
JSON.stringify(origin),
|
|
1907
|
+
JSON.stringify(record),
|
|
1908
|
+
now,
|
|
1909
|
+
now + (params.ttl_s ?? LIMITS2.ttlDefaultS) * 1e3
|
|
1910
|
+
);
|
|
1911
|
+
this.o.core.auditJudge("judge_hold", from, id, detail);
|
|
1912
|
+
return heldResult(id, params);
|
|
1913
|
+
}
|
|
1914
|
+
detail(verdict, body, groups) {
|
|
1915
|
+
return {
|
|
1916
|
+
side: "local",
|
|
1917
|
+
boundary: [...groups.keys()].join(" | "),
|
|
1918
|
+
prompt_version: verdict.prompt_version,
|
|
1919
|
+
categories: verdict.categories,
|
|
1920
|
+
sha256: sha256Hex(body),
|
|
1921
|
+
verdict: verdict.verdict,
|
|
1922
|
+
source: verdict.source,
|
|
1923
|
+
reason: verdict.reason,
|
|
1924
|
+
to: [...groups.values()].flat(),
|
|
1925
|
+
...verdict.model ? { model: verdict.model } : {},
|
|
1926
|
+
...verdict.ms !== void 0 ? { ms: verdict.ms } : {}
|
|
1927
|
+
};
|
|
1928
|
+
}
|
|
1929
|
+
storedDetail(row, verdict) {
|
|
1930
|
+
const params = JSON.parse(row.params);
|
|
1931
|
+
return {
|
|
1932
|
+
side: "local",
|
|
1933
|
+
boundary: verdict.boundary,
|
|
1934
|
+
prompt_version: verdict.prompt_version,
|
|
1935
|
+
categories: verdict.categories,
|
|
1936
|
+
sha256: params.sha256 ?? sha256Hex(params.body ?? ""),
|
|
1937
|
+
verdict: verdict.verdict,
|
|
1938
|
+
to: verdict.to,
|
|
1939
|
+
...verdict.model ? { model: verdict.model } : {}
|
|
1940
|
+
};
|
|
1941
|
+
}
|
|
1942
|
+
/** marks a hold resolved and purges its body and the judge's quotes of it */
|
|
1943
|
+
resolve(row, state, by) {
|
|
1944
|
+
const { body, ...params } = JSON.parse(row.params);
|
|
1945
|
+
const verdict = JSON.parse(row.verdict);
|
|
1946
|
+
const purged = { ...params, bytes: new TextEncoder().encode(body).length, sha256: sha256Hex(body) };
|
|
1947
|
+
const kinds = { ...verdict, redactions: verdict.redactions.map((r) => ({ kind: r.kind })) };
|
|
1948
|
+
this.o.sql.all(
|
|
1949
|
+
`UPDATE judge_holds SET state = ?, by = ?, resolved_at = ?, params = ?, verdict = ? WHERE id = ?`,
|
|
1950
|
+
state,
|
|
1951
|
+
by,
|
|
1952
|
+
this.o.now(),
|
|
1953
|
+
JSON.stringify(purged),
|
|
1954
|
+
JSON.stringify(kinds),
|
|
1955
|
+
row.id
|
|
1956
|
+
);
|
|
1957
|
+
}
|
|
1958
|
+
tell(endpoint, text) {
|
|
1959
|
+
try {
|
|
1960
|
+
if (this.o.notice) this.o.notice(endpoint, text);
|
|
1961
|
+
else {
|
|
1962
|
+
this.o.core.judgeNotice(endpoint, text);
|
|
1963
|
+
this.o.wake(endpoint);
|
|
1964
|
+
}
|
|
1965
|
+
} catch (error) {
|
|
1966
|
+
if (!(error instanceof RelayError2)) throw error;
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
row(id) {
|
|
1970
|
+
const row = one2(this.o.sql, `SELECT * FROM judge_holds WHERE id = ?`, id);
|
|
1971
|
+
if (!row) throw new RelayError2("not_found", `no held message ${id}`);
|
|
1972
|
+
return row;
|
|
1973
|
+
}
|
|
1974
|
+
view(row) {
|
|
1975
|
+
const params = JSON.parse(row.params);
|
|
1976
|
+
const verdict = JSON.parse(row.verdict);
|
|
1977
|
+
return {
|
|
1978
|
+
id: row.id,
|
|
1979
|
+
from: this.o.core.addressOf(row.from_ep),
|
|
1980
|
+
to: verdict.to,
|
|
1981
|
+
state: row.state,
|
|
1982
|
+
created_at: iso(row.created_at),
|
|
1983
|
+
expires_at: iso(row.expires_at),
|
|
1984
|
+
categories: verdict.categories,
|
|
1985
|
+
reason: verdict.reason,
|
|
1986
|
+
boundary: verdict.boundary,
|
|
1987
|
+
source: verdict.source,
|
|
1988
|
+
bytes: params.bytes ?? new TextEncoder().encode(params.body ?? "").length,
|
|
1989
|
+
by: row.by,
|
|
1990
|
+
resolved_at: iso(row.resolved_at)
|
|
1991
|
+
};
|
|
1992
|
+
}
|
|
1993
|
+
};
|
|
1994
|
+
var fingerprint = (p) => sha256Hex(
|
|
1995
|
+
JSON.stringify([
|
|
1996
|
+
[p.to].flat().map(String).sort(),
|
|
1997
|
+
p.body,
|
|
1998
|
+
p.kind ?? "message",
|
|
1999
|
+
p.thread ?? null,
|
|
2000
|
+
p.reply_to ?? null,
|
|
2001
|
+
p.data_class ?? "public",
|
|
2002
|
+
(p.attachments ?? []).map((a) => a?.path).sort(),
|
|
2003
|
+
p.mime ?? null,
|
|
2004
|
+
p.ttl_s ?? LIMITS2.ttlDefaultS,
|
|
2005
|
+
p.require_reply ?? null
|
|
2006
|
+
])
|
|
2007
|
+
);
|
|
2008
|
+
function heldResult(id, params) {
|
|
2009
|
+
return { id, thread: params.thread ?? "", hop: 0, deliveries: [], held: { hold_id: id, side: "local" } };
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
// src/hub.ts
|
|
1531
2013
|
var GRAPH_MINUTES = 15;
|
|
1532
2014
|
var GRAPH_MINUTES_MAX = 60;
|
|
1533
2015
|
var HUB_LIMITS = {
|
|
@@ -1563,9 +2045,9 @@ var HUB_MIGRATION = [
|
|
|
1563
2045
|
];
|
|
1564
2046
|
var OBSERVER_METHODS = /* @__PURE__ */ new Set(["list", "snapshot", "watch", "log", "machines", "machines_rm"]);
|
|
1565
2047
|
var CLIENT_METHODS = /* @__PURE__ */ new Set(["whoami", "send", "ask", "ask_wait", "inbox", "ack", "status", "event", ...OBSERVER_METHODS]);
|
|
1566
|
-
var
|
|
2048
|
+
var iso2 = (ms) => ms === null ? null : new Date(ms).toISOString();
|
|
1567
2049
|
function rpcError(error) {
|
|
1568
|
-
if (!(error instanceof
|
|
2050
|
+
if (!(error instanceof RelayError3)) return { code: -32603, message: "internal error", data: { code: "unavailable" } };
|
|
1569
2051
|
const code = error.code === "method_not_found" ? -32601 : error.code === "invalid_params" ? -32602 : -32e3;
|
|
1570
2052
|
return { code, message: error.message, data: { code: error.code, ...error.retryAfterS ? { retry_after: error.retryAfterS } : {} } };
|
|
1571
2053
|
}
|
|
@@ -1573,16 +2055,16 @@ function mirrorSync(value) {
|
|
|
1573
2055
|
if (value === void 0 || value === null) return null;
|
|
1574
2056
|
const { id, part, parts } = value;
|
|
1575
2057
|
if (typeof id !== "string" || !id || id.length > 64 || !Number.isInteger(parts) || parts < 1 || parts > 4096 || !Number.isInteger(part) || part < 1 || part > parts) {
|
|
1576
|
-
throw new
|
|
2058
|
+
throw new RelayError3("invalid_params", "sync is { id, part, parts } of a full mirror in several frames");
|
|
1577
2059
|
}
|
|
1578
2060
|
return { id, part, parts };
|
|
1579
2061
|
}
|
|
1580
2062
|
function serverFields(params) {
|
|
1581
|
-
for (const field of SERVER_FIELDS) if (field in params) throw new
|
|
2063
|
+
for (const field of SERVER_FIELDS) if (field in params) throw new RelayError3("invalid_params", `${field} is set by the relay, not the sender`);
|
|
1582
2064
|
}
|
|
1583
2065
|
function object(value, what) {
|
|
1584
2066
|
if (value === void 0) return {};
|
|
1585
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) throw new
|
|
2067
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new RelayError3("invalid_params", `${what} must be an object`);
|
|
1586
2068
|
return value;
|
|
1587
2069
|
}
|
|
1588
2070
|
var fullLogin = (grant) => grant.scoped === false;
|
|
@@ -1629,6 +2111,8 @@ var RelayHub = class _RelayHub {
|
|
|
1629
2111
|
xcounters = null;
|
|
1630
2112
|
xkey = null;
|
|
1631
2113
|
limits;
|
|
2114
|
+
judgeConfig = null;
|
|
2115
|
+
gate = null;
|
|
1632
2116
|
/** idempotent: migrates and records the account handle (the address user part, e.g. "ada"); call before handling each request */
|
|
1633
2117
|
init(account) {
|
|
1634
2118
|
if (this.core && this.account === account.user) return;
|
|
@@ -1649,6 +2133,7 @@ var RelayHub = class _RelayHub {
|
|
|
1649
2133
|
});
|
|
1650
2134
|
core.migrate();
|
|
1651
2135
|
this.core = core;
|
|
2136
|
+
this.gate = null;
|
|
1652
2137
|
this.account = account.user;
|
|
1653
2138
|
if (this.deps.crossAccount) {
|
|
1654
2139
|
this.xkey = core.xstate("key", () => randomHex2(32));
|
|
@@ -1663,12 +2148,37 @@ var RelayHub = class _RelayHub {
|
|
|
1663
2148
|
this.denied = null;
|
|
1664
2149
|
this.xcounters = null;
|
|
1665
2150
|
this.xkey = null;
|
|
2151
|
+
this.gate = null;
|
|
1666
2152
|
this.buckets.clear();
|
|
1667
2153
|
this.gone.clear();
|
|
1668
2154
|
}
|
|
2155
|
+
/**
|
|
2156
|
+
* judges this account's own sends (client send and ask, bridge forward) before delivery; sends to other accounts are left to the
|
|
2157
|
+
* cross-account gate. Without a policy, or with mode off, the hub holds no judge and creates no judge tables
|
|
2158
|
+
*/
|
|
2159
|
+
configureJudge(policy, options = { env: {} }) {
|
|
2160
|
+
this.gate = null;
|
|
2161
|
+
this.judgeConfig = policy && policy.mode !== "off" ? { policy, options } : null;
|
|
2162
|
+
}
|
|
2163
|
+
/** the held messages, newest first, without bodies */
|
|
2164
|
+
holds(state) {
|
|
2165
|
+
return this.judged().holds(state);
|
|
2166
|
+
}
|
|
2167
|
+
/** one hold with its body while it waits */
|
|
2168
|
+
holdShow(id) {
|
|
2169
|
+
return this.judged().show(id);
|
|
2170
|
+
}
|
|
2171
|
+
/** delivers a held message as its sender, every send check run again */
|
|
2172
|
+
holdRelease(id, options) {
|
|
2173
|
+
return this.judged().release(id, options, this.deliverJudged);
|
|
2174
|
+
}
|
|
2175
|
+
/** refuses a held message: never delivered, its sender told why */
|
|
2176
|
+
holdRefuse(id, reason, by) {
|
|
2177
|
+
return this.judged().refuse(id, reason, by);
|
|
2178
|
+
}
|
|
1669
2179
|
/** a fresh connection state for an accepted socket */
|
|
1670
2180
|
static connection(grant) {
|
|
1671
|
-
return { id:
|
|
2181
|
+
return { id: newId3("conn"), grant, role: "new", machine: null, endpoint: null, watching: false, subscribed: false };
|
|
1672
2182
|
}
|
|
1673
2183
|
/** a socket would exceed the `sockets` limit: the host refuses it (429) instead of accepting */
|
|
1674
2184
|
full() {
|
|
@@ -1677,7 +2187,7 @@ var RelayHub = class _RelayHub {
|
|
|
1677
2187
|
/** one JSON-RPC text frame from a socket; replies and pushes go out through socket.send */
|
|
1678
2188
|
async message(socket, text) {
|
|
1679
2189
|
if (new TextEncoder().encode(text).length > this.limits.frameBytes) {
|
|
1680
|
-
this.write(socket, { jsonrpc: "2.0", id: null, error: rpcError(new
|
|
2190
|
+
this.write(socket, { jsonrpc: "2.0", id: null, error: rpcError(new RelayError3("too_large", `a frame is at most ${this.limits.frameBytes} bytes`)) });
|
|
1681
2191
|
socket.close(1009, "frame too large");
|
|
1682
2192
|
this.closed(socket);
|
|
1683
2193
|
return;
|
|
@@ -1703,7 +2213,7 @@ var RelayHub = class _RelayHub {
|
|
|
1703
2213
|
/** POST /v1/relay/rpc: a JSON-RPC request or batch run in order on a short-lived connection; returns the response(s) */
|
|
1704
2214
|
async batch(grant, body) {
|
|
1705
2215
|
if (JSON.stringify(body ?? null).length > this.limits.frameBytes) {
|
|
1706
|
-
return { jsonrpc: "2.0", id: null, error: rpcError(new
|
|
2216
|
+
return { jsonrpc: "2.0", id: null, error: rpcError(new RelayError3("too_large", `a request is at most ${this.limits.frameBytes} bytes`)) };
|
|
1707
2217
|
}
|
|
1708
2218
|
const requests = Array.isArray(body) ? body : [body];
|
|
1709
2219
|
if (!requests.length) return { jsonrpc: "2.0", id: null, error: { code: -32600, message: "an empty batch" } };
|
|
@@ -1823,6 +2333,7 @@ var RelayHub = class _RelayHub {
|
|
|
1823
2333
|
this.gone.clear();
|
|
1824
2334
|
for (const [key, bucket] of this.buckets) if (bucket.tokens + (now - bucket.at) * bucket.rate / 6e4 >= bucket.rate) this.buckets.delete(key);
|
|
1825
2335
|
this.xcounters?.prune();
|
|
2336
|
+
this.judgeGate()?.sweep();
|
|
1826
2337
|
this.setState("last_sweep", now);
|
|
1827
2338
|
}
|
|
1828
2339
|
if (this.idle(now)) {
|
|
@@ -1850,7 +2361,7 @@ var RelayHub = class _RelayHub {
|
|
|
1850
2361
|
swept = now;
|
|
1851
2362
|
this.setState("last_sweep", swept);
|
|
1852
2363
|
}
|
|
1853
|
-
const retrying =
|
|
2364
|
+
const retrying = one3(this.deps.sql, `SELECT 1 FROM hub_state WHERE key LIKE ? LIMIT 1`, `${REVALIDATE_ERROR}%`) !== void 0;
|
|
1854
2365
|
return Math.min(
|
|
1855
2366
|
validated + (retrying ? REVALIDATE_RETRY_MS : this.limits.revalidateMs),
|
|
1856
2367
|
swept + SWEEP_MS,
|
|
@@ -1866,7 +2377,7 @@ var RelayHub = class _RelayHub {
|
|
|
1866
2377
|
/** while the hub holds rows: the earliest unacked delivery's expiry, at most a day and at least a minute away */
|
|
1867
2378
|
retentionAlarm(now) {
|
|
1868
2379
|
const sql = this.deps.sql;
|
|
1869
|
-
const held =
|
|
2380
|
+
const held = one3(
|
|
1870
2381
|
sql,
|
|
1871
2382
|
`SELECT EXISTS (SELECT 1 FROM machines) OR EXISTS (SELECT 1 FROM machine_tombstones) OR EXISTS (SELECT 1 FROM endpoints WHERE handle NOT IN (?, ?))
|
|
1872
2383
|
OR EXISTS (SELECT 1 FROM messages) OR EXISTS (SELECT 1 FROM deliveries) OR EXISTS (SELECT 1 FROM audit) AS held`,
|
|
@@ -1874,13 +2385,13 @@ var RelayHub = class _RelayHub {
|
|
|
1874
2385
|
SYSTEM_HANDLE2
|
|
1875
2386
|
).held;
|
|
1876
2387
|
if (!held) return null;
|
|
1877
|
-
const expiry =
|
|
2388
|
+
const expiry = one3(sql, `SELECT MIN(expires_at) AS at FROM deliveries WHERE acked_at IS NULL`).at;
|
|
1878
2389
|
return Math.max(now + MIN_ALARM_MS, Math.min(expiry ?? Number.POSITIVE_INFINITY, now + DAY_MS));
|
|
1879
2390
|
}
|
|
1880
2391
|
/** no machine bound or tombstoned, no live socket and nothing queued */
|
|
1881
2392
|
idle(now) {
|
|
1882
2393
|
if (this.deps.sockets().some((s) => !this.gone.has(s.conn.id))) return false;
|
|
1883
|
-
return !
|
|
2394
|
+
return !one3(
|
|
1884
2395
|
this.deps.sql,
|
|
1885
2396
|
`SELECT EXISTS (SELECT 1 FROM machines) OR EXISTS (SELECT 1 FROM machine_tombstones) OR EXISTS (SELECT 1 FROM deliveries WHERE acked_at IS NULL AND expires_at > ?) AS busy`,
|
|
1886
2397
|
now
|
|
@@ -1901,7 +2412,7 @@ var RelayHub = class _RelayHub {
|
|
|
1901
2412
|
const result = await this.dispatch(socket, method, object(request.params, "params"));
|
|
1902
2413
|
return request.id === void 0 ? null : { jsonrpc: "2.0", id, result };
|
|
1903
2414
|
} catch (error) {
|
|
1904
|
-
code = error instanceof
|
|
2415
|
+
code = error instanceof RelayError3 ? error.code : "internal";
|
|
1905
2416
|
if (code === "internal") this.deps.log?.("relay_internal_error", { account: this.account, method, error: error instanceof Error ? error.name : "error" });
|
|
1906
2417
|
return request.id === void 0 ? null : { jsonrpc: "2.0", id, error: rpcError(error) };
|
|
1907
2418
|
} finally {
|
|
@@ -1919,7 +2430,7 @@ var RelayHub = class _RelayHub {
|
|
|
1919
2430
|
const now = this.now();
|
|
1920
2431
|
if (method === "ack" && (conn.role === "client" || conn.role === "bridge") || method === "reject" && conn.role === "bridge") {
|
|
1921
2432
|
const seqs = params?.seqs;
|
|
1922
|
-
const cost = method === "ack" && Array.isArray(seqs) ? Math.min(Math.max(seqs.length, 1),
|
|
2433
|
+
const cost = method === "ack" && Array.isArray(seqs) ? Math.min(Math.max(seqs.length, 1), LIMITS3.inboxPage) : 1;
|
|
1923
2434
|
const token = this.bucket(`${conn.grant.tokenId}:ack`, this.limits.acksPerMinute, now);
|
|
1924
2435
|
const account2 = this.bucket("account:ack", this.limits.accountAcksPerMinute, now);
|
|
1925
2436
|
for (const [b, scope] of [
|
|
@@ -1927,7 +2438,7 @@ var RelayHub = class _RelayHub {
|
|
|
1927
2438
|
[account2, "an account"]
|
|
1928
2439
|
]) {
|
|
1929
2440
|
if (b.tokens < cost)
|
|
1930
|
-
throw new
|
|
2441
|
+
throw new RelayError3("rate_limited", `at most ${b.rate} acked seqs per minute on ${scope}`, Math.max(1, Math.ceil((cost - b.tokens) * 60 / b.rate)));
|
|
1931
2442
|
}
|
|
1932
2443
|
token.tokens -= cost;
|
|
1933
2444
|
account2.tokens -= cost;
|
|
@@ -1940,7 +2451,7 @@ var RelayHub = class _RelayHub {
|
|
|
1940
2451
|
if (method === "event") {
|
|
1941
2452
|
for (const b of [bucket, account]) {
|
|
1942
2453
|
if (b.tokens < b.rate / 4)
|
|
1943
|
-
throw new
|
|
2454
|
+
throw new RelayError3(
|
|
1944
2455
|
"rate_limited",
|
|
1945
2456
|
"activity events wait while the token or the account is near its call limit",
|
|
1946
2457
|
Math.max(1, Math.ceil((b.rate / 4 - b.tokens) * 60 / b.rate))
|
|
@@ -1952,14 +2463,14 @@ var RelayHub = class _RelayHub {
|
|
|
1952
2463
|
socket.close(1013, "rate limited");
|
|
1953
2464
|
this.closed(socket);
|
|
1954
2465
|
}
|
|
1955
|
-
throw new
|
|
2466
|
+
throw new RelayError3(
|
|
1956
2467
|
"rate_limited",
|
|
1957
2468
|
`at most ${rate} ${bridge ? "bridge " : ""}calls per minute on a token`,
|
|
1958
2469
|
Math.max(1, Math.ceil((1 - bucket.tokens) * 60 / rate))
|
|
1959
2470
|
);
|
|
1960
2471
|
}
|
|
1961
2472
|
if (account.tokens < 1) {
|
|
1962
|
-
throw new
|
|
2473
|
+
throw new RelayError3(
|
|
1963
2474
|
"rate_limited",
|
|
1964
2475
|
`at most ${this.limits.accountCallsPerMinute} calls per minute on an account`,
|
|
1965
2476
|
Math.max(1, Math.ceil((1 - account.tokens) * 60 / account.rate))
|
|
@@ -1978,36 +2489,36 @@ var RelayHub = class _RelayHub {
|
|
|
1978
2489
|
return bucket;
|
|
1979
2490
|
}
|
|
1980
2491
|
async dispatch(socket, method, params) {
|
|
1981
|
-
if (this.gone.has(socket.conn.id)) throw new
|
|
2492
|
+
if (this.gone.has(socket.conn.id)) throw new RelayError3("unauthorized", "this connection is closed");
|
|
1982
2493
|
if (method === "hello") return this.hello(socket, params);
|
|
1983
|
-
if (socket.conn.role === "new") throw new
|
|
2494
|
+
if (socket.conn.role === "new") throw new RelayError3("unauthorized", "call hello first");
|
|
1984
2495
|
if (socket.conn.role === "bridge") return this.bridgeMethod(socket, method, params);
|
|
1985
2496
|
if (socket.conn.role === "observer" && !OBSERVER_METHODS.has(method)) {
|
|
1986
|
-
if (CLIENT_METHODS.has(method)) throw new
|
|
1987
|
-
throw new
|
|
2497
|
+
if (CLIENT_METHODS.has(method)) throw new RelayError3("unauthorized", "this connection only observes: say hello as an agent or the human");
|
|
2498
|
+
throw new RelayError3("method_not_found", `no method ${method}`);
|
|
1988
2499
|
}
|
|
1989
2500
|
return this.clientMethod(socket, method, params);
|
|
1990
2501
|
}
|
|
1991
2502
|
hello(socket, params) {
|
|
1992
2503
|
const core = this.relay;
|
|
1993
2504
|
const conn = socket.conn;
|
|
1994
|
-
if (conn.role !== "new") throw new
|
|
1995
|
-
if (params.protocol !== PROTOCOL) throw new
|
|
2505
|
+
if (conn.role !== "new") throw new RelayError3("invalid_params", "hello was already called on this connection");
|
|
2506
|
+
if (params.protocol !== PROTOCOL) throw new RelayError3("invalid_params", `this relay speaks ${PROTOCOL}`);
|
|
1996
2507
|
if (params.transient !== void 0)
|
|
1997
|
-
throw new
|
|
2508
|
+
throw new RelayError3("invalid_params", "the hub takes no hook connections: hooks report to the local broker, which bridges them");
|
|
1998
2509
|
serverFields(params);
|
|
1999
2510
|
const observe = params.observe === true;
|
|
2000
2511
|
const named = params.machine;
|
|
2001
2512
|
if ((!observe || named !== void 0) && (typeof named !== "string" || !MACHINE_NAME.test(named) || named === "me" || named === "local" || named === this.account)) {
|
|
2002
|
-
throw new
|
|
2513
|
+
throw new RelayError3("invalid_params", "machine is a name of lowercase letters, digits and -, other than me, local and the account handle");
|
|
2003
2514
|
}
|
|
2004
2515
|
const bridge = params.bridge === true;
|
|
2005
2516
|
const human = params.human === true;
|
|
2006
2517
|
this.deps.sql.all(`DELETE FROM hub_state WHERE key = 'idle_since'`);
|
|
2007
|
-
const info = { account: this.account, capabilities: CAPABILITIES, server_time: new Date(this.now()).toISOString(), limits: { ...
|
|
2518
|
+
const info = { account: this.account, capabilities: CAPABILITIES, server_time: new Date(this.now()).toISOString(), limits: { ...LIMITS3, ...this.limits } };
|
|
2008
2519
|
if (observe) {
|
|
2009
2520
|
if (bridge || human || params.claim !== void 0 || params.resume !== void 0)
|
|
2010
|
-
throw new
|
|
2521
|
+
throw new RelayError3("invalid_params", "an observer hello has no bridge, human, claim or resume");
|
|
2011
2522
|
conn.role = "observer";
|
|
2012
2523
|
conn.machine = typeof named === "string" ? named : null;
|
|
2013
2524
|
this.save(socket);
|
|
@@ -2015,11 +2526,11 @@ var RelayHub = class _RelayHub {
|
|
|
2015
2526
|
}
|
|
2016
2527
|
const machine = named;
|
|
2017
2528
|
const base = { ...info, machine };
|
|
2018
|
-
if (bridge && this.transient.has(socket)) throw new
|
|
2529
|
+
if (bridge && this.transient.has(socket)) throw new RelayError3("invalid_params", "a bridge connects over the WebSocket");
|
|
2019
2530
|
if (human && typeof params.tty !== "boolean")
|
|
2020
|
-
throw new
|
|
2531
|
+
throw new RelayError3("invalid_params", "a human hello says whether a terminal is attached (tty: true or false)");
|
|
2021
2532
|
if (human) {
|
|
2022
|
-
if (!fullLogin(conn.grant)) throw new
|
|
2533
|
+
if (!fullLogin(conn.grant)) throw new RelayError3("unauthorized", "a scoped token acts as an agent: use --as");
|
|
2023
2534
|
const endpoint2 = core.claimHuman(params.tty);
|
|
2024
2535
|
conn.role = "client";
|
|
2025
2536
|
conn.machine = machine;
|
|
@@ -2046,8 +2557,8 @@ var RelayHub = class _RelayHub {
|
|
|
2046
2557
|
core.record("bridge", null, machine, { machine });
|
|
2047
2558
|
return { ...base, endpoints: core.remoteViews({ except: machine }), others: this.othersOf(conn.grant, machine) };
|
|
2048
2559
|
}
|
|
2049
|
-
if (
|
|
2050
|
-
throw new
|
|
2560
|
+
if (one3(this.deps.sql, `SELECT kind FROM machines WHERE name = ?`, machine)?.kind === "bridge") {
|
|
2561
|
+
throw new RelayError3(
|
|
2051
2562
|
"invalid_params",
|
|
2052
2563
|
`machine ${machine} is linked through its broker's bridge; run the command there without --cloud, or pick another --machine`
|
|
2053
2564
|
);
|
|
@@ -2068,23 +2579,23 @@ var RelayHub = class _RelayHub {
|
|
|
2068
2579
|
}
|
|
2069
2580
|
/** refuses a machine bound to another token, a revoked token's machine to anyone but its member or an admin, or one past the machine cap; returns whether this token already holds it */
|
|
2070
2581
|
bindable(machine, grant) {
|
|
2071
|
-
const bound =
|
|
2582
|
+
const bound = one3(this.deps.sql, `SELECT token_id FROM machines WHERE name = ?`, machine);
|
|
2072
2583
|
if (bound && bound.token_id !== grant.tokenId) {
|
|
2073
2584
|
this.relay.record("refuse", null, machine, { code: "unauthorized", machine });
|
|
2074
|
-
throw new
|
|
2585
|
+
throw new RelayError3("unauthorized", `machine ${machine} is bound to another token; release it with ${CLI_NAME} machines rm ${machine}`);
|
|
2075
2586
|
}
|
|
2076
2587
|
if (bound) return true;
|
|
2077
|
-
const tomb =
|
|
2588
|
+
const tomb = one3(this.deps.sql, `SELECT actor_id FROM machine_tombstones WHERE name = ?`, machine);
|
|
2078
2589
|
if (tomb && !fullOf(grant, tomb.actor_id) && !accountAdmin(grant)) {
|
|
2079
2590
|
this.relay.record("refuse", null, machine, { code: "unauthorized", machine });
|
|
2080
|
-
throw new
|
|
2591
|
+
throw new RelayError3(
|
|
2081
2592
|
"unauthorized",
|
|
2082
2593
|
`machine ${machine} was unbound when its token was revoked; a full login of the same member (or an org owner or admin) can take it back, or ${CLI_NAME} machines rm ${machine} drops its queued messages`
|
|
2083
2594
|
);
|
|
2084
2595
|
}
|
|
2085
|
-
const count =
|
|
2596
|
+
const count = one3(this.deps.sql, `SELECT COUNT(*) AS n FROM machines`).n;
|
|
2086
2597
|
if (count >= this.limits.machines)
|
|
2087
|
-
throw new
|
|
2598
|
+
throw new RelayError3("too_large", `at most ${this.limits.machines} machines per account; release one with ${CLI_NAME} machines rm`);
|
|
2088
2599
|
return false;
|
|
2089
2600
|
}
|
|
2090
2601
|
/** binds the machine to the token once its hello succeeded; `held` is what bindable() returned */
|
|
@@ -2141,7 +2652,7 @@ var RelayHub = class _RelayHub {
|
|
|
2141
2652
|
case "machines_rm":
|
|
2142
2653
|
return this.releaseMachine(socket, params.name);
|
|
2143
2654
|
}
|
|
2144
|
-
if (!me) throw new
|
|
2655
|
+
if (!me) throw new RelayError3("unauthorized", "this connection has no endpoint");
|
|
2145
2656
|
switch (method) {
|
|
2146
2657
|
case "whoami":
|
|
2147
2658
|
return core.whoami(me);
|
|
@@ -2151,15 +2662,14 @@ var RelayHub = class _RelayHub {
|
|
|
2151
2662
|
const send = { ...params };
|
|
2152
2663
|
delete send.wait_s;
|
|
2153
2664
|
if (method === "ask") send.kind = "request";
|
|
2154
|
-
const
|
|
2155
|
-
const
|
|
2156
|
-
|
|
2157
|
-
if (
|
|
2158
|
-
if (method === "send") return result;
|
|
2665
|
+
const origin = conn.tty === void 0 ? {} : { tty: conn.tty };
|
|
2666
|
+
const gate = this.judgeGate();
|
|
2667
|
+
const result = gate ? await gate.send(me, send, { ...origin, machine: conn.machine }, this.deliverJudged) : this.deliverOn(conn.machine, me, send, origin);
|
|
2668
|
+
if (method === "send" || result.held) return result;
|
|
2159
2669
|
return this.awaitReply(socket, me, result.id, this.waitMs(socket, params.wait_s), result);
|
|
2160
2670
|
}
|
|
2161
2671
|
case "ask_wait": {
|
|
2162
|
-
if (typeof params.id !== "string") throw new
|
|
2672
|
+
if (typeof params.id !== "string") throw new RelayError3("invalid_params", "id is the request's message id");
|
|
2163
2673
|
return this.awaitReply(socket, me, params.id, this.waitMs(socket, params.wait_s));
|
|
2164
2674
|
}
|
|
2165
2675
|
case "inbox": {
|
|
@@ -2187,7 +2697,7 @@ var RelayHub = class _RelayHub {
|
|
|
2187
2697
|
return row;
|
|
2188
2698
|
}
|
|
2189
2699
|
}
|
|
2190
|
-
throw new
|
|
2700
|
+
throw new RelayError3("method_not_found", `no method ${method}`);
|
|
2191
2701
|
}
|
|
2192
2702
|
async bridgeMethod(socket, method, params) {
|
|
2193
2703
|
const core = this.relay;
|
|
@@ -2225,20 +2735,19 @@ var RelayHub = class _RelayHub {
|
|
|
2225
2735
|
const send = { ...object(params.params, "params") };
|
|
2226
2736
|
serverFields(send);
|
|
2227
2737
|
const asked = params.hop_floor ?? 0;
|
|
2228
|
-
if (!Number.isInteger(asked) || asked < 0 || asked > 1e3) throw new
|
|
2738
|
+
if (!Number.isInteger(asked) || asked < 0 || asked > 1e3) throw new RelayError3("invalid_params", "hop_floor is a hop count");
|
|
2229
2739
|
if ([send.to].flat().some((to) => core.foreignAccount(to) !== null)) return this.xsend(sender, send, { tty: params.tty === true, hop: asked });
|
|
2230
2740
|
delete send.attachments;
|
|
2231
|
-
const floor = Math.min(asked,
|
|
2741
|
+
const floor = Math.min(asked, LIMITS3.hopMax + 1);
|
|
2232
2742
|
if (floor > 0) core.markReceived(sender, floor - 1);
|
|
2233
|
-
const
|
|
2234
|
-
const
|
|
2235
|
-
if (
|
|
2236
|
-
|
|
2237
|
-
return result;
|
|
2743
|
+
const gate = this.judgeGate();
|
|
2744
|
+
const origin = { tty: params.tty === true };
|
|
2745
|
+
if (gate) return gate.send(sender, send, { ...origin, machine }, this.deliverJudged);
|
|
2746
|
+
return this.deliverOn(machine, sender, send, origin);
|
|
2238
2747
|
}
|
|
2239
2748
|
case "subscribe": {
|
|
2240
2749
|
const after = params.after ?? 0;
|
|
2241
|
-
if (!Number.isInteger(after) || after < 0) throw new
|
|
2750
|
+
if (!Number.isInteger(after) || after < 0) throw new RelayError3("invalid_params", "after is a seq");
|
|
2242
2751
|
if (this.deps.crossAccount) core.xsenders(await this.xinbound());
|
|
2243
2752
|
conn.subscribed = true;
|
|
2244
2753
|
this.save(socket);
|
|
@@ -2275,7 +2784,7 @@ var RelayHub = class _RelayHub {
|
|
|
2275
2784
|
this.save(socket);
|
|
2276
2785
|
return core.snapshot({ activity: 20 });
|
|
2277
2786
|
}
|
|
2278
|
-
throw new
|
|
2787
|
+
throw new RelayError3("method_not_found", `no method ${method} on a bridge`);
|
|
2279
2788
|
}
|
|
2280
2789
|
// other accounts
|
|
2281
2790
|
/**
|
|
@@ -2287,22 +2796,22 @@ var RelayHub = class _RelayHub {
|
|
|
2287
2796
|
const core = this.relay;
|
|
2288
2797
|
const link = this.deps.crossAccount;
|
|
2289
2798
|
const targets = [send.to].flat();
|
|
2290
|
-
const missing = () => new
|
|
2291
|
-
if (targets.length !== 1) throw new
|
|
2799
|
+
const missing = () => new RelayError3("not_found", `no endpoint ${targets.map(String).join(", ")}`);
|
|
2800
|
+
if (targets.length !== 1) throw new RelayError3("invalid_params", "a message to another account goes to exactly one endpoint: no lists, groups or tree:");
|
|
2292
2801
|
const target = core.foreignAccount(targets[0]);
|
|
2293
2802
|
if (!target || !link || !this.xcounters || !this.xkey) throw missing();
|
|
2294
|
-
if (typeof send.body !== "string") throw new
|
|
2803
|
+
if (typeof send.body !== "string") throw new RelayError3("invalid_params", "body must be a string");
|
|
2295
2804
|
const bytes = new TextEncoder().encode(send.body).length;
|
|
2296
|
-
if (bytes > XLIMITS.bodyBytes) throw new
|
|
2805
|
+
if (bytes > XLIMITS.bodyBytes) throw new RelayError3("too_large", `a message to another account is at most ${XLIMITS.bodyBytes} bytes; this one is ${bytes}`);
|
|
2297
2806
|
if (send.attachments !== void 0 && !(Array.isArray(send.attachments) && !send.attachments.length))
|
|
2298
|
-
throw new
|
|
2299
|
-
if (send.data_class === "private") throw new
|
|
2300
|
-
if (send.data_class !== void 0 && send.data_class !== "public") throw new
|
|
2807
|
+
throw new RelayError3("data_class_refused", "attachments stay in this account");
|
|
2808
|
+
if (send.data_class === "private") throw new RelayError3("data_class_refused", "a private message stays in this account");
|
|
2809
|
+
if (send.data_class !== void 0 && send.data_class !== "public") throw new RelayError3("invalid_params", "data_class must be public or private");
|
|
2301
2810
|
const kind = send.kind ?? "message";
|
|
2302
|
-
if (!MESSAGE_KINDS2.includes(kind)) throw new
|
|
2303
|
-
if (send.reply_to !== void 0 && typeof send.reply_to !== "string") throw new
|
|
2811
|
+
if (!MESSAGE_KINDS2.includes(kind)) throw new RelayError3("invalid_params", `kind must be one of ${MESSAGE_KINDS2.join(", ")}`);
|
|
2812
|
+
if (send.reply_to !== void 0 && typeof send.reply_to !== "string") throw new RelayError3("invalid_params", "reply_to is a message id");
|
|
2304
2813
|
if (origin.hop > XLIMITS.hopMax)
|
|
2305
|
-
throw new
|
|
2814
|
+
throw new RelayError3(
|
|
2306
2815
|
"loop_guard",
|
|
2307
2816
|
`this message would be hop ${origin.hop} of a chain across accounts (limit ${XLIMITS.hopMax}); stop and report to your user`
|
|
2308
2817
|
);
|
|
@@ -2317,16 +2826,16 @@ var RelayHub = class _RelayHub {
|
|
|
2317
2826
|
const granted = answered && answered.from.address === address ? parent : null;
|
|
2318
2827
|
if (!granted && !core.crossAdmission(sender, "out", target).ok) throw missing();
|
|
2319
2828
|
const now = this.now();
|
|
2320
|
-
const thread = granted?.thread ?? cleanText2(send.thread, "thread", 64) ??
|
|
2829
|
+
const thread = granted?.thread ?? cleanText2(send.thread, "thread", 64) ?? newId3("thr", now);
|
|
2321
2830
|
const hop = Math.max(origin.hop, granted ? answered.hop + 1 : 0);
|
|
2322
2831
|
if (hop > XLIMITS.hopMax)
|
|
2323
|
-
throw new
|
|
2832
|
+
throw new RelayError3("loop_guard", `this message would be hop ${hop} of a chain across accounts (limit ${XLIMITS.hopMax}); stop and report to your user`);
|
|
2324
2833
|
this.xcounters.check(contact, me.remote, address, thread);
|
|
2325
|
-
const secret =
|
|
2326
|
-
if (secret) throw new
|
|
2327
|
-
const ttl = send.ttl_s ??
|
|
2328
|
-
if (!Number.isInteger(ttl) || ttl < 1 || ttl >
|
|
2329
|
-
throw new
|
|
2834
|
+
const secret = findSecret3(send.body);
|
|
2835
|
+
if (secret) throw new RelayError3("secret_refused", `the message looks like it carries a credential (${secret}); send a reference instead`);
|
|
2836
|
+
const ttl = send.ttl_s ?? LIMITS3.ttlDefaultS;
|
|
2837
|
+
if (!Number.isInteger(ttl) || ttl < 1 || ttl > LIMITS3.ttlMaxS)
|
|
2838
|
+
throw new RelayError3("invalid_params", `ttl_s must be 1 to ${LIMITS3.ttlMaxS}`);
|
|
2330
2839
|
const self = link.self;
|
|
2331
2840
|
const own = `${self.handle}/${me.machine}/${me.handle || "me"}`;
|
|
2332
2841
|
const human = me.kind === "human" && me.handle === "" && origin.tty;
|
|
@@ -2339,7 +2848,7 @@ var RelayHub = class _RelayHub {
|
|
|
2339
2848
|
machine: me.machine,
|
|
2340
2849
|
harness: me.harness
|
|
2341
2850
|
};
|
|
2342
|
-
const id = origin.id ??
|
|
2851
|
+
const id = origin.id ?? newId3("msg", now);
|
|
2343
2852
|
const expiresAt = now + ttl * 1e3;
|
|
2344
2853
|
const declared = declaredLimits(contact.limits);
|
|
2345
2854
|
const msg = {
|
|
@@ -2351,7 +2860,7 @@ var RelayHub = class _RelayHub {
|
|
|
2351
2860
|
thread,
|
|
2352
2861
|
reply_to: replyTo,
|
|
2353
2862
|
hop,
|
|
2354
|
-
client_id: origin.clientId ?? this.deps.hash(`${own}|${address}|${cleanText2(send.client_id, "client_id", 128) ??
|
|
2863
|
+
client_id: origin.clientId ?? this.deps.hash(`${own}|${address}|${cleanText2(send.client_id, "client_id", 128) ?? newId3("cid", now)}`),
|
|
2355
2864
|
created_at: now,
|
|
2356
2865
|
expires_at: expiresAt,
|
|
2357
2866
|
...granted?.grant ? { reply_grant: granted.grant, reply_grant_expires_at: granted.expires_at } : {},
|
|
@@ -2373,7 +2882,7 @@ var RelayHub = class _RelayHub {
|
|
|
2373
2882
|
try {
|
|
2374
2883
|
delivered = await link.deliver(contact, msg);
|
|
2375
2884
|
} catch (error) {
|
|
2376
|
-
if (!(error instanceof
|
|
2885
|
+
if (!(error instanceof RelayError3) || error.code === "unavailable") refund();
|
|
2377
2886
|
throw error;
|
|
2378
2887
|
}
|
|
2379
2888
|
this.fleet({ type: "message", machine: me.machine, id: delivered.id, thread, kind, from: from.address, to: [address], hop });
|
|
@@ -2396,7 +2905,7 @@ var RelayHub = class _RelayHub {
|
|
|
2396
2905
|
const core = this.relay;
|
|
2397
2906
|
const link = this.deps.crossAccount;
|
|
2398
2907
|
const msg = parseXMessage(raw);
|
|
2399
|
-
const missing = () => new
|
|
2908
|
+
const missing = () => new RelayError3("not_found", `no endpoint ${link?.self.handle ?? this.account}/${msg.to}`);
|
|
2400
2909
|
if (!link || !this.xcounters || !this.xkey) throw missing();
|
|
2401
2910
|
const contact = await link.contactById(fromAccountId);
|
|
2402
2911
|
if (!contact || contact.dir === "out" || contact.peerId !== fromAccountId || msg.from.account !== contact.peerHandle) throw missing();
|
|
@@ -2412,11 +2921,11 @@ var RelayHub = class _RelayHub {
|
|
|
2412
2921
|
handle: parts.length === 3 && parts[2] !== "me" ? parts[2] : ""
|
|
2413
2922
|
};
|
|
2414
2923
|
if (!granted && !core.crossAdmission(target.id, "in", peer).ok) throw missing();
|
|
2415
|
-
if (!honestSender(msg.from)) throw new
|
|
2924
|
+
if (!honestSender(msg.from)) throw new RelayError3("invalid_params", "the sender is not one its relay may name");
|
|
2416
2925
|
const bytes = new TextEncoder().encode(msg.body).length;
|
|
2417
|
-
if (bytes > XLIMITS.bodyBytes) throw new
|
|
2418
|
-
if (msg.hop > XLIMITS.hopMax) throw new
|
|
2419
|
-
if (msg.expires_at <= this.now()) throw new
|
|
2926
|
+
if (bytes > XLIMITS.bodyBytes) throw new RelayError3("too_large", `a message from another account is at most ${XLIMITS.bodyBytes} bytes`);
|
|
2927
|
+
if (msg.hop > XLIMITS.hopMax) throw new RelayError3("loop_guard", `hop ${msg.hop} is past the limit across accounts (${XLIMITS.hopMax})`);
|
|
2928
|
+
if (msg.expires_at <= this.now()) throw new RelayError3("invalid_params", "the message expired");
|
|
2420
2929
|
const earlier = core.xduplicate(fromAccountId, msg.client_id);
|
|
2421
2930
|
if (earlier) return { id: earlier.id, state: earlier.acked ? "delivered" : "queued", duplicate: true };
|
|
2422
2931
|
const envelope = {
|
|
@@ -2446,8 +2955,8 @@ var RelayHub = class _RelayHub {
|
|
|
2446
2955
|
const message = JSON.stringify(envelope);
|
|
2447
2956
|
const stored2 = new TextEncoder().encode(message).length;
|
|
2448
2957
|
this.xadmit(fromAccountId, contact, msg, target, stored2);
|
|
2449
|
-
const secret =
|
|
2450
|
-
if (secret) throw new
|
|
2958
|
+
const secret = findSecret3(msg.body);
|
|
2959
|
+
if (secret) throw new RelayError3("secret_refused", `the message looks like it carries a credential (${secret})`);
|
|
2451
2960
|
const verdict = judged ? { verdict: "pass" } : await link.gate.ingress(msg, { side: "ingress", account: link.self.handle, peer: contact.peerHandle, peer_id: fromAccountId });
|
|
2452
2961
|
if (verdict.verdict === "refuse") throw gateRefusal(verdict, "ingress");
|
|
2453
2962
|
if (verdict.verdict === "hold") return { id: msg.id, state: "queued", held: { hold_id: verdict.hold_id } };
|
|
@@ -2513,16 +3022,16 @@ var RelayHub = class _RelayHub {
|
|
|
2513
3022
|
const counts = core.xcounts(fromAccountId, msg.from.address, target.remote, dayStart(now));
|
|
2514
3023
|
const retry = (since, ms) => Math.max(1, Math.ceil(((since ?? now) + ms - now) / 1e3));
|
|
2515
3024
|
if (counts.minute >= perMinute)
|
|
2516
|
-
throw new
|
|
3025
|
+
throw new RelayError3("rate_limited", `${perMinute} messages from ${msg.from.address} to ${msg.to} a minute`, retry(counts.minuteAt, 6e4));
|
|
2517
3026
|
if (counts.hour >= perHour)
|
|
2518
|
-
throw new
|
|
2519
|
-
if (counts.day >= perDay) throw new
|
|
3027
|
+
throw new RelayError3("rate_limited", `${perHour} messages from ${msg.from.address} to ${msg.to} an hour`, retry(counts.hourAt, 36e5));
|
|
3028
|
+
if (counts.day >= perDay) throw new RelayError3("rate_limited", `${perDay} messages from ${contact.peerHandle} today`, untilTomorrow(now));
|
|
2520
3029
|
if (core.xthreadCount(target.remote, msg.thread) >= XLIMITS.threadMax)
|
|
2521
|
-
throw new
|
|
3030
|
+
throw new RelayError3("loop_guard", `thread ${msg.thread} reached ${XLIMITS.threadMax} messages across accounts`);
|
|
2522
3031
|
const cap = this.limits.storedBytes;
|
|
2523
|
-
if (this.storedNow() + bytes > cap) throw new
|
|
3032
|
+
if (this.storedNow() + bytes > cap) throw new RelayError3("inbox_full", `the receiving account's relay holds its cap of ${cap} bytes of messages`);
|
|
2524
3033
|
const unread = core.xunread(target);
|
|
2525
|
-
if (unread >=
|
|
3034
|
+
if (unread >= LIMITS3.inboxMax) throw new RelayError3("inbox_full", `${msg.to} has ${unread} unread messages`);
|
|
2526
3035
|
}
|
|
2527
3036
|
/**
|
|
2528
3037
|
* deletes the queued messages of every account that is no longer an active contact allowed to message this one, after the
|
|
@@ -2561,6 +3070,44 @@ var RelayHub = class _RelayHub {
|
|
|
2561
3070
|
}))
|
|
2562
3071
|
);
|
|
2563
3072
|
}
|
|
3073
|
+
// judge
|
|
3074
|
+
/** the judge, made on first use once configured; null when it is not */
|
|
3075
|
+
judgeGate() {
|
|
3076
|
+
if (!this.judgeConfig) return null;
|
|
3077
|
+
if (this.gate) return this.gate;
|
|
3078
|
+
const core = this.relay;
|
|
3079
|
+
const { policy, options } = this.judgeConfig;
|
|
3080
|
+
const gate = new JudgeGate({
|
|
3081
|
+
sql: this.deps.sql,
|
|
3082
|
+
core,
|
|
3083
|
+
now: this.now,
|
|
3084
|
+
env: options.env,
|
|
3085
|
+
client: options.client,
|
|
3086
|
+
limits: options.limits,
|
|
3087
|
+
log: (line) => this.deps.log?.("relay_judge", { account: this.account, line }),
|
|
3088
|
+
wake: (endpoint) => this.wake(endpoint),
|
|
3089
|
+
notice: (endpoint, text) => this.xnotice(endpoint, text),
|
|
3090
|
+
within: (origin, fn) => core.within(origin.machine ?? null, fn)
|
|
3091
|
+
});
|
|
3092
|
+
gate.configure(policy);
|
|
3093
|
+
this.gate = gate;
|
|
3094
|
+
return gate;
|
|
3095
|
+
}
|
|
3096
|
+
judged() {
|
|
3097
|
+
const gate = this.judgeGate();
|
|
3098
|
+
if (!gate) throw new RelayError3("not_found", "this relay has no judge");
|
|
3099
|
+
return gate;
|
|
3100
|
+
}
|
|
3101
|
+
/** a send of this account as it runs unjudged: room for the body, the core send on the sender's machine, then push */
|
|
3102
|
+
deliverOn(machine, from, send, origin) {
|
|
3103
|
+
const core = this.relay;
|
|
3104
|
+
const bytes = this.room(from, send.body);
|
|
3105
|
+
const result = core.within(machine, () => core.send(from, send, origin));
|
|
3106
|
+
if (!result.duplicate) this.stash(bytes);
|
|
3107
|
+
if (!result.duplicate) this.delivered(from, result, send.kind ?? "message");
|
|
3108
|
+
return result;
|
|
3109
|
+
}
|
|
3110
|
+
deliverJudged = async (from, send, { machine, ...origin }) => this.deliverOn(machine ?? null, from, send, origin);
|
|
2564
3111
|
// push and wait
|
|
2565
3112
|
delivered(from, result, kind) {
|
|
2566
3113
|
const core = this.relay;
|
|
@@ -2583,7 +3130,7 @@ var RelayHub = class _RelayHub {
|
|
|
2583
3130
|
const cap = this.limits.storedBytes;
|
|
2584
3131
|
if (this.storedNow() + bytes > cap) {
|
|
2585
3132
|
this.relay.record("refuse", sender, null, { code: "too_large" });
|
|
2586
|
-
throw new
|
|
3133
|
+
throw new RelayError3(
|
|
2587
3134
|
"too_large",
|
|
2588
3135
|
`the account relay holds its cap of ${cap} bytes of message bodies; ack or let messages expire, or release a machine with ${CLI_NAME} machines rm`
|
|
2589
3136
|
);
|
|
@@ -2593,7 +3140,7 @@ var RelayHub = class _RelayHub {
|
|
|
2593
3140
|
/** bytes of message bodies held, and of the messages from other accounts */
|
|
2594
3141
|
storedNow() {
|
|
2595
3142
|
const xinbox = this.deps.crossAccount ? ` + (SELECT COALESCE(SUM(LENGTH(CAST(message AS BLOB))), 0) FROM xinbox)` : "";
|
|
2596
|
-
this.stored ??=
|
|
3143
|
+
this.stored ??= one3(this.deps.sql, `SELECT (SELECT COALESCE(SUM(LENGTH(CAST(body AS BLOB))), 0) FROM messages)${xinbox} AS n`).n;
|
|
2597
3144
|
return this.stored;
|
|
2598
3145
|
}
|
|
2599
3146
|
stash(bytes) {
|
|
@@ -2633,7 +3180,7 @@ var RelayHub = class _RelayHub {
|
|
|
2633
3180
|
}
|
|
2634
3181
|
/** `own` tells each bridge whether a bound machine is its member's own device */
|
|
2635
3182
|
toBridges(machine, params) {
|
|
2636
|
-
const bound =
|
|
3183
|
+
const bound = one3(this.deps.sql, `SELECT actor_id FROM machines WHERE name = ?`, machine);
|
|
2637
3184
|
for (const socket of this.deps.sockets()) {
|
|
2638
3185
|
if (socket.conn.role !== "bridge" || socket.conn.machine === machine) continue;
|
|
2639
3186
|
const own = bound ? { own: (bound.actor_id ?? null) === (socket.conn.grant.actorId ?? null) } : {};
|
|
@@ -2662,8 +3209,8 @@ var RelayHub = class _RelayHub {
|
|
|
2662
3209
|
}
|
|
2663
3210
|
waitMs(socket, value) {
|
|
2664
3211
|
if (value === void 0) return 0;
|
|
2665
|
-
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new
|
|
2666
|
-
return Math.min(value, this.transient.has(socket) ? this.limits.httpWaitS :
|
|
3212
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new RelayError3("invalid_params", "wait_s must be a number of seconds");
|
|
3213
|
+
return Math.min(value, this.transient.has(socket) ? this.limits.httpWaitS : LIMITS3.waitMaxS) * 1e3;
|
|
2667
3214
|
}
|
|
2668
3215
|
wake(endpoint) {
|
|
2669
3216
|
const set = this.waiters.get(endpoint);
|
|
@@ -2719,7 +3266,7 @@ var RelayHub = class _RelayHub {
|
|
|
2719
3266
|
const endpoint = conn.endpoint;
|
|
2720
3267
|
core.disconnect(endpoint);
|
|
2721
3268
|
const machine = core.machineOf(endpoint);
|
|
2722
|
-
if (machine &&
|
|
3269
|
+
if (machine && one3(this.deps.sql, `SELECT kind FROM machines WHERE name = ?`, machine)?.kind === "bridge") {
|
|
2723
3270
|
for (const delivery of core.adoptClaimed(machine, endpoint)) this.route(delivery.seq, delivery.endpoint);
|
|
2724
3271
|
}
|
|
2725
3272
|
this.wake(endpoint);
|
|
@@ -2790,7 +3337,7 @@ var RelayHub = class _RelayHub {
|
|
|
2790
3337
|
const traffic = pairs.flatMap((row) => {
|
|
2791
3338
|
const from = byId.get(row.from_ep);
|
|
2792
3339
|
const to = byId.get(row.to_ep);
|
|
2793
|
-
return from && to && from !== to ? [{ kind: "message", from, to, count: row.n, last_at:
|
|
3340
|
+
return from && to && from !== to ? [{ kind: "message", from, to, count: row.n, last_at: iso2(row.last) }] : [];
|
|
2794
3341
|
});
|
|
2795
3342
|
edges.push(...traffic.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to)));
|
|
2796
3343
|
return { minutes: window, machines: this.machineList(), nodes, edges };
|
|
@@ -2802,10 +3349,10 @@ var RelayHub = class _RelayHub {
|
|
|
2802
3349
|
kind: row.kind,
|
|
2803
3350
|
online: sockets.some((s) => this.boundMachine(s.conn) === row.name),
|
|
2804
3351
|
bridge: sockets.some((s) => s.conn.role === "bridge" && s.conn.machine === row.name),
|
|
2805
|
-
created_at:
|
|
2806
|
-
last_seen:
|
|
3352
|
+
created_at: iso2(row.created_at),
|
|
3353
|
+
last_seen: iso2(row.last_seen)
|
|
2807
3354
|
}));
|
|
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:
|
|
3355
|
+
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) }));
|
|
2809
3356
|
return [...bound, ...revoked];
|
|
2810
3357
|
}
|
|
2811
3358
|
/**
|
|
@@ -2814,15 +3361,15 @@ var RelayHub = class _RelayHub {
|
|
|
2814
3361
|
*/
|
|
2815
3362
|
releaseMachine(caller, name) {
|
|
2816
3363
|
const core = this.relay;
|
|
2817
|
-
if (typeof name !== "string" || !MACHINE_NAME.test(name)) throw new
|
|
2818
|
-
const bound =
|
|
2819
|
-
const tomb = bound ? void 0 :
|
|
2820
|
-
if (!bound && !tomb) throw new
|
|
3364
|
+
if (typeof name !== "string" || !MACHINE_NAME.test(name)) throw new RelayError3("invalid_params", "name is a machine name");
|
|
3365
|
+
const bound = one3(this.deps.sql, `SELECT token_id, actor_id FROM machines WHERE name = ?`, name);
|
|
3366
|
+
const tomb = bound ? void 0 : one3(this.deps.sql, `SELECT actor_id FROM machine_tombstones WHERE name = ?`, name);
|
|
3367
|
+
if (!bound && !tomb) throw new RelayError3("not_found", `no machine ${name}`);
|
|
2821
3368
|
const grant = caller.conn.grant;
|
|
2822
3369
|
const member = !!bound && bound.actor_id !== null && grant.actorId === bound.actor_id;
|
|
2823
3370
|
if (!(bound?.token_id === grant.tokenId || fullOf(grant, (bound ?? tomb).actor_id) || accountAdmin(grant) || member && !this.liveMachines().has(name))) {
|
|
2824
3371
|
core.record("refuse", null, name, { code: "unauthorized", machine: name });
|
|
2825
|
-
throw new
|
|
3372
|
+
throw new RelayError3(
|
|
2826
3373
|
"unauthorized",
|
|
2827
3374
|
`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" : ""}`
|
|
2828
3375
|
);
|
|
@@ -2852,14 +3399,14 @@ var RelayHub = class _RelayHub {
|
|
|
2852
3399
|
);
|
|
2853
3400
|
for (const { name } of idle) {
|
|
2854
3401
|
if (live.has(name)) continue;
|
|
2855
|
-
const queued =
|
|
3402
|
+
const queued = one3(
|
|
2856
3403
|
this.deps.sql,
|
|
2857
3404
|
`SELECT 1 FROM deliveries d JOIN endpoints e ON e.id = d.endpoint WHERE substr(e.remote, 1, ?) = ? AND d.acked_at IS NULL AND d.expires_at > ? LIMIT 1`,
|
|
2858
3405
|
name.length + 1,
|
|
2859
3406
|
`${name}/`,
|
|
2860
3407
|
now
|
|
2861
3408
|
);
|
|
2862
|
-
const asking =
|
|
3409
|
+
const asking = one3(
|
|
2863
3410
|
this.deps.sql,
|
|
2864
3411
|
`SELECT 1 FROM messages m JOIN endpoints e ON e.id = m.from_ep WHERE substr(e.remote, 1, ?) = ? AND m.kind = 'request' AND m.expires_at > ?
|
|
2865
3412
|
AND NOT EXISTS (SELECT 1 FROM messages r WHERE r.reply_to = m.id AND r.kind = 'reply') LIMIT 1`,
|
|
@@ -2885,10 +3432,10 @@ var RelayHub = class _RelayHub {
|
|
|
2885
3432
|
`DELETE FROM machine_tombstones WHERE at <= ? AND NOT EXISTS (SELECT 1 FROM deliveries d JOIN endpoints e ON e.id = d.endpoint
|
|
2886
3433
|
WHERE substr(e.remote, 1, length(machine_tombstones.name) + 1) = machine_tombstones.name || '/' AND d.acked_at IS NULL AND d.expires_at > ?)
|
|
2887
3434
|
RETURNING name`,
|
|
2888
|
-
now - (
|
|
3435
|
+
now - (LIMITS3.ttlMaxS + LIMITS3.purgeAfterS) * 1e3,
|
|
2889
3436
|
now
|
|
2890
3437
|
);
|
|
2891
|
-
const unbound = names.map((row) => row.name).filter((name) => !
|
|
3438
|
+
const unbound = names.map((row) => row.name).filter((name) => !one3(this.deps.sql, `SELECT 1 FROM machines WHERE name = ?`, name));
|
|
2892
3439
|
for (const name of unbound) this.relay.record("machine_forget", null, name, { machine: name, endpoints: this.relay.dropMachine(name, { queue: true }) });
|
|
2893
3440
|
return unbound;
|
|
2894
3441
|
});
|
|
@@ -2906,13 +3453,13 @@ var RelayHub = class _RelayHub {
|
|
|
2906
3453
|
const at = this.deniedGrants().get(DENIED + socket.conn.grant.tokenId);
|
|
2907
3454
|
if (at === void 0 || at <= this.now() - this.limits.deniedS * 1e3) return;
|
|
2908
3455
|
this.kick(socket, "the token was revoked");
|
|
2909
|
-
throw new
|
|
3456
|
+
throw new RelayError3("unauthorized", "the token was revoked");
|
|
2910
3457
|
}
|
|
2911
3458
|
tx(fn) {
|
|
2912
3459
|
return this.deps.transaction ? this.deps.transaction(fn) : fn();
|
|
2913
3460
|
}
|
|
2914
3461
|
state(key) {
|
|
2915
|
-
return
|
|
3462
|
+
return one3(this.deps.sql, `SELECT value FROM hub_state WHERE key = ?`, key)?.value ?? null;
|
|
2916
3463
|
}
|
|
2917
3464
|
setState(key, value) {
|
|
2918
3465
|
this.deps.sql.all(`INSERT OR REPLACE INTO hub_state (key, value) VALUES (?, ?)`, key, value);
|
|
@@ -2927,14 +3474,14 @@ var RelayHub = class _RelayHub {
|
|
|
2927
3474
|
if (hold.side === "ingress") return this.xreceive(hold.from_account, hold.message, true);
|
|
2928
3475
|
const msg = hold.message;
|
|
2929
3476
|
const left = Math.ceil((msg.expires_at - this.now()) / 1e3);
|
|
2930
|
-
if (left < 1) throw new
|
|
3477
|
+
if (left < 1) throw new RelayError3("invalid_params", "the held message expired");
|
|
2931
3478
|
const send = {
|
|
2932
3479
|
to: `${hold.peer}/${msg.to.endsWith("/") ? `${msg.to}me` : msg.to}`,
|
|
2933
3480
|
body: msg.body,
|
|
2934
3481
|
kind: msg.kind,
|
|
2935
3482
|
thread: msg.thread,
|
|
2936
3483
|
...msg.reply_to ? { reply_to: msg.reply_to } : {},
|
|
2937
|
-
ttl_s: Math.min(left,
|
|
3484
|
+
ttl_s: Math.min(left, LIMITS3.ttlMaxS)
|
|
2938
3485
|
};
|
|
2939
3486
|
return this.xsend(hold.sender, send, { tty: msg.from.human, hop: msg.hop, judged: true, clientId: msg.client_id, id: hold.hold_id });
|
|
2940
3487
|
}
|
|
@@ -2948,6 +3495,8 @@ export {
|
|
|
2948
3495
|
GRAPH_MINUTES,
|
|
2949
3496
|
GRAPH_MINUTES_MAX,
|
|
2950
3497
|
HUB_LIMITS,
|
|
3498
|
+
JUDGE_LIMITS,
|
|
3499
|
+
JudgeGate,
|
|
2951
3500
|
MACHINE_NAME,
|
|
2952
3501
|
RelayHub
|
|
2953
3502
|
};
|