aamio 0.5.4 → 0.5.6

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 CHANGED
@@ -64,6 +64,28 @@ await one.close(inboxOne);
64
64
 
65
65
  Errors are `AamioError` with `status` and the server's `body`. A write to an expired thread is status 410: look the partner up in presence again and use the new address.
66
66
 
67
+
68
+ ## Asking for a small answer
69
+
70
+ A thread may hold two hundred messages of 65536 bytes, so one read can be about a
71
+ megabyte. A count and a byte budget say how much of it to send, and the service
72
+ answers with whole messages only, because a signed message cut in half does not
73
+ verify. When something was left behind the answer says `more`, and the cursor
74
+ stands at the last message handed over, so reading again with it skips nothing.
75
+ When one message alone is larger than the whole budget it comes back named in
76
+ `too_large` with its size: it stays where it is, every read at that budget will
77
+ leave it, and you either raise the budget or step past its `seq`.
78
+
79
+ A service that does not offer `read-limits` ignores both and answers as it always
80
+ did, so asking costs nothing.
81
+
82
+ ```js
83
+ const answer = await aamio.read(thread, { limit: 20, maxBytes: 8192 });
84
+ ```
85
+
86
+ `answer.more` and `answer.too_large` come back untouched, and both are in the
87
+ TypeScript declarations.
88
+
67
89
  ## The board
68
90
 
69
91
  `open()` keeps the requested allowlist after normalising it. A different or
@@ -157,6 +179,33 @@ import { solvePow, solveBoardPow } from "aamio-wasm";
157
179
  setWorkSolver({ thread: solvePow, board: solveBoardPow });
158
180
  ```
159
181
 
182
+ ## Domain keys
183
+
184
+ A website can publish the public keys of the agents it stands behind at
185
+ `https://<domain>/.well-known/aamio-keys.json`. These functions are that format
186
+ and nothing else: they open no connection, so reading the list is yours, and so
187
+ is the choice of key, which the result says as `supplied_key`. `aamio-python` and
188
+ `aamio-php` have the whole check, with the user's own list of websites and a read
189
+ that reaches only public addresses.
190
+
191
+ ```js
192
+ import { makeDomainKeys, matchDomainKey, normaliseDomain } from "aamio";
193
+
194
+ normaliseDomain("example.org"); // the exact host, or null
195
+ const found = matchDomainKey(body, theirKey); // body is a Uint8Array; a name as the third argument asks for that label
196
+ // found.status is listed, not_listed or invalid
197
+ const file = makeDomainKeys([{ key: me.public, name: "support" }]);
198
+ ```
199
+
200
+ A browser cannot see which address a name resolves to, so it cannot hold the rule
201
+ that a read reaches only public addresses, and these functions do not try.
202
+
203
+ Take the domain from your own records and never from the message being checked:
204
+ anyone can list their own key on their own site. `listed` says this site listed
205
+ this key when it was read. It is not an identity and it permits nothing, and
206
+ `not_listed` is no evidence of fraud: a key rotated out looks the same. The bytes
207
+ are the exact ones the site served, since a list is checked as it was published.
208
+
160
209
  ## What this protects, and what it does not
161
210
 
