agent-protocols 0.10.1 → 0.11.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/README.md +89 -2
- package/dist/identity.d.ts +14 -0
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.js +79 -2
- package/dist/identity.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/mail-client.d.ts +38 -0
- package/dist/mail-client.d.ts.map +1 -0
- package/dist/mail-client.js +176 -0
- package/dist/mail-client.js.map +1 -0
- package/dist/mail-schema.d.ts +355 -0
- package/dist/mail-schema.d.ts.map +1 -0
- package/dist/mail-schema.js +376 -0
- package/dist/mail-schema.js.map +1 -0
- package/dist/mail.d.ts +244 -0
- package/dist/mail.d.ts.map +1 -0
- package/dist/mail.js +817 -0
- package/dist/mail.js.map +1 -0
- package/package.json +14 -3
package/dist/mail.js
ADDED
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
/** Agent Mail 1.0. State helpers are process-local; persist snapshots before acknowledging delivery. */
|
|
2
|
+
import { CipherSuite, HkdfSha256 } from "@hpke/core";
|
|
3
|
+
import { DhkemX25519HkdfSha256 } from "@hpke/dhkem-x25519";
|
|
4
|
+
import { Chacha20Poly1305 } from "@hpke/chacha20poly1305";
|
|
5
|
+
import { sha3_256 } from "@noble/hashes/sha3.js";
|
|
6
|
+
import { Validator } from "@cfworker/json-schema";
|
|
7
|
+
import canonicalize from "canonicalize";
|
|
8
|
+
import nacl from "tweetnacl";
|
|
9
|
+
import { base64UrlDecodeCanonical, base64UrlEncode } from "./encoding.js";
|
|
10
|
+
import { protocolError } from "./errors.js";
|
|
11
|
+
import { MemoryNonceStore, createEvent, formatAgentUrl, parseAgentUrl, parseStrictJson, validateAgentId, validateOrigin, verifyEnvelope, verifyRequestJwt, verifyTimestamp, } from "./identity.js";
|
|
12
|
+
import { MAIL_SCHEMA } from "./mail-schema.js";
|
|
13
|
+
export { MAIL_SCHEMA } from "./mail-schema.js";
|
|
14
|
+
export const MAIL_PROTOCOL = "agent-mail/1.0";
|
|
15
|
+
export const MAIL_MAX_TTL_MS = 30 * 86400000;
|
|
16
|
+
export const MAIL_FUTURE_SKEW_MS = 300000;
|
|
17
|
+
export const MAIL_MAX_PACKET_BYTES = 1048576;
|
|
18
|
+
const encoder = new TextEncoder();
|
|
19
|
+
const INFO = encoder.encode(MAIL_PROTOCOL);
|
|
20
|
+
const clone = (v) => structuredClone(v);
|
|
21
|
+
const validators = new Map();
|
|
22
|
+
const LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
|
|
23
|
+
function requireMail(ok, code, message = code) {
|
|
24
|
+
if (!ok)
|
|
25
|
+
throw protocolError(code, message);
|
|
26
|
+
}
|
|
27
|
+
function safeTime(now) {
|
|
28
|
+
requireMail(Number.isSafeInteger(now) && now >= 0, "invalid_request", "clock must be nonnegative safe milliseconds");
|
|
29
|
+
}
|
|
30
|
+
function strictString(value) {
|
|
31
|
+
requireMail(!LONE_SURROGATE.test(value), "invalid_event", "lone surrogate");
|
|
32
|
+
}
|
|
33
|
+
function jsonValue(value, seen = new Set()) {
|
|
34
|
+
if (value === null || typeof value === "boolean")
|
|
35
|
+
return;
|
|
36
|
+
if (typeof value === "string")
|
|
37
|
+
return strictString(value);
|
|
38
|
+
if (typeof value === "number") {
|
|
39
|
+
requireMail(Number.isFinite(value) &&
|
|
40
|
+
(!Number.isInteger(value) || Number.isSafeInteger(value)), "invalid_event", "invalid JSON number");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
requireMail(typeof value === "object", "invalid_event", "non-JSON value");
|
|
44
|
+
requireMail(!seen.has(value), "invalid_event", "cyclic JSON");
|
|
45
|
+
requireMail(Array.isArray(value) ||
|
|
46
|
+
Object.getPrototypeOf(value) === Object.prototype ||
|
|
47
|
+
Object.getPrototypeOf(value) === null, "invalid_event", "expected plain JSON object");
|
|
48
|
+
seen.add(value);
|
|
49
|
+
if (Array.isArray(value))
|
|
50
|
+
for (const child of value)
|
|
51
|
+
jsonValue(child, seen);
|
|
52
|
+
else
|
|
53
|
+
for (const [key, child] of Object.entries(value)) {
|
|
54
|
+
strictString(key);
|
|
55
|
+
requireMail(child !== undefined, "invalid_event", "undefined member");
|
|
56
|
+
jsonValue(child, seen);
|
|
57
|
+
}
|
|
58
|
+
seen.delete(value);
|
|
59
|
+
}
|
|
60
|
+
/** Canonical, strict I-JSON bytes. This never silently drops undefined members. */
|
|
61
|
+
export function mailCanonicalBytes(value) {
|
|
62
|
+
jsonValue(value);
|
|
63
|
+
return encoder.encode(canonicalize(value));
|
|
64
|
+
}
|
|
65
|
+
export function validateMailSchema(value, definition = "envelope") {
|
|
66
|
+
jsonValue(value);
|
|
67
|
+
let validator = validators.get(definition);
|
|
68
|
+
if (!validator) {
|
|
69
|
+
requireMail(Object.hasOwn(MAIL_SCHEMA.$defs, definition), "invalid_request", "unknown Mail schema definition");
|
|
70
|
+
validator = new Validator({
|
|
71
|
+
$ref: `#/$defs/${definition}`,
|
|
72
|
+
$defs: MAIL_SCHEMA.$defs,
|
|
73
|
+
}, "2020-12");
|
|
74
|
+
validators.set(definition, validator);
|
|
75
|
+
}
|
|
76
|
+
requireMail(validator.validate(value).valid, "invalid_event", `${definition} schema violation`);
|
|
77
|
+
}
|
|
78
|
+
export function decodeMailBytes(value, length) {
|
|
79
|
+
requireMail(typeof value === "string", "invalid_encoding");
|
|
80
|
+
const bytes = base64UrlDecodeCanonical(value);
|
|
81
|
+
requireMail(bytes !== undefined && (length === undefined || bytes.length === length), "invalid_encoding", "invalid canonical base64url or byte length");
|
|
82
|
+
return bytes;
|
|
83
|
+
}
|
|
84
|
+
function pathId(value, length) {
|
|
85
|
+
const bytes = typeof value === "string" ? base64UrlDecodeCanonical(value) : undefined;
|
|
86
|
+
requireMail(bytes?.length === length, "invalid_request", "invalid path ID");
|
|
87
|
+
}
|
|
88
|
+
export function newMailId() {
|
|
89
|
+
return base64UrlEncode(nacl.randomBytes(16));
|
|
90
|
+
}
|
|
91
|
+
/** Parse `did:agent:<key>/mail/<mailbox_id>[?route=<origin>...]`. */
|
|
92
|
+
export function parseMailAddress(value) {
|
|
93
|
+
const url = parseAgentUrl(value);
|
|
94
|
+
requireMail(url.protocol === "mail" &&
|
|
95
|
+
url.resource !== undefined &&
|
|
96
|
+
base64UrlDecodeCanonical(url.resource)?.length === 16, "invalid_url", "not a mailbox address");
|
|
97
|
+
return { owner: url.agent_id, mailbox_id: url.resource, routes: url.routes };
|
|
98
|
+
}
|
|
99
|
+
/** Format the stable address, or a contact address when routes are given. */
|
|
100
|
+
export function formatMailAddress(owner, mailboxId, routes = []) {
|
|
101
|
+
requireMail(base64UrlDecodeCanonical(mailboxId)?.length === 16, "invalid_url", "mailbox_id must be an id16");
|
|
102
|
+
return formatAgentUrl({
|
|
103
|
+
agent_id: owner,
|
|
104
|
+
protocol: "mail",
|
|
105
|
+
resource: mailboxId,
|
|
106
|
+
routes,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
export function mailTextPart(text, name) {
|
|
110
|
+
strictString(text);
|
|
111
|
+
return {
|
|
112
|
+
media_type: "text/plain",
|
|
113
|
+
data: base64UrlEncode(encoder.encode(text)),
|
|
114
|
+
...(name === undefined ? {} : { name }),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** Returns inert text only; does not render markup or perform network/file actions. */
|
|
118
|
+
export function mailPartText(part) {
|
|
119
|
+
validateMailSchema(part, "part");
|
|
120
|
+
requireMail(part.media_type.startsWith("text/"), "invalid_event", "part is not text");
|
|
121
|
+
try {
|
|
122
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(decodeMailBytes(part.data));
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
throw protocolError("invalid_event", "invalid UTF-8 text part");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function lifetime(created, expires) {
|
|
129
|
+
requireMail(created < expires && expires - created <= MAIL_MAX_TTL_MS, "invalid_event", "invalid lifetime");
|
|
130
|
+
}
|
|
131
|
+
function usableX25519(publicKey) {
|
|
132
|
+
const key = decodeMailBytes(publicKey, 32);
|
|
133
|
+
// A public, disposable scalar detects all low-order peers; no shared secret is used for encryption.
|
|
134
|
+
requireMail(nacl.scalarMult(new Uint8Array(32).fill(42), key).some((x) => x !== 0), "invalid_event", "unusable X25519 public key");
|
|
135
|
+
}
|
|
136
|
+
/** Historical verification: shape, signature, owner, intrinsic times, routes, and key. */
|
|
137
|
+
export function validateMailboxCard(card, owner) {
|
|
138
|
+
validateMailSchema(card, "mailboxCardEnvelope");
|
|
139
|
+
const c = card;
|
|
140
|
+
verifyEnvelope(c);
|
|
141
|
+
if (owner !== undefined)
|
|
142
|
+
requireMail(c.event.actor === owner, "invalid_actor", "card owner mismatch");
|
|
143
|
+
const p = c.event.payload;
|
|
144
|
+
lifetime(c.event.created_at, p.expires_at);
|
|
145
|
+
requireMail(p.expires_at <= p.receive_until &&
|
|
146
|
+
p.receive_until - p.expires_at <= MAIL_MAX_TTL_MS, "invalid_event", "invalid receive_until");
|
|
147
|
+
p.routes.forEach(validateOrigin);
|
|
148
|
+
usableX25519(p.public_key);
|
|
149
|
+
}
|
|
150
|
+
/** A card usable for new encryption: not future-dated, open (non-empty routes), unexpired. */
|
|
151
|
+
export function validateMailSenderCard(card, owner, now = Date.now()) {
|
|
152
|
+
safeTime(now);
|
|
153
|
+
validateMailboxCard(card, owner);
|
|
154
|
+
const c = card;
|
|
155
|
+
requireMail(c.event.created_at <= now + MAIL_FUTURE_SKEW_MS, "timestamp_out_of_window", "card is future-dated");
|
|
156
|
+
requireMail(c.event.payload.routes.length > 0, "mailbox_unavailable", "mailbox closed");
|
|
157
|
+
requireMail(now < c.event.payload.expires_at, "stale_card", "card expired");
|
|
158
|
+
}
|
|
159
|
+
/** Validate immutable plaintext; sender authentication comes from its signed packet. */
|
|
160
|
+
export function validateMailMessage(message) {
|
|
161
|
+
validateMailSchema(message, "messagePayload");
|
|
162
|
+
const p = message;
|
|
163
|
+
lifetime(p.created_at, p.expires_at);
|
|
164
|
+
for (const part of p.parts) {
|
|
165
|
+
decodeMailBytes(part.data);
|
|
166
|
+
if (part.media_type.startsWith("text/"))
|
|
167
|
+
mailPartText(part);
|
|
168
|
+
}
|
|
169
|
+
if (p.reply_card !== undefined)
|
|
170
|
+
validateMailboxCard(p.reply_card, p.from);
|
|
171
|
+
}
|
|
172
|
+
export function mailboxPublishEvent(actor, createdAt, nonce, payload) {
|
|
173
|
+
return createEvent(MAIL_PROTOCOL, "mailbox.publish", actor, createdAt, nonce, clone(payload));
|
|
174
|
+
}
|
|
175
|
+
export function newMailMessageId() {
|
|
176
|
+
return base64UrlEncode(nacl.randomBytes(32));
|
|
177
|
+
}
|
|
178
|
+
export function createMailMessage(actor, createdAt, payload) {
|
|
179
|
+
requireMail(payload &&
|
|
180
|
+
!["message_id", "from", "created_at"].some((k) => Object.hasOwn(payload, k)), "invalid_event", "message content overrides identity fields");
|
|
181
|
+
const message = {
|
|
182
|
+
...clone(payload),
|
|
183
|
+
message_id: newMailMessageId(),
|
|
184
|
+
from: actor,
|
|
185
|
+
created_at: createdAt,
|
|
186
|
+
};
|
|
187
|
+
validateMailMessage(message);
|
|
188
|
+
return message;
|
|
189
|
+
}
|
|
190
|
+
export function validateMailReply(reply, parent) {
|
|
191
|
+
validateMailMessage(reply);
|
|
192
|
+
validateMailMessage(parent);
|
|
193
|
+
requireMail(reply.in_reply_to === parent.message_id &&
|
|
194
|
+
reply.thread_id === parent.thread_id &&
|
|
195
|
+
reply.from === parent.to &&
|
|
196
|
+
reply.to === parent.from, "invalid_event", "reply binding mismatch");
|
|
197
|
+
}
|
|
198
|
+
function packetBytes(packet, expectedId) {
|
|
199
|
+
validateMailSchema(packet, "packetEnvelope");
|
|
200
|
+
const signed = packet;
|
|
201
|
+
verifyEnvelope(signed);
|
|
202
|
+
const e = signed.event, p = e.payload;
|
|
203
|
+
decodeMailBytes(p.enc, 32);
|
|
204
|
+
const ct = decodeMailBytes(p.ciphertext);
|
|
205
|
+
requireMail(ct.length >= 1040 && ct.length % 1024 === 16, "invalid_packet", "invalid ciphertext length");
|
|
206
|
+
requireMail(e.created_at < p.header.expires_at &&
|
|
207
|
+
p.header.expires_at - e.created_at <=
|
|
208
|
+
MAIL_MAX_TTL_MS + MAIL_FUTURE_SKEW_MS, "invalid_packet", "invalid packet lifetime");
|
|
209
|
+
const bytes = mailCanonicalBytes(signed);
|
|
210
|
+
requireMail(bytes.length <= MAIL_MAX_PACKET_BYTES, "payload_too_large", "packet exceeds limit");
|
|
211
|
+
if (expectedId !== undefined)
|
|
212
|
+
requireMail(signed.hash === expectedId, "invalid_packet", "packet ID mismatch");
|
|
213
|
+
return bytes;
|
|
214
|
+
}
|
|
215
|
+
export function validateMailPacket(packet, expectedId) {
|
|
216
|
+
packetBytes(packet, expectedId);
|
|
217
|
+
}
|
|
218
|
+
export function mailPacketId(packet) {
|
|
219
|
+
packetBytes(packet);
|
|
220
|
+
return packet.hash;
|
|
221
|
+
}
|
|
222
|
+
/** HPKE AAD: the packet's five metadata fields and header, from the verified outer event. */
|
|
223
|
+
export function mailPacketAad(event) {
|
|
224
|
+
return mailCanonicalBytes({
|
|
225
|
+
protocol: event.protocol,
|
|
226
|
+
type: event.type,
|
|
227
|
+
actor: event.actor,
|
|
228
|
+
created_at: event.created_at,
|
|
229
|
+
nonce: event.nonce,
|
|
230
|
+
header: event.payload.header,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
export function frameMailBytes(bytes) {
|
|
234
|
+
requireMail(bytes.length > 0 && bytes.length <= MAIL_MAX_PACKET_BYTES, "invalid_packet", "invalid frame input length");
|
|
235
|
+
const frame = new Uint8Array(1024 * Math.ceil((4 + bytes.length) / 1024));
|
|
236
|
+
new DataView(frame.buffer).setUint32(0, bytes.length, false);
|
|
237
|
+
frame.set(bytes, 4);
|
|
238
|
+
return frame;
|
|
239
|
+
}
|
|
240
|
+
export function unframeMailMessage(frame) {
|
|
241
|
+
requireMail(frame.length >= 1024 &&
|
|
242
|
+
frame.length <= MAIL_MAX_PACKET_BYTES &&
|
|
243
|
+
frame.length % 1024 === 0, "invalid_packet", "invalid frame length");
|
|
244
|
+
const n = new DataView(frame.buffer, frame.byteOffset, frame.byteLength).getUint32(0, false);
|
|
245
|
+
requireMail(n > 0 &&
|
|
246
|
+
n <= frame.length - 4 &&
|
|
247
|
+
frame.length === 1024 * Math.ceil((4 + n) / 1024), "invalid_packet", "invalid/nonminimal frame length");
|
|
248
|
+
requireMail(frame.subarray(4 + n).every((x) => x === 0), "invalid_packet", "nonzero padding");
|
|
249
|
+
let text;
|
|
250
|
+
try {
|
|
251
|
+
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(frame.subarray(4, 4 + n));
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
throw protocolError("invalid_packet", "invalid UTF-8 frame");
|
|
255
|
+
}
|
|
256
|
+
const message = parseStrictJson(text);
|
|
257
|
+
requireMail(canonicalize(message) === text, "invalid_packet", "noncanonical message JSON");
|
|
258
|
+
validateMailMessage(message);
|
|
259
|
+
return message;
|
|
260
|
+
}
|
|
261
|
+
const hpke = () => new CipherSuite({
|
|
262
|
+
kem: new DhkemX25519HkdfSha256(),
|
|
263
|
+
kdf: new HkdfSha256(),
|
|
264
|
+
aead: new Chacha20Poly1305(),
|
|
265
|
+
});
|
|
266
|
+
/** Independent raw X25519 secret. Never derive this from an Identity signing key. */
|
|
267
|
+
export class MailEncryptionKey {
|
|
268
|
+
secret;
|
|
269
|
+
destroyed = false;
|
|
270
|
+
constructor(secret) {
|
|
271
|
+
requireMail(secret.length === 32, "invalid_private_key");
|
|
272
|
+
this.secret = new Uint8Array(secret);
|
|
273
|
+
}
|
|
274
|
+
static generate() {
|
|
275
|
+
return new MailEncryptionKey(nacl.randomBytes(32));
|
|
276
|
+
}
|
|
277
|
+
static fromBytes(secret) {
|
|
278
|
+
return new MailEncryptionKey(secret);
|
|
279
|
+
}
|
|
280
|
+
publicKey() {
|
|
281
|
+
requireMail(!this.destroyed, "invalid_private_key", "key destroyed");
|
|
282
|
+
return base64UrlEncode(nacl.scalarMult.base(this.secret));
|
|
283
|
+
}
|
|
284
|
+
/** Sensitive backup material: store only in an owner-controlled encrypted key store. */
|
|
285
|
+
exportSecret() {
|
|
286
|
+
requireMail(!this.destroyed, "invalid_private_key", "key destroyed");
|
|
287
|
+
return new Uint8Array(this.secret);
|
|
288
|
+
}
|
|
289
|
+
destroy() {
|
|
290
|
+
this.secret.fill(0);
|
|
291
|
+
this.destroyed = true;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/** Stateless encryption with fresh HPKE randomness. Pin the card with MailCardCache first. */
|
|
295
|
+
export async function sealMailPacket(message, card, signer, nonce, now = Date.now()) {
|
|
296
|
+
message = clone(message);
|
|
297
|
+
card = clone(card);
|
|
298
|
+
validateMailMessage(message);
|
|
299
|
+
requireMail(message.from === signer.agentId(), "invalid_actor", "message sender differs from signer");
|
|
300
|
+
validateMailSenderCard(card, message.to, now);
|
|
301
|
+
requireMail(message.created_at <= now + MAIL_FUTURE_SKEW_MS && now < message.expires_at, "packet_expired", "message expired or future-dated");
|
|
302
|
+
const c = card.event.payload;
|
|
303
|
+
requireMail(message.expires_at <= c.receive_until, "invalid_packet", "message exceeds receive_until");
|
|
304
|
+
const plaintext = frameMailBytes(mailCanonicalBytes(message));
|
|
305
|
+
const header = {
|
|
306
|
+
mailbox_id: c.mailbox_id,
|
|
307
|
+
card_hash: card.hash,
|
|
308
|
+
expires_at: message.expires_at,
|
|
309
|
+
};
|
|
310
|
+
const event = createEvent(MAIL_PROTOCOL, "mail.submit", signer.agentId(), now, nonce, {
|
|
311
|
+
header,
|
|
312
|
+
enc: base64UrlEncode(new Uint8Array(32)),
|
|
313
|
+
ciphertext: base64UrlEncode(new Uint8Array(plaintext.length + 16)),
|
|
314
|
+
});
|
|
315
|
+
const estimate = { event, hash: "A".repeat(43), signature: "A".repeat(86) };
|
|
316
|
+
requireMail(mailCanonicalBytes(estimate).length <= c.max_packet_bytes, "payload_too_large", "packet exceeds card limit");
|
|
317
|
+
const suite = hpke();
|
|
318
|
+
const sender = await suite.createSenderContext({
|
|
319
|
+
recipientPublicKey: await suite.kem.deserializePublicKey(decodeMailBytes(c.public_key, 32)),
|
|
320
|
+
info: INFO,
|
|
321
|
+
});
|
|
322
|
+
try {
|
|
323
|
+
const ct = await sender.seal(plaintext, mailPacketAad(event));
|
|
324
|
+
event.payload.enc = base64UrlEncode(new Uint8Array(sender.enc));
|
|
325
|
+
event.payload.ciphertext = base64UrlEncode(new Uint8Array(ct));
|
|
326
|
+
return signer.signEvent(event);
|
|
327
|
+
}
|
|
328
|
+
finally {
|
|
329
|
+
plaintext.fill(0);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/** Verify/decrypt a queued packet; expiration of new acceptance is checked by MailInbox. */
|
|
333
|
+
export async function openMailPacket(packet, card, key, owner, now = Date.now(), expectedId) {
|
|
334
|
+
packet = clone(packet);
|
|
335
|
+
card = clone(card);
|
|
336
|
+
safeTime(now);
|
|
337
|
+
const bytes = packetBytes(packet, expectedId);
|
|
338
|
+
validateMailboxCard(card, owner);
|
|
339
|
+
return openVerified(packet, bytes, card, key, owner, now);
|
|
340
|
+
}
|
|
341
|
+
/** Open with a card that was already verified for `owner` (the keyring verifies at `add`). */
|
|
342
|
+
async function openVerified(packet, bytes, card, key, owner, now) {
|
|
343
|
+
requireMail(packet.event.created_at <= now + MAIL_FUTURE_SKEW_MS, "timestamp_out_of_window", "future packet");
|
|
344
|
+
const c = card.event.payload, h = packet.event.payload.header;
|
|
345
|
+
requireMail(h.card_hash === card.hash && h.mailbox_id === c.mailbox_id, "invalid_packet", "card/header binding mismatch");
|
|
346
|
+
requireMail(bytes.length <= c.max_packet_bytes, "payload_too_large", "packet exceeds card limit");
|
|
347
|
+
requireMail(h.expires_at <= c.receive_until, "invalid_packet", "packet exceeds receive_until");
|
|
348
|
+
requireMail(key.publicKey() === c.public_key, "invalid_private_key", "recipient secret does not match card");
|
|
349
|
+
const secret = key.exportSecret();
|
|
350
|
+
let plaintext;
|
|
351
|
+
try {
|
|
352
|
+
const suite = hpke();
|
|
353
|
+
const ctx = await suite.createRecipientContext({
|
|
354
|
+
recipientKey: await suite.kem.deserializePrivateKey(secret),
|
|
355
|
+
enc: decodeMailBytes(packet.event.payload.enc, 32),
|
|
356
|
+
info: INFO,
|
|
357
|
+
});
|
|
358
|
+
plaintext = new Uint8Array(await ctx.open(decodeMailBytes(packet.event.payload.ciphertext), mailPacketAad(packet.event)));
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
throw protocolError("invalid_packet", "HPKE opening failed");
|
|
362
|
+
}
|
|
363
|
+
finally {
|
|
364
|
+
secret.fill(0);
|
|
365
|
+
}
|
|
366
|
+
try {
|
|
367
|
+
const message = unframeMailMessage(plaintext);
|
|
368
|
+
requireMail(message.from === packet.event.actor && message.to === owner, "invalid_packet", "message recipient mismatch");
|
|
369
|
+
requireMail(message.expires_at === h.expires_at, "invalid_packet", "message/header expiration mismatch");
|
|
370
|
+
requireMail(message.created_at <=
|
|
371
|
+
Math.min(now, packet.event.created_at) + MAIL_FUTURE_SKEW_MS, "timestamp_out_of_window", "future message");
|
|
372
|
+
return message;
|
|
373
|
+
}
|
|
374
|
+
finally {
|
|
375
|
+
plaintext.fill(0);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Sender-side card pins: the greatest-nonce card seen per (owner, mailbox),
|
|
380
|
+
* including closed or expired cards. Persist the snapshot after every observe,
|
|
381
|
+
* even one that throws because the new pin is unusable.
|
|
382
|
+
*/
|
|
383
|
+
export class MailCardCache {
|
|
384
|
+
pins = new Map();
|
|
385
|
+
constructor(snapshot) {
|
|
386
|
+
if (snapshot) {
|
|
387
|
+
requireMail(snapshot.version === 1, "invalid_request");
|
|
388
|
+
for (const card of snapshot.pins) {
|
|
389
|
+
validateMailSchema(card, "mailboxCardEnvelope");
|
|
390
|
+
this.pins.set(this.id(card.event.actor, card.event.payload.mailbox_id), clone(card));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
id(owner, mailbox) {
|
|
395
|
+
return owner + ":" + mailbox;
|
|
396
|
+
}
|
|
397
|
+
snapshot() {
|
|
398
|
+
return { version: 1, pins: clone([...this.pins.values()]) };
|
|
399
|
+
}
|
|
400
|
+
/** Pin a verified card, then (by default) require that it is usable for new encryption. */
|
|
401
|
+
observe(card, owner, now = Date.now(), requireUsable = true) {
|
|
402
|
+
card = clone(card);
|
|
403
|
+
safeTime(now);
|
|
404
|
+
validateMailboxCard(card, owner);
|
|
405
|
+
requireMail(card.event.created_at <= now + MAIL_FUTURE_SKEW_MS, "timestamp_out_of_window", "card is future-dated");
|
|
406
|
+
const id = this.id(owner, card.event.payload.mailbox_id), pin = this.pins.get(id);
|
|
407
|
+
if (pin && pin.hash !== card.hash)
|
|
408
|
+
requireMail(card.event.nonce > pin.event.nonce, "stale_card", "card is older than or conflicts with the pinned card");
|
|
409
|
+
this.pins.set(id, card);
|
|
410
|
+
if (requireUsable)
|
|
411
|
+
validateMailSenderCard(card, owner, now);
|
|
412
|
+
return clone(card);
|
|
413
|
+
}
|
|
414
|
+
/** Drop pins old enough that every earlier card of the mailbox has expired. */
|
|
415
|
+
prune(now = Date.now()) {
|
|
416
|
+
safeTime(now);
|
|
417
|
+
for (const [id, pin] of this.pins)
|
|
418
|
+
if (now >= pin.event.created_at + MAIL_MAX_TTL_MS + MAIL_FUTURE_SKEW_MS)
|
|
419
|
+
this.pins.delete(id);
|
|
420
|
+
}
|
|
421
|
+
async seal(message, card, signer, nonce, now = Date.now()) {
|
|
422
|
+
validateMailMessage(message);
|
|
423
|
+
return sealMailPacket(message, this.observe(card, message.to, now), signer, nonce, now);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
/** Retained verified cards and their secrets, looked up by card hash until receive_until. */
|
|
427
|
+
export class MailKeyring {
|
|
428
|
+
owner;
|
|
429
|
+
entries = new Map();
|
|
430
|
+
constructor(owner) {
|
|
431
|
+
this.owner = owner;
|
|
432
|
+
validateAgentId(owner);
|
|
433
|
+
}
|
|
434
|
+
add(card, key) {
|
|
435
|
+
card = clone(card);
|
|
436
|
+
validateMailboxCard(card, this.owner);
|
|
437
|
+
requireMail(key.publicKey() === card.event.payload.public_key, "invalid_private_key", "recipient secret does not match card");
|
|
438
|
+
this.entries.get(card.hash)?.key.destroy();
|
|
439
|
+
this.entries.set(card.hash, {
|
|
440
|
+
card,
|
|
441
|
+
key: MailEncryptionKey.fromBytes(key.exportSecret()),
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
/** Sensitive snapshot, including raw encryption secrets. Protect it separately from logs and application data. */
|
|
445
|
+
exportSnapshot() {
|
|
446
|
+
return {
|
|
447
|
+
version: 1,
|
|
448
|
+
owner: this.owner,
|
|
449
|
+
entries: [...this.entries.values()].map((x) => ({
|
|
450
|
+
card: clone(x.card),
|
|
451
|
+
secret: base64UrlEncode(x.key.exportSecret()),
|
|
452
|
+
})),
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
static restore(snapshot) {
|
|
456
|
+
requireMail(snapshot.version === 1, "invalid_request");
|
|
457
|
+
const r = new MailKeyring(snapshot.owner);
|
|
458
|
+
for (const x of snapshot.entries)
|
|
459
|
+
r.add(x.card, MailEncryptionKey.fromBytes(decodeMailBytes(x.secret, 32)));
|
|
460
|
+
return r;
|
|
461
|
+
}
|
|
462
|
+
prune(now = Date.now()) {
|
|
463
|
+
safeTime(now);
|
|
464
|
+
for (const [hash, x] of this.entries)
|
|
465
|
+
if (now >= x.card.event.payload.receive_until) {
|
|
466
|
+
x.key.destroy();
|
|
467
|
+
this.entries.delete(hash);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
async open(packet, now = Date.now(), expectedId) {
|
|
471
|
+
packet = clone(packet);
|
|
472
|
+
safeTime(now);
|
|
473
|
+
return this.openChecked(packet, packetBytes(packet, expectedId), now);
|
|
474
|
+
}
|
|
475
|
+
/** @internal Open a packet already checked by `packetBytes`; used by `MailInbox`. */
|
|
476
|
+
openChecked(packet, bytes, now) {
|
|
477
|
+
const x = this.entries.get(packet.event.payload.header.card_hash);
|
|
478
|
+
requireMail(x, "invalid_packet", "unknown retained card");
|
|
479
|
+
return openVerified(packet, bytes, x.card, x.key, this.owner, now);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
export class MailInbox {
|
|
483
|
+
keyring;
|
|
484
|
+
accepted = new Map();
|
|
485
|
+
blocked = new Set();
|
|
486
|
+
constructor(keyring, snapshot) {
|
|
487
|
+
this.keyring = keyring;
|
|
488
|
+
if (snapshot) {
|
|
489
|
+
requireMail(snapshot.version === 1 && snapshot.owner === keyring.owner, "invalid_request");
|
|
490
|
+
for (const [id, expires, digest] of snapshot.accepted)
|
|
491
|
+
this.accepted.set(id, { expires, digest });
|
|
492
|
+
for (const sender of snapshot.blocked_senders)
|
|
493
|
+
this.setSenderBlocked(sender, true);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
setSenderBlocked(sender, blocked = true) {
|
|
497
|
+
validateAgentId(sender);
|
|
498
|
+
requireMail(typeof blocked === "boolean", "invalid_request");
|
|
499
|
+
if (blocked)
|
|
500
|
+
this.blocked.add(sender);
|
|
501
|
+
else
|
|
502
|
+
this.blocked.delete(sender);
|
|
503
|
+
}
|
|
504
|
+
snapshot() {
|
|
505
|
+
return {
|
|
506
|
+
version: 1,
|
|
507
|
+
owner: this.keyring.owner,
|
|
508
|
+
accepted: [...this.accepted].map(([k, v]) => [k, v.expires, v.digest]),
|
|
509
|
+
blocked_senders: [...this.blocked],
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
has(sender, messageId) {
|
|
513
|
+
return this.accepted.has(sender + "/" + messageId);
|
|
514
|
+
}
|
|
515
|
+
/** Verify the packet once, apply sender policy before decryption, then deduplicate the message. */
|
|
516
|
+
async accept(packet, now = Date.now(), expectedId) {
|
|
517
|
+
packet = clone(packet);
|
|
518
|
+
safeTime(now);
|
|
519
|
+
const bytes = packetBytes(packet, expectedId);
|
|
520
|
+
requireMail(!this.blocked.has(packet.event.actor), "permission_denied", "sender not admitted");
|
|
521
|
+
const message = await this.keyring.openChecked(packet, bytes, now);
|
|
522
|
+
const key = message.from + "/" + message.message_id, digest = base64UrlEncode(sha3_256(mailCanonicalBytes(message))), previous = this.accepted.get(key);
|
|
523
|
+
// No await between lookup and commit: concurrent decryptions cannot both accept.
|
|
524
|
+
if (previous) {
|
|
525
|
+
requireMail(previous.digest === digest, "invalid_event", "message ID reused for different content");
|
|
526
|
+
return { kind: "duplicate", message };
|
|
527
|
+
}
|
|
528
|
+
requireMail(now < message.expires_at, "packet_expired", "expired new message");
|
|
529
|
+
this.accepted.set(key, { expires: message.expires_at, digest });
|
|
530
|
+
return { kind: "accepted", message };
|
|
531
|
+
}
|
|
532
|
+
prune(now = Date.now()) {
|
|
533
|
+
safeTime(now);
|
|
534
|
+
for (const [k, v] of this.accepted)
|
|
535
|
+
if (now >= v.expires)
|
|
536
|
+
this.accepted.delete(k);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
export function validateMailDiscovery(value, service) {
|
|
540
|
+
validateMailSchema(value, "discoveryDocument");
|
|
541
|
+
validateOrigin(service);
|
|
542
|
+
requireMail(value.service === service, "invalid_response", "discovery origin mismatch");
|
|
543
|
+
}
|
|
544
|
+
function ownerClaims(token, service, now) {
|
|
545
|
+
safeTime(now);
|
|
546
|
+
try {
|
|
547
|
+
return verifyRequestJwt(token, {
|
|
548
|
+
audience: service,
|
|
549
|
+
nowSecs: Math.floor(now / 1000),
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
catch {
|
|
553
|
+
throw protocolError("invalid_token", "invalid mailbox owner JWT");
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
/** Identity request JWT for this relay origin whose subject is the mailbox owner. */
|
|
557
|
+
export function validateMailOwnerJwt(token, service, owner, now = Date.now()) {
|
|
558
|
+
validateOrigin(service);
|
|
559
|
+
validateAgentId(owner);
|
|
560
|
+
requireMail(ownerClaims(token, service, now).iss === owner, "permission_denied", "not mailbox owner");
|
|
561
|
+
}
|
|
562
|
+
export function validateMailCardRecord(value, owner, mailboxId, hash) {
|
|
563
|
+
validateMailSchema(value, "cardAcceptedRecord");
|
|
564
|
+
const r = value;
|
|
565
|
+
validateMailboxCard(r.envelope, owner);
|
|
566
|
+
requireMail((mailboxId === undefined ||
|
|
567
|
+
r.envelope.event.payload.mailbox_id === mailboxId) &&
|
|
568
|
+
(hash === undefined || r.envelope.hash === hash), "invalid_response", "card record mismatch");
|
|
569
|
+
}
|
|
570
|
+
export function validateMailDeliveryResult(value, packet) {
|
|
571
|
+
validateMailSchema(value, "deliveryResult");
|
|
572
|
+
const r = value;
|
|
573
|
+
if (packet !== undefined)
|
|
574
|
+
requireMail(r.packet_id === mailPacketId(packet) &&
|
|
575
|
+
r.accepted_at < packet.event.payload.header.expires_at, "invalid_response", "delivery result mismatch");
|
|
576
|
+
}
|
|
577
|
+
export function validateMailPacketRecord(value, mailboxId) {
|
|
578
|
+
validateMailSchema(value, "packetRecord");
|
|
579
|
+
const r = value;
|
|
580
|
+
validateMailPacket(r.packet, r.packet_id);
|
|
581
|
+
requireMail(r.accepted_at < r.packet.event.payload.header.expires_at &&
|
|
582
|
+
(mailboxId === undefined ||
|
|
583
|
+
r.packet.event.payload.header.mailbox_id === mailboxId), "invalid_response", "packet record mismatch");
|
|
584
|
+
}
|
|
585
|
+
export function validateMailPacketList(value, mailboxId, limit = 100) {
|
|
586
|
+
validateMailSchema(value, "packetList");
|
|
587
|
+
const p = value;
|
|
588
|
+
requireMail(p.result.length <= limit && (!p.next_cursor || p.result.length > 0), "invalid_response", "invalid page size");
|
|
589
|
+
let seq = 0;
|
|
590
|
+
for (const r of p.result) {
|
|
591
|
+
validateMailPacketRecord(r, mailboxId);
|
|
592
|
+
requireMail(r.seq > seq, "invalid_response", "unordered packets");
|
|
593
|
+
seq = r.seq;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Synchronous process-local relay model. All validation precedes mutation;
|
|
598
|
+
* persist the snapshot atomically before emitting a successful HTTP response.
|
|
599
|
+
* The short-lived nonce cache is not part of the snapshot.
|
|
600
|
+
*/
|
|
601
|
+
export class MailRelayStore {
|
|
602
|
+
origin;
|
|
603
|
+
boxes = new Map();
|
|
604
|
+
nonces;
|
|
605
|
+
clock;
|
|
606
|
+
maxPackets;
|
|
607
|
+
maxBytes;
|
|
608
|
+
constructor(origin, options = {}) {
|
|
609
|
+
this.origin = origin;
|
|
610
|
+
validateOrigin(origin);
|
|
611
|
+
this.nonces = options.nonceStore ?? new MemoryNonceStore();
|
|
612
|
+
this.clock = options.clock ?? Date.now;
|
|
613
|
+
this.maxPackets = options.maxPackets ?? 10000;
|
|
614
|
+
this.maxBytes = options.maxBytes ?? 64 * MAIL_MAX_PACKET_BYTES;
|
|
615
|
+
requireMail(Number.isSafeInteger(this.maxPackets) &&
|
|
616
|
+
this.maxPackets > 0 &&
|
|
617
|
+
Number.isSafeInteger(this.maxBytes) &&
|
|
618
|
+
this.maxBytes > 0, "invalid_request", "invalid quota");
|
|
619
|
+
const s = options.snapshot;
|
|
620
|
+
if (s) {
|
|
621
|
+
requireMail(s.version === 1 && s.origin === origin, "invalid_request", "snapshot origin mismatch");
|
|
622
|
+
for (const m of s.mailboxes) {
|
|
623
|
+
const box = {
|
|
624
|
+
current: clone(m.current),
|
|
625
|
+
lastSeq: m.last_seq,
|
|
626
|
+
bytes: 0,
|
|
627
|
+
packets: new Map(),
|
|
628
|
+
tombstones: new Map(),
|
|
629
|
+
blockedSenders: new Set(m.blocked_senders),
|
|
630
|
+
};
|
|
631
|
+
for (const record of m.packets) {
|
|
632
|
+
const size = mailCanonicalBytes(record.packet).length;
|
|
633
|
+
box.packets.set(record.packet_id, { record: clone(record), size });
|
|
634
|
+
box.bytes += size;
|
|
635
|
+
}
|
|
636
|
+
for (const t of m.tombstones)
|
|
637
|
+
box.tombstones.set(t.packet_id, {
|
|
638
|
+
accepted_at: t.accepted_at,
|
|
639
|
+
expires_at: t.expires_at,
|
|
640
|
+
});
|
|
641
|
+
this.boxes.set(m.current.envelope.event.payload.mailbox_id, box);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
snapshot() {
|
|
646
|
+
return {
|
|
647
|
+
version: 1,
|
|
648
|
+
origin: this.origin,
|
|
649
|
+
mailboxes: [...this.boxes.values()].map((b) => ({
|
|
650
|
+
current: clone(b.current),
|
|
651
|
+
last_seq: b.lastSeq,
|
|
652
|
+
blocked_senders: [...b.blockedSenders],
|
|
653
|
+
packets: [...b.packets.values()].map((p) => clone(p.record)),
|
|
654
|
+
tombstones: [...b.tombstones].map(([packet_id, t]) => ({
|
|
655
|
+
packet_id,
|
|
656
|
+
...t,
|
|
657
|
+
})),
|
|
658
|
+
})),
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
discovery() {
|
|
662
|
+
return { protocol: MAIL_PROTOCOL, service: this.origin };
|
|
663
|
+
}
|
|
664
|
+
publish(card, now = this.clock()) {
|
|
665
|
+
card = clone(card);
|
|
666
|
+
safeTime(now);
|
|
667
|
+
validateMailboxCard(card);
|
|
668
|
+
const p = card.event.payload, b = this.boxes.get(p.mailbox_id);
|
|
669
|
+
if (b) {
|
|
670
|
+
const current = b.current.envelope;
|
|
671
|
+
requireMail(current.event.actor === card.event.actor, "mailbox_conflict", "mailbox belongs to another owner");
|
|
672
|
+
if (current.hash === card.hash)
|
|
673
|
+
return clone(b.current);
|
|
674
|
+
if (card.event.nonce <= current.event.nonce)
|
|
675
|
+
throw protocolError("nonce_not_greater", "card nonce does not advance", {
|
|
676
|
+
max_nonce: current.event.nonce,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
else
|
|
680
|
+
requireMail(p.routes.includes(this.origin), "permission_denied", "first card must list this relay");
|
|
681
|
+
verifyTimestamp(card.event.created_at, now, MAIL_FUTURE_SKEW_MS);
|
|
682
|
+
// The final fallible step, so a rejected card consumes no nonce.
|
|
683
|
+
this.nonces.checkAndUpdate(card.event.actor, card.event.nonce, now, 2 * MAIL_FUTURE_SKEW_MS);
|
|
684
|
+
const record = { envelope: card, accepted_at: now };
|
|
685
|
+
if (b)
|
|
686
|
+
b.current = record;
|
|
687
|
+
else
|
|
688
|
+
this.boxes.set(p.mailbox_id, {
|
|
689
|
+
current: record,
|
|
690
|
+
lastSeq: 0,
|
|
691
|
+
bytes: 0,
|
|
692
|
+
packets: new Map(),
|
|
693
|
+
tombstones: new Map(),
|
|
694
|
+
blockedSenders: new Set(),
|
|
695
|
+
});
|
|
696
|
+
return clone(record);
|
|
697
|
+
}
|
|
698
|
+
card(mailboxId) {
|
|
699
|
+
pathId(mailboxId, 16);
|
|
700
|
+
const b = this.boxes.get(mailboxId);
|
|
701
|
+
requireMail(b, "mailbox_unavailable");
|
|
702
|
+
return clone(b.current);
|
|
703
|
+
}
|
|
704
|
+
publishJson(text, now = this.clock()) {
|
|
705
|
+
requireMail(encoder.encode(text).length <= MAIL_MAX_PACKET_BYTES, "payload_too_large");
|
|
706
|
+
return this.publish(parseStrictJson(text), now);
|
|
707
|
+
}
|
|
708
|
+
deliverJson(mailboxId, text, now = this.clock()) {
|
|
709
|
+
requireMail(encoder.encode(text).length <= MAIL_MAX_PACKET_BYTES, "payload_too_large");
|
|
710
|
+
return this.deliver(mailboxId, parseStrictJson(text), now);
|
|
711
|
+
}
|
|
712
|
+
/** Packets are asynchronous signed objects: no live-write window or nonce maximum applies. */
|
|
713
|
+
deliver(mailboxId, packet, now = this.clock()) {
|
|
714
|
+
packet = clone(packet);
|
|
715
|
+
safeTime(now);
|
|
716
|
+
pathId(mailboxId, 16);
|
|
717
|
+
const bytes = packetBytes(packet), size = bytes.length;
|
|
718
|
+
const h = packet.event.payload.header;
|
|
719
|
+
requireMail(h.mailbox_id === mailboxId, "invalid_packet", "path mailbox mismatch");
|
|
720
|
+
const b = this.boxes.get(mailboxId);
|
|
721
|
+
requireMail(b, "mailbox_unavailable");
|
|
722
|
+
const id = packet.hash, prior = b.tombstones.get(id);
|
|
723
|
+
if (prior)
|
|
724
|
+
return { packet_id: id, accepted_at: prior.accepted_at };
|
|
725
|
+
const c = b.current.envelope, p = c.event.payload;
|
|
726
|
+
requireMail(p.routes.includes(this.origin), "mailbox_unavailable");
|
|
727
|
+
requireMail(h.card_hash === c.hash && now < p.expires_at, "stale_card");
|
|
728
|
+
requireMail(now < h.expires_at, "packet_expired");
|
|
729
|
+
requireMail(h.expires_at <= p.receive_until &&
|
|
730
|
+
h.expires_at - now <= MAIL_MAX_TTL_MS + MAIL_FUTURE_SKEW_MS, "invalid_packet", "packet lifetime exceeds bound");
|
|
731
|
+
requireMail(packet.event.created_at <= now + MAIL_FUTURE_SKEW_MS, "timestamp_out_of_window", "future packet");
|
|
732
|
+
requireMail(size <= p.max_packet_bytes, "payload_too_large", "packet exceeds card limit");
|
|
733
|
+
this.dropExpired(b, now);
|
|
734
|
+
requireMail(b.packets.size < this.maxPackets &&
|
|
735
|
+
b.bytes + size <= this.maxBytes &&
|
|
736
|
+
b.lastSeq < Number.MAX_SAFE_INTEGER, "rate_limited", "mailbox storage quota exhausted");
|
|
737
|
+
requireMail(!b.blockedSenders.has(packet.event.actor), "permission_denied", "sender not admitted");
|
|
738
|
+
b.lastSeq += 1;
|
|
739
|
+
b.packets.set(id, {
|
|
740
|
+
record: { packet_id: id, packet, accepted_at: now, seq: b.lastSeq },
|
|
741
|
+
size,
|
|
742
|
+
});
|
|
743
|
+
b.bytes += size;
|
|
744
|
+
b.tombstones.set(id, { accepted_at: now, expires_at: h.expires_at });
|
|
745
|
+
return { packet_id: id, accepted_at: now };
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Owner sender policy for new packets. Mail defines no management endpoint:
|
|
749
|
+
* the host authenticates the mailbox owner before calling this.
|
|
750
|
+
*/
|
|
751
|
+
setSenderBlocked(mailboxId, sender, blocked) {
|
|
752
|
+
pathId(mailboxId, 16);
|
|
753
|
+
const b = this.boxes.get(mailboxId);
|
|
754
|
+
requireMail(b, "mailbox_unavailable");
|
|
755
|
+
validateAgentId(sender);
|
|
756
|
+
requireMail(typeof blocked === "boolean", "invalid_request");
|
|
757
|
+
if (blocked)
|
|
758
|
+
b.blockedSenders.add(sender);
|
|
759
|
+
else
|
|
760
|
+
b.blockedSenders.delete(sender);
|
|
761
|
+
}
|
|
762
|
+
ownerBox(mailboxId, jwt, now) {
|
|
763
|
+
pathId(mailboxId, 16);
|
|
764
|
+
const claims = ownerClaims(jwt, this.origin, now);
|
|
765
|
+
const b = this.boxes.get(mailboxId);
|
|
766
|
+
requireMail(b, "mailbox_unavailable");
|
|
767
|
+
requireMail(claims.iss === b.current.envelope.event.actor, "permission_denied", "not mailbox owner");
|
|
768
|
+
return b;
|
|
769
|
+
}
|
|
770
|
+
list(mailboxId, jwt, options = {}) {
|
|
771
|
+
const now = options.now ?? this.clock();
|
|
772
|
+
safeTime(now);
|
|
773
|
+
const b = this.ownerBox(mailboxId, jwt, now), limit = options.limit ?? 100, cursor = options.cursor;
|
|
774
|
+
requireMail(Number.isSafeInteger(limit) && limit >= 1 && limit <= 1000, "invalid_request", "invalid limit");
|
|
775
|
+
// The cursor is the last returned seq; ownership is checked by the JWT.
|
|
776
|
+
requireMail(cursor === undefined ||
|
|
777
|
+
(/^(0|[1-9][0-9]*)$/.test(cursor) &&
|
|
778
|
+
Number.isSafeInteger(Number(cursor))), "invalid_request", "invalid cursor");
|
|
779
|
+
const after = cursor === undefined ? 0 : Number(cursor);
|
|
780
|
+
const rows = [...b.packets.values()]
|
|
781
|
+
.map((x) => x.record)
|
|
782
|
+
.filter((r) => r.seq > after && now < r.packet.event.payload.header.expires_at);
|
|
783
|
+
const result = clone(rows.slice(0, limit));
|
|
784
|
+
return rows.length > limit
|
|
785
|
+
? { result, next_cursor: String(result.at(-1).seq) }
|
|
786
|
+
: { result };
|
|
787
|
+
}
|
|
788
|
+
delete(mailboxId, packetId, jwt, now = this.clock()) {
|
|
789
|
+
const b = this.ownerBox(mailboxId, jwt, now);
|
|
790
|
+
pathId(packetId, 32);
|
|
791
|
+
const stored = b.packets.get(packetId);
|
|
792
|
+
if (stored) {
|
|
793
|
+
b.packets.delete(packetId);
|
|
794
|
+
b.bytes -= stored.size;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
/** Drop expired packets and tombstones, then forget mailboxes whose current card's `receive_until` passed. */
|
|
798
|
+
prune(now = this.clock()) {
|
|
799
|
+
safeTime(now);
|
|
800
|
+
for (const [mailboxId, b] of this.boxes) {
|
|
801
|
+
this.dropExpired(b, now);
|
|
802
|
+
for (const [id, t] of b.tombstones)
|
|
803
|
+
if (now >= t.expires_at)
|
|
804
|
+
b.tombstones.delete(id);
|
|
805
|
+
if (now >= b.current.envelope.event.payload.receive_until)
|
|
806
|
+
this.boxes.delete(mailboxId);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
dropExpired(b, now) {
|
|
810
|
+
for (const [id, x] of b.packets)
|
|
811
|
+
if (now >= x.record.packet.event.payload.header.expires_at) {
|
|
812
|
+
b.packets.delete(id);
|
|
813
|
+
b.bytes -= x.size;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
//# sourceMappingURL=mail.js.map
|