@uuaid/pillar 0.2.0-alpha.8 → 2.0.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/HOSTING-LIMITS.md +432 -0
- package/INTERACTION-CONVENTION.md +222 -1
- package/README.md +371 -17
- package/bin/carrier.mjs +7 -1
- package/bin/pillar-ask.mjs +1 -0
- package/bin/pillar-blob.mjs +139 -0
- package/bin/pillar-directory.mjs +232 -0
- package/bin/pillar-escrow.mjs +675 -0
- package/bin/pillar-responder.mjs +47 -1
- package/bin/pillar-seed.mjs +113 -13
- package/bin/pillar.mjs +345 -4
- package/package.json +30 -7
- package/src/blob/blob.mjs +510 -0
- package/src/blob/broker.mjs +129 -0
- package/src/blob/http.mjs +334 -0
- package/src/blob/store.mjs +55 -0
- package/src/escrow/box.mjs +43 -0
- package/src/escrow/escrow-service.mjs +538 -0
- package/src/escrow/rails/evm-rail.mjs +427 -0
- package/src/escrow/rails/index.mjs +41 -0
- package/src/escrow/rails/zilligon-rail.mjs +73 -0
- package/src/escrow/terms.mjs +368 -0
- package/src/escrow/token-registry.mjs +231 -0
- package/src/escrow/verify.mjs +119 -0
- package/src/escrow/zilligon.mjs +266 -0
- package/src/identity/keychain.mjs +27 -10
- package/src/identity/tier.mjs +130 -0
- package/src/identity/trust.mjs +63 -0
- package/src/index.mjs +28 -2
- package/src/mailbox/mailbox.mjs +41 -0
- package/src/net/carrier-client.mjs +93 -14
- package/src/net/carrier-server.mjs +212 -15
- package/src/net/delegation.mjs +209 -0
- package/src/net/envelope.mjs +81 -0
- package/src/net/link-offer.mjs +107 -0
- package/src/net/refusals.mjs +126 -0
- package/src/net/seed-registry.mjs +467 -0
- package/src/net/seed-tier.mjs +185 -0
- package/src/node-check.mjs +32 -0
- package/src/observability.mjs +141 -0
- package/src/pillar.mjs +206 -17
- package/src/vda/core.mjs +290 -0
- package/src/vda/directory.mjs +87 -0
- package/src/vda/instruments.mjs +246 -0
- package/src/vda/shims.mjs +77 -0
package/src/index.mjs
CHANGED
|
@@ -1,18 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @uuaid/pillar — v0.2.0-alpha.0
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Every agent runs its own PDP pillar, discovers peers
|
|
5
5
|
* via a Kademlia DHT, exchanges cryptographically signed messages over libp2p.
|
|
6
6
|
*
|
|
7
7
|
* Entry point. Not intended for import in browsers today — browser build
|
|
8
8
|
* lives at @uuaid/pillar-browser (later).
|
|
9
9
|
*/
|
|
10
|
-
export { Keychain } from "./identity/keychain.mjs";
|
|
10
|
+
export { Keychain, localIdFromKey } from "./identity/keychain.mjs";
|
|
11
11
|
export { jcs } from "./identity/jcs.mjs";
|
|
12
12
|
export { seal, open, decrypt, envelopeSha, keychainSeed, ENVELOPE_VERSION, E2E_ALG } from "./net/envelope.mjs";
|
|
13
|
+
export { DELEGATION_VERSION, verifyDelegationDoc, delegationCoversKind } from "./net/delegation.mjs";
|
|
13
14
|
export { sealPayload, openPayload, edPubToX25519, edSeedToX25519 } from "./crypto/e2e.mjs";
|
|
14
15
|
export { Mailbox } from "./mailbox/mailbox.mjs";
|
|
15
16
|
export { Pillar } from "./pillar.mjs";
|
|
16
17
|
export { createTransport, PILLAR_PROTOCOL, uuaidToDhtKey, peerIdFromEd25519Hex } from "./net/transport.mjs";
|
|
17
18
|
export { CarrierClient } from "./net/carrier-client.mjs";
|
|
18
19
|
export { CarrierServer } from "./net/carrier-server.mjs";
|
|
20
|
+
// Seeds: the ladder that turns sustained relay service into earned tier
|
|
21
|
+
// grants + priority delivery, and the registry that observes and publishes it.
|
|
22
|
+
export {
|
|
23
|
+
SEED_TIERS, SEED_TIER_NAMES, GRANT_TTL_DAYS, DEMOTE_AFTER_FAILURES,
|
|
24
|
+
seedTier, seedTierAtLeast, uptimeOf, evaluateSeedTier,
|
|
25
|
+
} from "./net/seed-tier.mjs";
|
|
26
|
+
export { SeedRegistry, checkSeedUrl, verifySeedInfo, PROBE_WINDOW } from "./net/seed-registry.mjs";
|
|
27
|
+
// Blob transport (client/core half): oversized payloads ride an ephemeral
|
|
28
|
+
// encrypted object store; only a <=4KiB signed ref travels in the envelope.
|
|
29
|
+
export {
|
|
30
|
+
BLOB_REF_V, BLOB_KIND, MAX_BLOB_REF_BYTES, MAX_INLINE_BODY_BYTES,
|
|
31
|
+
MAX_BLOB_OBJECT_BYTES, MAX_BLOB_PLAINTEXT_BYTES,
|
|
32
|
+
DEFAULT_BLOB_TTL_MS, DEFAULT_DIRECT_THRESHOLD_BYTES,
|
|
33
|
+
BlobError, BlobSizeError, BlobExpiryError, BlobNotFoundError, BlobUploadError,
|
|
34
|
+
BlobFetchError, BlobIntegrityError, BlobRefError, BlobBrokerError, BlobSenderKeyError,
|
|
35
|
+
blobAad, encryptBlob, decryptBlob, contentHash, mkObjectName,
|
|
36
|
+
signBlobRef, verifyBlobRefSig, blobRefMetadata, blobRefSecret, refSerializedBytes,
|
|
37
|
+
makeBlobRef, validateBlobRef, envelopeBodyBytes, preflightBodyBytes, chooseTransport,
|
|
38
|
+
prepareBlobSend, fetchAndDecryptBlob, prepareBrokerBlobSend, fetchBrokerBlob,
|
|
39
|
+
SELF_CERTIFIED_KEY_ID, isSelfCertifiedUuaid, selfCertifiedUuaidFor, senderSelfCertified,
|
|
40
|
+
} from "./blob/blob.mjs";
|
|
41
|
+
export { MemoryBlobStore, FsBlobStore } from "./blob/store.mjs";
|
|
42
|
+
export { PillarBlobHttpClient, PILLAR_BLOB_AUDIENCE } from "./blob/http.mjs";
|
|
43
|
+
// Refusals in agent language: carrier reason -> one-line fix, `<code>: <fix>`.
|
|
44
|
+
export { REFUSAL_FIXES, carrierFix, withCarrierFix, refusalLine, refusalFields } from "./net/refusals.mjs";
|
package/src/mailbox/mailbox.mjs
CHANGED
|
@@ -69,6 +69,12 @@ export class Mailbox {
|
|
|
69
69
|
this.db.pragma("journal_mode = WAL");
|
|
70
70
|
this.db.pragma("synchronous = NORMAL");
|
|
71
71
|
this.db.exec(SCHEMA);
|
|
72
|
+
// v2 migration: quarantine marker (trust-policy gate). Nullable text =
|
|
73
|
+
// reason; null = normal mail. Chain hashes are unaffected (metadata only).
|
|
74
|
+
const cols = this.db.pragma("table_info(inbox)").map(c => c.name);
|
|
75
|
+
if (!cols.includes("quarantined")) {
|
|
76
|
+
this.db.exec("alter table inbox add column quarantined text");
|
|
77
|
+
}
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
close() { try { this.db.close(); } catch (_e) {} }
|
|
@@ -140,6 +146,21 @@ export class Mailbox {
|
|
|
140
146
|
})();
|
|
141
147
|
}
|
|
142
148
|
|
|
149
|
+
/** True if we sent this envelope (trust gate: is a receipt answering OUR send?). */
|
|
150
|
+
hasOutbound(envelopeId) {
|
|
151
|
+
return !!this.db.prepare("select 1 from outbox where envelope_id = ?").get(envelopeId);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Full outbox row by envelope id (VDA message-delivery instrument). */
|
|
155
|
+
getOutbound(envelopeId) {
|
|
156
|
+
return this.db.prepare("select * from outbox where envelope_id = ?").get(envelopeId) ?? null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Full inbox row by envelope id (VDA message-delivery instrument). */
|
|
160
|
+
getInbound(envelopeId) {
|
|
161
|
+
return this.db.prepare("select * from inbox where envelope_id = ?").get(envelopeId) ?? null;
|
|
162
|
+
}
|
|
163
|
+
|
|
143
164
|
markOutboundDelivered(envelopeId, ackSha256) {
|
|
144
165
|
this.db.prepare("update outbox set status = 'delivered', delivered_at = ?, ack_sha256 = ? where envelope_id = ?")
|
|
145
166
|
.run(new Date().toISOString(), ackSha256 ?? null, envelopeId);
|
|
@@ -155,6 +176,26 @@ export class Mailbox {
|
|
|
155
176
|
return this.db.prepare("select * from inbox where seq > ? order by seq asc limit ?").all(seq, limit);
|
|
156
177
|
}
|
|
157
178
|
|
|
179
|
+
/** Envelopes never shown to the operator (read_at null, not quarantined). Oldest first. */
|
|
180
|
+
inboxUnread(limit = 100) {
|
|
181
|
+
return this.db.prepare("select * from inbox where read_at is null and quarantined is null order by seq asc limit ?").all(limit);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Trust-gated envelopes: stored as evidence, never surfaced to handlers. */
|
|
185
|
+
inboxQuarantined(limit = 100) {
|
|
186
|
+
return this.db.prepare("select * from inbox where quarantined is not null order by seq asc limit ?").all(limit);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
markQuarantined(envelopeId, reason) {
|
|
190
|
+
this.db.prepare("update inbox set quarantined = ? where envelope_id = ?")
|
|
191
|
+
.run(String(reason).slice(0, 100), envelopeId);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
markInboxRead(envelopeId) {
|
|
195
|
+
this.db.prepare("update inbox set read_at = ? where envelope_id = ? and read_at is null")
|
|
196
|
+
.run(new Date().toISOString(), envelopeId);
|
|
197
|
+
}
|
|
198
|
+
|
|
158
199
|
/**
|
|
159
200
|
* Recompute the outbox chain from scratch and compare to stored chain_hash column.
|
|
160
201
|
* Returns { ok, badSeq? }. Used by adversarial tests + operator-driven audits.
|
|
@@ -5,17 +5,51 @@
|
|
|
5
5
|
* This is the universal transport: works from sandboxes, NATed laptops,
|
|
6
6
|
* serverless, anywhere. No listening socket required.
|
|
7
7
|
*/
|
|
8
|
+
import { envelopeBodyBytes, preflightBodyBytes } from "../blob/blob.mjs";
|
|
9
|
+
import { seedTier } from "./seed-tier.mjs";
|
|
10
|
+
import { carrierFix, REFUSAL_FIXES } from "./refusals.mjs";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Rank carriers by how proven they are, best first. Unknown carriers rank as
|
|
14
|
+
* `probation` — the primary carrier from a directory has no seed tier and
|
|
15
|
+
* should not be assumed better than a seed that earned one, nor worse.
|
|
16
|
+
* Array.prototype.sort is stable, so a client given no tier information at
|
|
17
|
+
* all keeps its list in exactly the order it was constructed with.
|
|
18
|
+
*/
|
|
19
|
+
function byTier(tiers) {
|
|
20
|
+
return (a, b) => seedTier(tiers[a] ?? "probation").listRank - seedTier(tiers[b] ?? "probation").listRank;
|
|
21
|
+
}
|
|
8
22
|
|
|
9
23
|
export class CarrierClient {
|
|
10
24
|
/**
|
|
11
25
|
* @param {{keychain: import('../identity/keychain.mjs').Keychain, carriers: string[]}} opts
|
|
12
26
|
* carriers: base URLs, e.g. ["https://pillar.uuaid.org"]
|
|
13
27
|
*/
|
|
14
|
-
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} opts
|
|
30
|
+
* carrierTiers optional { "<url>": "<seed tier>" } from a directory's
|
|
31
|
+
* seeds[]. Drives which carrier's receipt is authoritative
|
|
32
|
+
* and which one is polled first — see `deliver`.
|
|
33
|
+
*/
|
|
34
|
+
constructor({ keychain, carriers, tierGrant = null, carrierTiers = {} }) {
|
|
15
35
|
if (!keychain?._identity) throw new Error("CarrierClient needs a loaded keychain");
|
|
16
36
|
if (!carriers?.length) throw new Error("CarrierClient needs at least one carrier URL");
|
|
17
37
|
this.keychain = keychain;
|
|
18
|
-
|
|
38
|
+
// Normalise tier keys the same way as carrier URLs so a trailing slash on
|
|
39
|
+
// one side does not silently drop a seed to `probation`.
|
|
40
|
+
this.carrierTiers = Object.fromEntries(
|
|
41
|
+
Object.entries(carrierTiers).map(([u, t]) => [String(u).replace(/\/+$/, ""), t]),
|
|
42
|
+
);
|
|
43
|
+
this.carriers = carriers.map(u => u.replace(/\/+$/, "")).sort(byTier(this.carrierTiers));
|
|
44
|
+
// Tier label for the local preflight: a signed grant raises it; default
|
|
45
|
+
// stays community (matches the carrier's unauthenticated tier).
|
|
46
|
+
this.tier = tierGrant?.tier ?? "community";
|
|
47
|
+
this.maxBodyBytes = Number.isInteger(tierGrant?.maxBodyBytes) ? tierGrant.maxBodyBytes : null;
|
|
48
|
+
// Optional signed tier grant (tier.mjs) — sent as a header on deliver so
|
|
49
|
+
// qualifying senders get raised budgets at carriers that trust the issuer.
|
|
50
|
+
this.tierGrantB64 = tierGrant
|
|
51
|
+
? Buffer.from(JSON.stringify(tierGrant), "utf-8").toString("base64")
|
|
52
|
+
: null;
|
|
19
53
|
this._stopPolling = false;
|
|
20
54
|
}
|
|
21
55
|
|
|
@@ -30,25 +64,56 @@ export class CarrierClient {
|
|
|
30
64
|
* envelope.id and re-fires no handlers.
|
|
31
65
|
*
|
|
32
66
|
* Resolves once every carrier has answered. Succeeds if at least one
|
|
33
|
-
* accepted; throws if every carrier rejects. Returns the
|
|
34
|
-
* accepting
|
|
35
|
-
* `all` with every carrier's outcome ({ carrier, ok,
|
|
36
|
-
* duplicate?, error? }).
|
|
67
|
+
* accepted; throws if every carrier rejects. Returns the receipt from the
|
|
68
|
+
* most PROVEN accepting carrier as the primary ({ carrier, seq, sha,
|
|
69
|
+
* duplicate }) plus `all` with every carrier's outcome ({ carrier, ok,
|
|
70
|
+
* seq?, sha?, duplicate?, error?, status?, reason?, fix? }).
|
|
71
|
+
*
|
|
72
|
+
* When every carrier refuses, the thrown error carries `code` (the most
|
|
73
|
+
* proven carrier's `reason`, else carrier-unreachable / carrier-bad-response),
|
|
74
|
+
* `fix`, `status`, `carrier` and `refusals` (= `all`).
|
|
75
|
+
*
|
|
76
|
+
* Which receipt is primary matters because callers quote its seq back as a
|
|
77
|
+
* cursor and cite its carrier in receipts: it should name a seed that will
|
|
78
|
+
* still be there. With no tier information every carrier ranks equally and
|
|
79
|
+
* the first-in-list receipt wins, exactly as before.
|
|
37
80
|
*/
|
|
38
81
|
async deliver(envelope, { timeoutMs = 10000 } = {}) {
|
|
82
|
+
// Preflight against the carrier limits BEFORE any network call: the
|
|
83
|
+
// tier inline limit first, then the 2 MiB direct hard cap — no tier
|
|
84
|
+
// grant can raise the hard cap. Typed failure, not a carrier 413.
|
|
85
|
+
const pf = preflightBodyBytes(envelopeBodyBytes(envelope), { tier: this.tier, maxBodyBytes: this.maxBodyBytes });
|
|
86
|
+
if (!pf.ok) {
|
|
87
|
+
const e = new Error(`carrier preflight: body ${pf.bytes}B exceeds ${pf.source} limit ${pf.limit}B — use the blob transport`);
|
|
88
|
+
e.code = pf.source === "direct-cap" ? "blob-too-large" : "over-tier-limit";
|
|
89
|
+
e.limit = pf.limit; e.source = pf.source;
|
|
90
|
+
e.fix = REFUSAL_FIXES["over-tier-limit"];
|
|
91
|
+
throw e;
|
|
92
|
+
}
|
|
39
93
|
const attempt = async (base) => {
|
|
40
94
|
try {
|
|
41
95
|
const res = await fetch(`${base}/v1/envelopes`, {
|
|
42
96
|
method: "POST",
|
|
43
|
-
headers: {
|
|
97
|
+
headers: {
|
|
98
|
+
"content-type": "application/json",
|
|
99
|
+
...(this.tierGrantB64 ? { "x-pillar-tier-grant": this.tierGrantB64 } : {}),
|
|
100
|
+
},
|
|
44
101
|
body: JSON.stringify(envelope),
|
|
45
102
|
signal: AbortSignal.timeout(timeoutMs),
|
|
46
103
|
});
|
|
47
|
-
const
|
|
104
|
+
const raw = await res.text();
|
|
105
|
+
let body = {};
|
|
106
|
+
try { body = raw ? JSON.parse(raw) : {}; } catch (_error) {}
|
|
48
107
|
if (res.status === 202 && body.accepted) {
|
|
49
108
|
return { carrier: base, ok: true, seq: body.seq, sha: body.sha, duplicate: !!body.duplicate };
|
|
50
109
|
}
|
|
51
|
-
|
|
110
|
+
const reason = typeof body.reason === "string" && body.reason ? body.reason
|
|
111
|
+
: typeof body.error === "string" && body.error ? body.error : null;
|
|
112
|
+
return {
|
|
113
|
+
carrier: base, ok: false, status: res.status,
|
|
114
|
+
error: `${res.status} ${body.reason ?? body.error ?? (raw ? "non-json-response" : "empty-response")}`,
|
|
115
|
+
...(reason ? { reason, fix: (typeof body.fix === "string" && body.fix) || carrierFix(reason) } : {}),
|
|
116
|
+
};
|
|
52
117
|
} catch (e) {
|
|
53
118
|
return { carrier: base, ok: false, error: e.message };
|
|
54
119
|
}
|
|
@@ -57,9 +122,17 @@ export class CarrierClient {
|
|
|
57
122
|
rs.map(r => r.status === "fulfilled" ? r.value : { carrier: "?", ok: false, error: String(r.reason) }));
|
|
58
123
|
const accepted = all.filter(r => r.ok);
|
|
59
124
|
if (!accepted.length) {
|
|
60
|
-
|
|
125
|
+
const e = new Error(`carrier deliver failed on all carriers: ${all.map(r => `${r.carrier}: ${r.error}`).join("; ")}`);
|
|
126
|
+
// `all` is in rank order, so this is the most proven carrier's answer.
|
|
127
|
+
const primary = all.find(r => r.reason) ?? all.find(r => r.status) ?? null;
|
|
128
|
+
e.code = primary?.reason ?? (primary ? "carrier-bad-response" : "carrier-unreachable");
|
|
129
|
+
e.fix = primary?.fix ?? carrierFix(e.code);
|
|
130
|
+
if (primary) { e.status = primary.status; e.carrier = primary.carrier; }
|
|
131
|
+
e.refusals = all;
|
|
132
|
+
throw e;
|
|
61
133
|
}
|
|
62
|
-
const
|
|
134
|
+
const ranked = accepted.slice().sort((a, b) => byTier(this.carrierTiers)(a.carrier, b.carrier));
|
|
135
|
+
const { carrier, seq, sha, duplicate } = ranked[0];
|
|
63
136
|
return { carrier, seq, sha, duplicate, all };
|
|
64
137
|
}
|
|
65
138
|
|
|
@@ -81,7 +154,11 @@ export class CarrierClient {
|
|
|
81
154
|
});
|
|
82
155
|
if (res.status !== 200) {
|
|
83
156
|
const body = await res.json().catch(() => ({}));
|
|
84
|
-
|
|
157
|
+
const e = new Error(`inbox fetch ${base}: ${res.status} ${body.error ?? ""}`);
|
|
158
|
+
e.code = typeof body.error === "string" && body.error ? body.error : "carrier-bad-response";
|
|
159
|
+
e.fix = (typeof body.fix === "string" && body.fix) || carrierFix(e.code);
|
|
160
|
+
e.status = res.status; e.carrier = base;
|
|
161
|
+
throw e;
|
|
85
162
|
}
|
|
86
163
|
return res.json(); // { envelopes: [{seq, envelope}], now }
|
|
87
164
|
}
|
|
@@ -89,6 +166,8 @@ export class CarrierClient {
|
|
|
89
166
|
/**
|
|
90
167
|
* Long-poll every carrier in parallel forever, invoking onEnvelope for each
|
|
91
168
|
* new envelope. Tracks per-carrier cursor. Returns a stop() function.
|
|
169
|
+
* onError(error, { carrier }) — the carrier context is passed so callers
|
|
170
|
+
* can attribute failures per carrier in metrics.
|
|
92
171
|
*/
|
|
93
172
|
startPolling({ onEnvelope, cursors = {}, waitS = 25, onError }) {
|
|
94
173
|
this._stopPolling = false;
|
|
@@ -100,10 +179,10 @@ export class CarrierClient {
|
|
|
100
179
|
for (const { seq, envelope } of r.envelopes) {
|
|
101
180
|
state.cursors[base] = Math.max(state.cursors[base] ?? 0, seq);
|
|
102
181
|
try { await onEnvelope(envelope, { carrier: base, seq }); }
|
|
103
|
-
catch (e) { onError?.(new Error(`onEnvelope threw: ${e.message}`)); }
|
|
182
|
+
catch (e) { onError?.(new Error(`onEnvelope threw: ${e.message}`), { carrier: base }); }
|
|
104
183
|
}
|
|
105
184
|
} catch (e) {
|
|
106
|
-
onError?.(e);
|
|
185
|
+
onError?.(e, { carrier: base });
|
|
107
186
|
// Back off on error so a dead carrier doesn't spin the CPU.
|
|
108
187
|
await new Promise(r2 => setTimeout(r2, 2000));
|
|
109
188
|
}
|
|
@@ -18,14 +18,33 @@
|
|
|
18
18
|
* x-pillar-pubkey: <ed25519 pubkey hex>
|
|
19
19
|
* x-pillar-ts: <unix ms> (rejected if clock skew > 5 min)
|
|
20
20
|
* x-pillar-sig: hex(sign("GET /v1/inbox/<uuaid>?since=<seq>" + "\n" + ts))
|
|
21
|
+
* x-pillar-delegation: (optional) JSON delegation document letting a
|
|
22
|
+
* delegate key read <uuaid>'s inbox; must be signed by <uuaid> and
|
|
23
|
+
* cover kind "inbox" (or "*")
|
|
21
24
|
* - carrier derives UUAID from pubkey; must equal <uuaid> (self-authenticating,
|
|
22
|
-
* zero registration)
|
|
25
|
+
* zero registration) unless a valid inbox delegation is presented
|
|
23
26
|
* - long-polls up to `wait` seconds (max 30)
|
|
24
27
|
* -> 200 { envelopes: [{seq, envelope}], now }
|
|
25
28
|
*
|
|
26
29
|
* GET /v1/health
|
|
27
30
|
* -> 200 { ok, uptime, stored }
|
|
28
31
|
*
|
|
32
|
+
* GET /v1/seed-info
|
|
33
|
+
* -> 200 { v, url, uuaid, publicKey, startedAt, firstSeen, stats, now, sig }
|
|
34
|
+
* Present only when the server was constructed with a keychain + publicUrl
|
|
35
|
+
* (i.e. it is running as a public seed, not a private relay). The
|
|
36
|
+
* signature binds this URL to the operator's key, which is what the seed
|
|
37
|
+
* registry checks at enrollment before listing the URL as a seed.
|
|
38
|
+
*
|
|
39
|
+
* PRIORITY ADMISSION
|
|
40
|
+
*
|
|
41
|
+
* Load shedding is not uniform. A sender presenting a valid tier grant with
|
|
42
|
+
* `priority: true` — issued to durable seed operators by the seed registry —
|
|
43
|
+
* is admitted from reserved headroom that community traffic cannot touch:
|
|
44
|
+
* the last slice of the store, its own per-IP budget, and reserved long-poll
|
|
45
|
+
* slots. Under no load the two lanes are indistinguishable; the privilege
|
|
46
|
+
* only becomes visible at the moment the carrier starts saying no.
|
|
47
|
+
*
|
|
29
48
|
* Durability: SQLite (WAL). Envelopes are retained until TTL (default 14 days).
|
|
30
49
|
* The carrier CANNOT read payloads it relays beyond what the envelope exposes,
|
|
31
50
|
* and CANNOT forge envelopes (no keys). It can only: deliver, delay, or drop —
|
|
@@ -36,10 +55,16 @@ import { createHash } from "node:crypto";
|
|
|
36
55
|
import Database from "better-sqlite3";
|
|
37
56
|
import { open as openEnvelope } from "./envelope.mjs";
|
|
38
57
|
import { Keychain } from "../identity/keychain.mjs";
|
|
58
|
+
import { jcs } from "../identity/jcs.mjs";
|
|
59
|
+
import { verifyTierGrant } from "../identity/tier.mjs";
|
|
60
|
+
import { verifyDelegationDoc, delegationCoversKind } from "./delegation.mjs";
|
|
61
|
+
import { withCarrierFix } from "./refusals.mjs"; // additive `fix` on refusals; reason/error/status unchanged
|
|
39
62
|
|
|
40
63
|
const MAX_WAIT_S = 30;
|
|
41
|
-
const MAX_BODY = 512 * 1024; // 512 KiB per envelope
|
|
64
|
+
const MAX_BODY = 512 * 1024; // 512 KiB per envelope (community tier)
|
|
65
|
+
const MAX_BODY_STREAM = 2 * 1024 * 1024 + 8192; // absolute read cap: max tier body + grant/header slack
|
|
42
66
|
const MAX_SKEW_MS = 5 * 60 * 1000;
|
|
67
|
+
const MAX_DELEGATION_HEADER = 8192; // x-pillar-delegation size cap (bytes)
|
|
43
68
|
// Hardening (RedTeam F2/F3):
|
|
44
69
|
const MAX_ENVELOPE_AGE_MS = 24 * 3600_000; // F3: ingest freshness — reject skewed createdAt
|
|
45
70
|
const RATE_WINDOW_MS = 60_000; // F2: sliding window for all rate limits
|
|
@@ -49,9 +74,16 @@ const MAX_INVALID_PER_MIN = 60; // F2: per client-IP budget for rejec
|
|
|
49
74
|
const MAX_CONCURRENT_WAITS = 64; // F2: inbox long-poll concurrency cap
|
|
50
75
|
const MAX_DB_BYTES = 256 * 1024 * 1024; // F2: SQLite size backpressure (503 when full)
|
|
51
76
|
const RATE_MAP_HARD_CAP = 10_000; // F2: bound memory under key spray
|
|
77
|
+
// Priority lanes. Each is a RESERVE carved out of an existing cap, never an
|
|
78
|
+
// increase of it: community traffic sheds earlier so that priority traffic
|
|
79
|
+
// still fits inside the same absolute ceiling. A carrier with no trusted tier
|
|
80
|
+
// issuers configured never resolves a priority grant, so these are inert.
|
|
81
|
+
const COMMUNITY_DB_BYTES = Math.floor(MAX_DB_BYTES * 0.85); // last 15% of the store is reserved
|
|
82
|
+
const COMMUNITY_CONCURRENT_WAITS = 48; // of MAX_CONCURRENT_WAITS
|
|
83
|
+
const MAX_PRIORITY_GOOD_PER_MIN = 600; // priority senders bill to their own per-IP bucket
|
|
52
84
|
|
|
53
85
|
export class CarrierServer {
|
|
54
|
-
constructor({ dbPath = "carrier.db", ttlDays = 14, cryptoInventory } = {}) {
|
|
86
|
+
constructor({ dbPath = "carrier.db", ttlDays = 14, cryptoInventory, trustedTierIssuers = [], keychain = null, publicUrl = null } = {}) {
|
|
55
87
|
this.db = new Database(dbPath);
|
|
56
88
|
this.db.pragma("journal_mode = WAL");
|
|
57
89
|
this.db.exec(`
|
|
@@ -65,7 +97,16 @@ export class CarrierServer {
|
|
|
65
97
|
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
66
98
|
);
|
|
67
99
|
create index if not exists idx_envelopes_recipient on envelopes(recipient, seq);
|
|
100
|
+
-- Lifetime service counters. Kept OUTSIDE the envelopes table because
|
|
101
|
+
-- vacuum() deletes envelopes at TTL, and a seed's service history must
|
|
102
|
+
-- survive its own garbage collection.
|
|
103
|
+
create table if not exists seed_stats (
|
|
104
|
+
k text primary key,
|
|
105
|
+
v text not null
|
|
106
|
+
);
|
|
68
107
|
`);
|
|
108
|
+
this.db.prepare("insert or ignore into seed_stats (k, v) values ('first_seen', ?)")
|
|
109
|
+
.run(new Date().toISOString());
|
|
69
110
|
this.ttlDays = ttlDays;
|
|
70
111
|
this.cryptoInventory = cryptoInventory ?? { ed25519: "active" };
|
|
71
112
|
this.startedAt = Date.now();
|
|
@@ -73,6 +114,94 @@ export class CarrierServer {
|
|
|
73
114
|
this.rateMap = new Map(); // F2: "<kind>:<key>" -> [ms timestamps]
|
|
74
115
|
this.activeWaits = 0; // F2: long-polls currently sleeping
|
|
75
116
|
this._closed = false;
|
|
117
|
+
// Tier grants: issuer pubkeys (hex) this carrier honors. Empty = every
|
|
118
|
+
// sender gets community-tier budgets (pre-tier behavior, default).
|
|
119
|
+
this.trustedTierIssuers = trustedTierIssuers;
|
|
120
|
+
this._grantCache = new Map(); // sender uuaid -> { grant, cachedAt }
|
|
121
|
+
// Seed identity. Optional: a private relay runs without one and simply
|
|
122
|
+
// does not expose /v1/seed-info.
|
|
123
|
+
this.keychain = keychain;
|
|
124
|
+
this.publicUrl = publicUrl ? String(publicUrl).replace(/\/+$/, "") : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Read a lifetime counter. */
|
|
128
|
+
_stat(k, fallback = 0) {
|
|
129
|
+
const row = this.db.prepare("select v from seed_stats where k = ?").get(k);
|
|
130
|
+
if (row === undefined) return fallback;
|
|
131
|
+
const n = Number(row.v);
|
|
132
|
+
return Number.isFinite(n) ? n : row.v;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Bump a lifetime counter by n (creating it at 0 first). */
|
|
136
|
+
_bump(k, n = 1) {
|
|
137
|
+
this.db.prepare(
|
|
138
|
+
"insert into seed_stats (k, v) values (?, ?) on conflict(k) do update set v = cast(cast(v as integer) + ? as text)"
|
|
139
|
+
).run(k, String(n), n);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The signed service claim served at /v1/seed-info.
|
|
144
|
+
*
|
|
145
|
+
* These counters are the seed's OWN account of its work and are therefore
|
|
146
|
+
* not evidence: an operator can inflate them freely. The registry records
|
|
147
|
+
* them for display and promotes strictly on what it observes itself (see
|
|
148
|
+
* net/seed-tier.mjs). What the signature does prove is the thing enrollment
|
|
149
|
+
* actually needs — that whoever controls this URL also controls this key.
|
|
150
|
+
*/
|
|
151
|
+
seedInfo() {
|
|
152
|
+
if (!this.keychain?._identity || !this.publicUrl) return null;
|
|
153
|
+
const doc = {
|
|
154
|
+
v: 1,
|
|
155
|
+
url: this.publicUrl,
|
|
156
|
+
uuaid: this.keychain._identity.uuaid,
|
|
157
|
+
publicKey: this.keychain._identity.publicKeyHex,
|
|
158
|
+
startedAt: new Date(this.startedAt).toISOString(),
|
|
159
|
+
firstSeen: this.db.prepare("select v from seed_stats where k = 'first_seen'").get()?.v ?? null,
|
|
160
|
+
stats: {
|
|
161
|
+
relayed: this._stat("relayed"),
|
|
162
|
+
served: this._stat("served"),
|
|
163
|
+
stored: this.db.prepare("select count(*) as n from envelopes").get().n,
|
|
164
|
+
},
|
|
165
|
+
now: new Date().toISOString(),
|
|
166
|
+
};
|
|
167
|
+
doc.sig = this.keychain.sign(Buffer.from(jcs(doc), "utf-8")).toString("hex");
|
|
168
|
+
return doc;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Tier resolution for a POST: verify an optional x-pillar-tier-grant header
|
|
173
|
+
* (base64 JSON, issued by makeTierGrant). A valid grant FOR THE CLAIMED
|
|
174
|
+
* SENDER raises that sender's rate/size budget. Invalid/foreign grants are
|
|
175
|
+
* ignored (fall back to community) — never a hard reject, so a bad grant
|
|
176
|
+
* cannot break plain delivery.
|
|
177
|
+
*/
|
|
178
|
+
_resolveTier(req, sender) {
|
|
179
|
+
const fallback = { tier: "community", envelopesPerMin: MAX_ENVELOPES_PER_MIN, maxBodyBytes: MAX_BODY, priority: false };
|
|
180
|
+
if (!this.trustedTierIssuers.length) return fallback;
|
|
181
|
+
const hdr = req?.headers?.["x-pillar-tier-grant"];
|
|
182
|
+
if (!hdr) return fallback;
|
|
183
|
+
let grant;
|
|
184
|
+
try { grant = JSON.parse(Buffer.from(String(hdr), "base64").toString("utf-8")); }
|
|
185
|
+
catch (_e) { return fallback; }
|
|
186
|
+
if (grant?.uuaid !== sender) return fallback; // grant must name the claimed sender
|
|
187
|
+
const cached = this._grantCache.get(sender);
|
|
188
|
+
if (cached && cached.sig === grant.sig && Date.now() - cached.at < 60_000) return cached.resolved;
|
|
189
|
+
const v = verifyTierGrant(grant, this.trustedTierIssuers);
|
|
190
|
+
const resolved = v.ok
|
|
191
|
+
? {
|
|
192
|
+
tier: grant.tier,
|
|
193
|
+
envelopesPerMin: grant.envelopesPerMin,
|
|
194
|
+
maxBodyBytes: grant.maxBodyBytes,
|
|
195
|
+
// Only a grant the issuer actually signed as priority opens the
|
|
196
|
+
// reserved lane. verifyTierGrant has already rejected any grant
|
|
197
|
+
// whose `priority` is present but not exactly true.
|
|
198
|
+
priority: grant.priority === true,
|
|
199
|
+
seedTier: grant.seedTier ?? null,
|
|
200
|
+
}
|
|
201
|
+
: fallback;
|
|
202
|
+
if (this._grantCache.size > 4096) this._grantCache.clear(); // bounded
|
|
203
|
+
this._grantCache.set(sender, { sig: grant.sig, at: Date.now(), resolved });
|
|
204
|
+
return resolved;
|
|
76
205
|
}
|
|
77
206
|
|
|
78
207
|
/** F2: best-effort client identity, nginx-aware (XFF set by proxy). */
|
|
@@ -117,7 +246,7 @@ export class CarrierServer {
|
|
|
117
246
|
// No-op on a dead socket: close() destroys connections, and a long-poll
|
|
118
247
|
// handler whose timer fires afterwards must not throw on write.
|
|
119
248
|
if (res.destroyed || res.writableEnded) return;
|
|
120
|
-
const body = JSON.stringify(obj);
|
|
249
|
+
const body = JSON.stringify(withCarrierFix(code, obj));
|
|
121
250
|
res.writeHead(code, { "content-type": "application/json", "content-length": Buffer.byteLength(body) });
|
|
122
251
|
res.end(body);
|
|
123
252
|
}
|
|
@@ -127,7 +256,7 @@ export class CarrierServer {
|
|
|
127
256
|
let size = 0; const chunks = [];
|
|
128
257
|
req.on("data", (c) => {
|
|
129
258
|
size += c.length;
|
|
130
|
-
if (size >
|
|
259
|
+
if (size > MAX_BODY_STREAM) { reject(new Error("body too large")); req.destroy(); return; }
|
|
131
260
|
chunks.push(c);
|
|
132
261
|
});
|
|
133
262
|
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
@@ -135,25 +264,35 @@ export class CarrierServer {
|
|
|
135
264
|
});
|
|
136
265
|
}
|
|
137
266
|
|
|
138
|
-
_handlePost(envelope, ip = "unknown") {
|
|
267
|
+
_handlePost(envelope, ip = "unknown", req = null, rawBytes = 0) {
|
|
139
268
|
const verdict = openEnvelope(envelope, { cryptoInventory: this.cryptoInventory });
|
|
140
269
|
if (!verdict.ok) {
|
|
141
270
|
// F2: cap rejected sends per client (flood the verifier → 429).
|
|
142
271
|
if (!this._rateAllow(`bad:${ip}`, MAX_INVALID_PER_MIN)) {
|
|
143
272
|
return { code: 429, body: { accepted: false, reason: "rate-limited" } };
|
|
144
273
|
}
|
|
145
|
-
return { code: 400, body: { accepted: false, reason: verdict.reason } };
|
|
274
|
+
return { code: verdict.code ?? 400, body: { accepted: false, reason: verdict.reason } };
|
|
146
275
|
}
|
|
147
276
|
if (typeof envelope.recipient !== "string" || !envelope.recipient.startsWith("uuaid:")) {
|
|
148
277
|
return { code: 400, body: { accepted: false, reason: "bad-recipient" } };
|
|
149
278
|
}
|
|
279
|
+
// Tier: a verified grant raises this sender's budgets; default community.
|
|
280
|
+
const tier = this._resolveTier(req, envelope.sender);
|
|
281
|
+
if (rawBytes > tier.maxBodyBytes) {
|
|
282
|
+
return { code: 413, body: { accepted: false, reason: "body-too-large", maxBodyBytes: tier.maxBodyBytes, tier: tier.tier } };
|
|
283
|
+
}
|
|
150
284
|
// F2: per-sender ingest budget — a sender uuaid cannot flood its carrier.
|
|
151
|
-
if (!this._rateAllow(`s:${envelope.sender}`,
|
|
285
|
+
if (!this._rateAllow(`s:${envelope.sender}`, tier.envelopesPerMin)) {
|
|
152
286
|
return { code: 429, body: { accepted: false, reason: "rate-limited" } };
|
|
153
287
|
}
|
|
154
288
|
// F2: per-client budget on ACCEPTED sends — closes the unlimited
|
|
155
289
|
// fresh-keypair spray (each new key gets a fresh sender budget).
|
|
156
|
-
|
|
290
|
+
// Priority senders bill to a SEPARATE bucket: the point of the lane is
|
|
291
|
+
// that a noisy neighbour behind the same egress IP (or NAT, or CI
|
|
292
|
+
// runner) cannot consume the budget a durable seed operator earned.
|
|
293
|
+
const ipKey = tier.priority ? `p:${ip}` : `g:${ip}`;
|
|
294
|
+
const ipLimit = tier.priority ? MAX_PRIORITY_GOOD_PER_MIN : MAX_GOOD_PER_MIN;
|
|
295
|
+
if (!this._rateAllow(ipKey, ipLimit)) {
|
|
157
296
|
return { code: 429, body: { accepted: false, reason: "rate-limited" } };
|
|
158
297
|
}
|
|
159
298
|
// F3: freshness — reject envelopes whose createdAt is missing or skewed
|
|
@@ -163,10 +302,13 @@ export class CarrierServer {
|
|
|
163
302
|
return { code: 400, body: { accepted: false, reason: "stale-envelope" } };
|
|
164
303
|
}
|
|
165
304
|
// F2: backpressure — refuse ingest when the store is near-full instead of
|
|
166
|
-
// failing at disk-full mid-write.
|
|
305
|
+
// failing at disk-full mid-write. Community traffic sheds at 85% so the
|
|
306
|
+
// remaining slice stays available to priority senders; the absolute cap
|
|
307
|
+
// is identical for both, so the reserve cannot push the store past it.
|
|
167
308
|
const pages = this.db.pragma("page_count", { simple: true });
|
|
168
309
|
const pageBytes = this.db.pragma("page_size", { simple: true });
|
|
169
|
-
|
|
310
|
+
const storeCap = tier.priority ? MAX_DB_BYTES : COMMUNITY_DB_BYTES;
|
|
311
|
+
if (pages * pageBytes > storeCap) {
|
|
170
312
|
return { code: 503, body: { accepted: false, reason: "carrier-full" } };
|
|
171
313
|
}
|
|
172
314
|
const sha = createHash("sha256").update(JSON.stringify(envelope)).digest("hex");
|
|
@@ -174,6 +316,7 @@ export class CarrierServer {
|
|
|
174
316
|
const r = this.db.prepare(
|
|
175
317
|
"insert into envelopes (envelope_id, recipient, sender, sha, body) values (?,?,?,?,?)"
|
|
176
318
|
).run(envelope.id, envelope.recipient, envelope.sender, sha, JSON.stringify(envelope));
|
|
319
|
+
this._bump("relayed");
|
|
177
320
|
return { code: 202, body: { accepted: true, seq: Number(r.lastInsertRowid), sha } };
|
|
178
321
|
} catch (e) {
|
|
179
322
|
if (/UNIQUE constraint/.test(e.message)) {
|
|
@@ -198,7 +341,17 @@ export class CarrierServer {
|
|
|
198
341
|
// Self-authenticating: UUAID must derive from the presented pubkey.
|
|
199
342
|
const localId = Keychain._localIdFromKey(Buffer.from(pubkey, "hex"));
|
|
200
343
|
const derived = `uuaid:foundation:agent:${localId}`;
|
|
201
|
-
if (derived !== uuaid)
|
|
344
|
+
if (derived !== uuaid) {
|
|
345
|
+
// A delegate key may read the principal's inbox only by presenting the
|
|
346
|
+
// principal-signed delegation document that names it. Without the
|
|
347
|
+
// header this stays the legacy mismatch.
|
|
348
|
+
const raw = req.headers["x-pillar-delegation"];
|
|
349
|
+
if (raw === undefined) return { ok: false, reason: "uuaid-pubkey-mismatch" };
|
|
350
|
+
const delegated = this._verifyInboxDelegation(raw, uuaid, pubkey);
|
|
351
|
+
if (!delegated.ok) return delegated;
|
|
352
|
+
}
|
|
353
|
+
// Either the principal's own key or an authorized delegate key: in both
|
|
354
|
+
// cases it must have signed THIS request.
|
|
202
355
|
const message = Buffer.from(`${sincePath}\n${ts}`, "utf-8");
|
|
203
356
|
let verified = false;
|
|
204
357
|
try { verified = Keychain.verifyDetached(pubkey, message, Buffer.from(sig, "hex")); }
|
|
@@ -207,6 +360,37 @@ export class CarrierServer {
|
|
|
207
360
|
return { ok: true };
|
|
208
361
|
}
|
|
209
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Authorize a delegate to poll `uuaid`'s inbox from an `x-pillar-delegation`
|
|
365
|
+
* header. Fails closed on every error and never logs the document.
|
|
366
|
+
*
|
|
367
|
+
* Reasons (all surface as HTTP 401):
|
|
368
|
+
* delegation-too-large header exceeds MAX_DELEGATION_HEADER
|
|
369
|
+
* delegation-unparseable not a string / not JSON
|
|
370
|
+
* delegation-invalid:<reason> verifyDelegationDoc verdict (expired,
|
|
371
|
+
* bad-signature, *-uuaid-binding, ...)
|
|
372
|
+
* delegation-principal-mismatch doc principal is not the path uuaid
|
|
373
|
+
* delegation-delegate-key-mismatch doc names a different delegate key
|
|
374
|
+
* delegation-inbox-denied kinds cover neither "inbox" nor "*"
|
|
375
|
+
*/
|
|
376
|
+
_verifyInboxDelegation(raw, uuaid, pubkey) {
|
|
377
|
+
if (typeof raw !== "string") return { ok: false, reason: "delegation-unparseable" };
|
|
378
|
+
if (Buffer.byteLength(raw, "utf-8") > MAX_DELEGATION_HEADER) {
|
|
379
|
+
return { ok: false, reason: "delegation-too-large" };
|
|
380
|
+
}
|
|
381
|
+
let doc;
|
|
382
|
+
try { doc = JSON.parse(raw); }
|
|
383
|
+
catch (_e) { return { ok: false, reason: "delegation-unparseable" }; }
|
|
384
|
+
const verdict = verifyDelegationDoc(doc);
|
|
385
|
+
if (!verdict.ok) return { ok: false, reason: `delegation-invalid:${verdict.reason}` };
|
|
386
|
+
if (doc.principal !== uuaid) return { ok: false, reason: "delegation-principal-mismatch" };
|
|
387
|
+
if (doc.delegatePublicKey.toLowerCase() !== pubkey.toLowerCase()) {
|
|
388
|
+
return { ok: false, reason: "delegation-delegate-key-mismatch" };
|
|
389
|
+
}
|
|
390
|
+
if (!delegationCoversKind(doc, "inbox")) return { ok: false, reason: "delegation-inbox-denied" };
|
|
391
|
+
return { ok: true };
|
|
392
|
+
}
|
|
393
|
+
|
|
210
394
|
async _handleInbox(req, res, uuaid, url) {
|
|
211
395
|
const since = Number(url.searchParams.get("since") ?? 0);
|
|
212
396
|
const wait = Math.min(Number(url.searchParams.get("wait") ?? 0), MAX_WAIT_S);
|
|
@@ -215,8 +399,13 @@ export class CarrierServer {
|
|
|
215
399
|
if (!auth.ok) return this._json(res, 401, { error: auth.reason });
|
|
216
400
|
|
|
217
401
|
// F2: cap concurrent long-polls so sleeping connections cannot pin the
|
|
218
|
-
// event loop / fd table.
|
|
219
|
-
|
|
402
|
+
// event loop / fd table. The grant is resolved against the PRINCIPAL
|
|
403
|
+
// whose inbox this is (the path uuaid), not the key that signed — a
|
|
404
|
+
// delegate polling for a priority principal inherits the reserved slots,
|
|
405
|
+
// which is the same authority it already has to read the mail.
|
|
406
|
+
const tier = this._resolveTier(req, uuaid);
|
|
407
|
+
const waitCap = tier.priority ? MAX_CONCURRENT_WAITS : COMMUNITY_CONCURRENT_WAITS;
|
|
408
|
+
if (wait > 0 && this.activeWaits >= waitCap) {
|
|
220
409
|
return this._json(res, 503, { error: "busy" });
|
|
221
410
|
}
|
|
222
411
|
if (wait > 0) this.activeWaits++;
|
|
@@ -237,6 +426,7 @@ export class CarrierServer {
|
|
|
237
426
|
await new Promise(r => setTimeout(r, 400));
|
|
238
427
|
rows = fetchRows();
|
|
239
428
|
}
|
|
429
|
+
if (rows.length) this._bump("served", rows.length);
|
|
240
430
|
this._json(res, 200, {
|
|
241
431
|
envelopes: rows.map(r => ({ seq: r.seq, envelope: JSON.parse(r.body) })),
|
|
242
432
|
now: Date.now(),
|
|
@@ -255,7 +445,7 @@ export class CarrierServer {
|
|
|
255
445
|
let envelope;
|
|
256
446
|
try { envelope = JSON.parse(raw); }
|
|
257
447
|
catch { return this._json(res, 400, { accepted: false, reason: "bad-json" }); }
|
|
258
|
-
const { code, body } = this._handlePost(envelope, this._clientIp(req));
|
|
448
|
+
const { code, body } = this._handlePost(envelope, this._clientIp(req), req, Buffer.byteLength(raw));
|
|
259
449
|
return this._json(res, code, body);
|
|
260
450
|
}
|
|
261
451
|
const inboxMatch = /^\/v1\/inbox\/([^/]+)$/.exec(url.pathname);
|
|
@@ -264,6 +454,13 @@ export class CarrierServer {
|
|
|
264
454
|
if (!uuaid.startsWith("uuaid:")) return this._json(res, 404, { error: "not-found" });
|
|
265
455
|
return await this._handleInbox(req, res, uuaid, url);
|
|
266
456
|
}
|
|
457
|
+
if (req.method === "GET" && url.pathname === "/v1/seed-info") {
|
|
458
|
+
const info = this.seedInfo();
|
|
459
|
+
// A relay with no seed identity is not a seed and must not look
|
|
460
|
+
// like one to the registry.
|
|
461
|
+
if (!info) return this._json(res, 404, { error: "not-a-seed" });
|
|
462
|
+
return this._json(res, 200, info);
|
|
463
|
+
}
|
|
267
464
|
if (req.method === "GET" && url.pathname === "/v1/health") {
|
|
268
465
|
const stored = this.db.prepare("select count(*) as n from envelopes").get().n;
|
|
269
466
|
return this._json(res, 200, { ok: true, uptimeMs: Date.now() - this.startedAt, stored });
|