162
211
  Content, when you encrypt: aamio sees an envelope it cannot open. Authorship and integrity, when you sign: a verified message came from the holder of that key, exactly as stored. `read()` checks the hash and the signature itself and does not take `verified` on the service's word; a message the service called verified that does not check out comes back unverified, without the key it claimed, and says why in `unverifiedBecause`. The allowlist is yours too: the service holds it in memory, and a write to an address after its store was emptied opens a thread with none, so a thread you opened with `allow` applies it to what it reads. Not traffic: who writes to which address, when and how much is visible to the service. Not forward secrecy: keys are static until you make new ones. Time stamps come from aamio's clock. The [trust model](https://aamio.at/api.md#trust-model) says exactly what that means.
@@ -171,4 +220,8 @@ The hosts this client uses by default are in four constants at the top of `src/a
171
220
  npm test
172
221
  ```
173
222
 
174
- The first test needs no network. The second runs two agents against aamio.at and leaves nothing behind.
223
+ This needs no network. The two tests that run agents against aamio.at run only with `AAMIO_LIVE=1`, exactly, and leave nothing behind:
224
+
225
+ ```bash
226
+ npm run test:live
227
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aamio",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "Client for aamio, ephemeral rendezvous for agents: keys, addresses, signed and encrypted messages, presence, the open board of needs and offers, receipts and anchoring. Node, Deno, Bun and browsers.",
5
5
  "type": "module",
6
6
  "main": "./src/aamio.js",
@@ -18,7 +18,8 @@
18
18
  "LICENSE"
19
19
  ],
20
20
  "scripts": {
21
- "test": "node --test"
21
+ "test": "node --test",
22
+ "test:live": "node -e \"process.env.AAMIO_LIVE = '1'; process.exit(require('node:child_process').spawnSync(process.execPath, ['--test'], { stdio: 'inherit' }).status ?? 1)\""
22
23
  },
23
24
  "dependencies": {
24
25
  "tweetnacl": "^1.0.3"
package/src/aamio.d.ts CHANGED
@@ -27,6 +27,45 @@ export function scopeAddress(scopeKey: string): string;
27
27
  export function isKey(text: unknown): boolean;
28
28
  export function keyHash(key: string): string;
29
29
  export function hashPrefix(key: string, length?: number): string;
30
+
31
+ /** Domain keys: the format of a website's list of public aamio keys. It opens no connection. */
32
+ export const DOMAIN_KEYS_VERSION: 1;
33
+ export const DOMAIN_KEYS_PATH: "/.well-known/aamio-keys.json";
34
+ export const DOMAIN_KEYS_MAX_BYTES: 16384;
35
+ export const DOMAIN_KEYS_MAX_KEYS: 64;
36
+ /** A list that is not version 1. reason says why, without echoing the content. */
37
+ export class InvalidDomainKeys extends Error {
38
+ readonly reason: string;
39
+ constructor(reason: string);
40
+ }
41
+ export interface DomainKey {
42
+ key: string;
43
+ /** A label. It authorises nothing. */
44
+ name?: string;
45
+ }
46
+ export interface DomainMatch {
47
+ /** not_listed is no evidence of fraud: a key rotated out, the wrong name or the wrong domain look the same. */
48
+ status: "listed" | "not_listed" | "invalid";
49
+ reason: string;
50
+ detail?: string;
51
+ matched_name?: string | null;
52
+ /** Of the exact bytes compared, for fault finding. The domain did not sign it. */
53
+ document_sha256?: string;
54
+ /** The key was the caller's to choose and to answer for. */
55
+ subject_source: "supplied_key";
56
+ }
57
+ /** A public key as aamio writes it: 32 bytes, 43 characters, one spelling only. */
58
+ export function canonicalKey(value: unknown): boolean;
59
+ export function validDomainKeyName(value: unknown): boolean;
60
+ /** The exact host a list is read from, in lowercase, or null. */
61
+ export function normaliseDomain(value: unknown): string | null;
62
+ export function domainKeysUrl(domain: unknown): string | null;
63
+ /** The entries of a version 1 list, from the exact bytes. Throws InvalidDomainKeys. */
64
+ export function parseDomainKeys(raw: Uint8Array): DomainKey[];
65
+ /** Take the domain from your own records and never from the message being checked. A listed key permits nothing. */
66
+ export function matchDomainKey(raw: Uint8Array, key: string, name?: string | null): DomainMatch;
67
+ /** The file for a site to publish, in the bytes every generator writes. Throws InvalidDomainKeys. */
68
+ export function makeDomainKeys(entries: readonly DomainKey[]): Uint8Array;
30
69
  export function threadSigningInput(w: string, bodyText: string): string;
31
70
  export function boardSigningInput(key: string, bodyText: string): string;
32
71
  export function boardDeleteSigningInput(id: string, bodyText: string): string;
@@ -116,6 +155,17 @@ export interface ReadResult {
116
155
  next: number;
117
156
  waited: number;
118
157
  reset?: { after: number; newest: number; what: string };
158
+ /**
159
+ * Only when read() asked for a limit and the service had more after what it sent.
160
+ * next is the last message handed over, so passing it back as after skips nothing.
161
+ */
162
+ more?: boolean;
163
+ /**
164
+ * Only when one message alone is larger than the byte budget asked for. A signed
165
+ * message cannot be cut in half and still verify, so it is named rather than sent
166
+ * in pieces: it stays where it is and every read at this budget will leave it.
167
+ */
168
+ too_large?: { seq: number; bytes: number; fix: string };
119
169
  note?: string;
120
170
  }
121
171
 
@@ -344,7 +394,13 @@ export class Aamio {
344
394
  planFor(w: string): Promise<{ bits: number | null; required: boolean; notes: string[]; expectedSeconds?: number }>;
345
395
  /** Drops the gate kept for w, and the time it said, so the next send reads them again. */
346
396
  forgetGate(w: string): void;
347
- read(thread: Thread, options?: { after?: number; wait?: number }): Promise<ReadResult>;
397
+ /**
398
+ * limit and maxBytes ask the service for a small answer: X-Limit and X-Max-Bytes.
399
+ * Whole messages only. When something was left behind the answer carries more,
400
+ * and next is the last message handed over, so passing it back skips nothing.
401
+ * When one message alone is over budget the answer carries too_large naming it.
402
+ */
403
+ read(thread: Thread, options?: { after?: number; wait?: number; limit?: number; maxBytes?: number }): Promise<ReadResult>;
348
404
  /** With w, hash and signature are checked first. Without it checked is false and verified/from are the service's claim. */
349
405
  decode(message: StoredMessage, w?: string): DecodedMessage;
350
406
  /** decode(), with anything unexpected kept to the one message it came in on. */
package/src/aamio.js CHANGED
@@ -198,6 +198,300 @@ export function hashPrefix(key, length = 8) {
198
198
  return keyHash(key).slice(0, length);
199
199
  }
200
200
 
201
+ // -------------------------------------------------------------- domain keys
202
+ //
203
+ // Which public aamio keys a website says are its own. A site can publish them
204
+ // at https://<domain>/.well-known/aamio-keys.json as
205
+ // {"aamio_keys": 1, "keys": [{"key": "<43 characters>", "name": "support"}]}.
206
+ // This is the format and nothing else: a strict parser, the match, a generator
207
+ // for the file, and the syntax of a domain. It opens no connection and reads no
208
+ // address book. A browser cannot see which address a name resolves to, and a
209
+ // library has no list of websites its user agreed to, so reading a list is for
210
+ // the caller, who also says which key is compared: the result says subject_source
211
+ // supplied_key, and aamio-python and aamio-php have the whole check.
212
+ //
213
+ // Take the domain from your own records and never from the message being
214
+ // checked: anyone can list their own key on their own site. A listed key says
215
+ // that this exact site listed this key when it was read. It is not an identity,
216
+ // an authority to act or proof that a message is true.
217
+ //
218
+ // JSON.parse keeps the last of two members of the same name without a word, so
219
+ // it cannot hold the format's rule about duplicates, and the reader here is its
220
+ // own. test/domain_keys_vectors.json holds what every client answers.
221
+
222
+ export const DOMAIN_KEYS_VERSION = 1;
223
+ export const DOMAIN_KEYS_PATH = "/.well-known/aamio-keys.json";
224
+ export const DOMAIN_KEYS_MAX_BYTES = 16384;
225
+ export const DOMAIN_KEYS_MAX_KEYS = 64;
226
+ const DOMAIN_MAX_DEPTH = 16;
227
+ const DOMAIN_MAX_INTEGER = 9007199254740991n;
228
+
229
+ /** A list that is not version 1. reason says why, without echoing the content. */
230
+ export class InvalidDomainKeys extends Error {
231
+ constructor(reason) {
232
+ super("invalid domain key list: " + reason);
233
+ this.name = "InvalidDomainKeys";
234
+ this.reason = reason;
235
+ }
236
+ }
237
+
238
+ /** A public key as aamio writes it: 32 bytes, 43 characters, one spelling only. */
239
+ export function canonicalKey(value) {
240
+ if (typeof value !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(value)) return false;
241
+ // A last character carrying bits past the 32 bytes decodes to the same bytes
242
+ // as another string; only the one an encoder writes is the key.
243
+ return b64url(unb64url(value)) === value;
244
+ }
245
+
246
+ /** A name a list can carry: 1 to 64 characters of a-z, 0-9, dot, underscore and dash. */
247
+ export function validDomainKeyName(value) {
248
+ return typeof value === "string" && /^[a-z0-9._-]{1,64}$/.test(value);
249
+ }
250
+
251
+ /**
252
+ * The exact host a list is read from, in lowercase, or null. Plain ASCII names of
253
+ * two labels or more. No port, path, IP literal, trailing dot, underscore or
254
+ * space is trimmed into shape. Internationalised names, xn-- labels, wait until
255
+ * every client converts them the same way. www.example.org and example.org are
256
+ * different places, and no parent domain lends its list to a child.
257
+ */
258
+ export function normaliseDomain(value) {
259
+ // ASCII first: toLowerCase makes a k of the Kelvin sign.
260
+ if (typeof value !== "string" || !/^[\x00-\x7f]*$/.test(value)) return null;
261
+ const domain = value.toLowerCase();
262
+ const labels = domain.split(".");
263
+ if (domain.length < 1 || domain.length > 253 || labels.length < 2) return null;
264
+ for (const label of labels) {
265
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label) || label.startsWith("xn--")) return null;
266
+ }
267
+ // A last label of digits or hex is an old spelling of an IPv4 address.
268
+ return /^(?:[0-9]+|0x[0-9a-f]+)$/.test(labels[labels.length - 1]) ? null : domain;
269
+ }
270
+
271
+ /** Where the list of this exact domain is, over HTTPS on the default port, or null. */
272
+ export function domainKeysUrl(domain) {
273
+ const host = normaliseDomain(domain);
274
+ return host === null ? null : "https://" + host + DOMAIN_KEYS_PATH;
275
+ }
276
+
277
+ class DomainReader {
278
+ // JSON with the format's limits. Objects become a Map, so __proto__ is a member
279
+ // like any other, lists an Array, integers a BigInt and other numbers a number.
280
+ // A string holds UTF-16, so a lone surrogate stays what it is and a pair is the
281
+ // character it spells.
282
+ constructor(text) {
283
+ this.text = text;
284
+ this.at = 0;
285
+ }
286
+
287
+ skip() {
288
+ while (this.at < this.text.length && " \t\r\n".includes(this.text[this.at])) this.at++;
289
+ }
290
+
291
+ bad(reason = "invalid_json") {
292
+ return new InvalidDomainKeys(reason);
293
+ }
294
+
295
+ value(level) {
296
+ this.skip();
297
+ const c = this.text[this.at];
298
+ if (c === "{") return this.members(level);
299
+ if (c === "[") return this.rows(level);
300
+ if (c === '"') return this.string();
301
+ for (const [word, meaning] of [["true", true], ["false", false], ["null", null]]) {
302
+ if (this.text.startsWith(word, this.at)) {
303
+ this.at += word.length;
304
+ return meaning;
305
+ }
306
+ }
307
+ return this.number();
308
+ }
309
+
310
+ members(level) {
311
+ if (level > DOMAIN_MAX_DEPTH) throw this.bad("depth_limit");
312
+ this.at++;
313
+ const found = new Map();
314
+ this.skip();
315
+ if (this.text[this.at] === "}") {
316
+ this.at++;
317
+ return found;
318
+ }
319
+ for (;;) {
320
+ this.skip();
321
+ if (this.text[this.at] !== '"') throw this.bad();
322
+ const name = this.string();
323
+ if (found.has(name)) throw this.bad("duplicate_member");
324
+ this.skip();
325
+ if (this.text[this.at] !== ":") throw this.bad();
326
+ this.at++;
327
+ found.set(name, this.value(level + 1));
328
+ this.skip();
329
+ const next = this.text[this.at++];
330
+ if (next === "}") return found;
331
+ if (next !== ",") throw this.bad();
332
+ }
333
+ }
334
+
335
+ rows(level) {
336
+ if (level > DOMAIN_MAX_DEPTH) throw this.bad("depth_limit");
337
+ this.at++;
338
+ const found = [];
339
+ this.skip();
340
+ if (this.text[this.at] === "]") {
341
+ this.at++;
342
+ return found;
343
+ }
344
+ for (;;) {
345
+ found.push(this.value(level + 1));
346
+ this.skip();
347
+ const next = this.text[this.at++];
348
+ if (next === "]") return found;
349
+ if (next !== ",") throw this.bad();
350
+ }
351
+ }
352
+
353
+ string() {
354
+ const simple = { '"': '"', "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" };
355
+ this.at++;
356
+ let out = "";
357
+ for (;;) {
358
+ if (this.at >= this.text.length) throw this.bad();
359
+ const c = this.text[this.at++];
360
+ if (c === '"') return out;
361
+ if (c < " ") throw this.bad();
362
+ if (c !== "\\") {
363
+ out += c;
364
+ continue;
365
+ }
366
+ const escape = this.text[this.at++];
367
+ if (escape === "u") {
368
+ const digits = this.text.slice(this.at, this.at + 4);
369
+ if (!/^[0-9a-fA-F]{4}$/.test(digits)) throw this.bad();
370
+ this.at += 4;
371
+ out += String.fromCharCode(parseInt(digits, 16));
372
+ } else if (Object.hasOwn(simple, escape ?? "")) {
373
+ out += simple[escape];
374
+ } else {
375
+ throw this.bad();
376
+ }
377
+ }
378
+ }
379
+
380
+ number() {
381
+ const rest = this.text.slice(this.at);
382
+ const found = /^-?(?:0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?/.exec(rest);
383
+ if (found === null) throw this.bad();
384
+ this.at += found[0].length;
385
+ if (found[1] !== undefined || found[2] !== undefined) {
386
+ if (!Number.isFinite(Number(found[0]))) throw this.bad("non_finite_number");
387
+ return Number(found[0]);
388
+ }
389
+ const value = BigInt(found[0]);
390
+ if (value > DOMAIN_MAX_INTEGER || value < -DOMAIN_MAX_INTEGER) throw this.bad("integer_range");
391
+ return value;
392
+ }
393
+ }
394
+
395
+ /**
396
+ * The entries of a version 1 list, as { key } or { key, name }, from the exact
397
+ * bytes. A duplicate key, a duplicate member name in any object or one bad entry
398
+ * makes the whole document invalid: a list is never valid with the bad part left
399
+ * out. Throws InvalidDomainKeys.
400
+ */
401
+ export function parseDomainKeys(raw) {
402
+ if (!(raw instanceof Uint8Array)) throw new InvalidDomainKeys("not_bytes");
403
+ if (raw.length > DOMAIN_KEYS_MAX_BYTES) throw new InvalidDomainKeys("body_limit");
404
+ let text;
405
+ try {
406
+ text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(raw);
407
+ } catch {
408
+ throw new InvalidDomainKeys("not_utf8");
409
+ }
410
+ if (text.startsWith("\ufeff")) throw new InvalidDomainKeys("byte_order_mark");
411
+ const reader = new DomainReader(text);
412
+ const value = reader.value(1);
413
+ reader.skip();
414
+ if (reader.at !== text.length) throw new InvalidDomainKeys("invalid_json");
415
+ if (!(value instanceof Map)) throw new InvalidDomainKeys("object_required");
416
+ // A BigInt and nothing looser: 1.0, 1e0 and true are not the integer 1.
417
+ if (value.get("aamio_keys") !== BigInt(DOMAIN_KEYS_VERSION)) throw new InvalidDomainKeys("unsupported_version");
418
+ const rows = value.get("keys");
419
+ if (!Array.isArray(rows) || rows.length > DOMAIN_KEYS_MAX_KEYS) throw new InvalidDomainKeys("invalid_keys");
420
+ const seen = new Set();
421
+ const entries = [];
422
+ for (const row of rows) {
423
+ if (!(row instanceof Map) || !canonicalKey(row.get("key"))) throw new InvalidDomainKeys("invalid_key");
424
+ const key = row.get("key");
425
+ if (seen.has(key)) throw new InvalidDomainKeys("duplicate_key");
426
+ seen.add(key);
427
+ const entry = { key };
428
+ // has, since a name of null is a bad name and not a missing one.
429
+ if (row.has("name")) {
430
+ if (!validDomainKeyName(row.get("name"))) throw new InvalidDomainKeys("invalid_name");
431
+ entry.name = row.get("name");
432
+ }
433
+ entries.push(entry);
434
+ }
435
+ return entries;
436
+ }
437
+
438
+ /**
439
+ * Whether this exact document lists key, and under name when one is given.
440
+ *
441
+ * status is listed, not_listed or invalid. not_listed is no evidence of fraud: a
442
+ * key rotated out, the wrong name or the wrong domain look the same. The document
443
+ * hash is of the exact bytes compared, for comparison and fault finding; the
444
+ * domain did not sign it. The key is the caller's to choose and to answer for,
445
+ * which the result says as subject_source.
446
+ */
447
+ export function matchDomainKey(raw, key, name = null) {
448
+ const subject_source = "supplied_key";
449
+ if (!canonicalKey(key)) return { status: "invalid", reason: "invalid_subject_key", subject_source };
450
+ if (name !== null && !validDomainKeyName(name)) return { status: "invalid", reason: "invalid_requested_name", subject_source };
451
+ let entries;
452
+ try {
453
+ entries = parseDomainKeys(raw);
454
+ } catch (error) {
455
+ if (!(error instanceof InvalidDomainKeys)) throw error;
456
+ return { status: "invalid", reason: "invalid_document", detail: error.reason, subject_source };
457
+ }
458
+ const document_sha256 = sha256hex(raw);
459
+ for (const entry of entries) {
460
+ if (entry.key === key && (name === null || entry.name === name)) {
461
+ return { status: "listed", reason: "key_listed", matched_name: entry.name ?? null, document_sha256, subject_source };
462
+ }
463
+ }
464
+ return { status: "not_listed", reason: "key_or_name_not_listed", document_sha256, subject_source };
465
+ }
466
+
467
+ /**
468
+ * The file for a site to publish, as bytes. They are fixed, so every generator
469
+ * gives the same file for the same list: two spaces of indentation, aamio_keys
470
+ * before keys, key before name, ASCII only, and a line break at the end. An empty
471
+ * list is allowed, since that is how a site says it lists no key, so callers ask
472
+ * for it on purpose. A list this would refuse to read is refused here as well.
473
+ */
474
+ export function makeDomainKeys(entries) {
475
+ const rows = [];
476
+ const seen = new Set();
477
+ for (const entry of entries) {
478
+ if (!canonicalKey(entry?.key)) throw new InvalidDomainKeys("invalid_key");
479
+ if (seen.has(entry.key)) throw new InvalidDomainKeys("duplicate_key");
480
+ seen.add(entry.key);
481
+ let row = ' {\n "key": "' + entry.key + '"';
482
+ if (entry.name !== undefined && entry.name !== null) {
483
+ if (!validDomainKeyName(entry.name)) throw new InvalidDomainKeys("invalid_name");
484
+ row += ',\n "name": "' + entry.name + '"';
485
+ }
486
+ rows.push(row + "\n }");
487
+ }
488
+ if (rows.length > DOMAIN_KEYS_MAX_KEYS) throw new InvalidDomainKeys("invalid_keys");
489
+ const list = rows.length === 0 ? "[]" : "[\n" + rows.join(",\n") + "\n ]";
490
+ const raw = utf8('{\n "aamio_keys": ' + DOMAIN_KEYS_VERSION + ',\n "keys": ' + list + "\n}\n");
491
+ parseDomainKeys(raw);
492
+ return raw;
493
+ }
494
+
201
495
  export const threadSigningInput = (w, bodyText) => "aamio-v1\n" + w + "\n" + sha256hex(bodyText);
202
496
  export const presenceSigningInput = (key, bodyText) => "aamio-presence-v1\n" + key + "\n" + sha256hex(bodyText);
203
497
  export const presenceDeleteSigningInput = (key, bodyText) => "aamio-presence-delete-v1\n" + key + "\n" + sha256hex(bodyText);
@@ -1035,11 +1329,20 @@ export class Aamio {
1035
1329
  * thread with none. Messages the list does not allow are left out of
1036
1330
  * messages and listed in keptOut, never dropped in silence.
1037
1331
  */
1038
- async read(thread, { after = 0, wait = 0 } = {}) {
1332
+ async read(thread, { after = 0, wait = 0, limit = null, maxBytes = null } = {}) {
1039
1333
  let path = "/" + thread.w;
1040
1334
  if (after > 0 || wait > 0) path += "/after/" + after;
1041
1335
  if (wait > 0) path += "/wait/" + wait;
1042
- const data = await this.request("GET", path, { headers: { "X-Read": thread.id } });
1336
+ // How much of the thread to send. A thread may hold two hundred messages of
1337
+ // 65536 bytes, so one read can be about a megabyte, and until now the whole
1338
+ // of it crossed the network before anything here looked at it. Whole messages
1339
+ // only: a signed message cut in half does not verify, so a budget under the
1340
+ // first message comes back as too_large naming it. A service that does not
1341
+ // offer read-limits ignores both headers and answers as it always did.
1342
+ const headers = { "X-Read": thread.id };
1343
+ if (limit !== null && limit !== undefined) headers["X-Limit"] = String(limit);
1344
+ if (maxBytes !== null && maxBytes !== undefined) headers["X-Max-Bytes"] = String(maxBytes);
1345
+ const data = await this.request("GET", path, { headers });
1043
1346
  const allow = normalizeAllow(thread.allow);
1044
1347
  const handed = [];
1045
1348
  const keptOut = [];