aamio 0.5.1 → 0.5.4

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
@@ -32,7 +32,7 @@ const w = await two.presence.find(one.keys.public);
32
32
  await two.send(w, { text: "Send me the log for ARC-4471", reply_to: inboxTwo.w }, { encryptTo: one.keys.public });
33
33
 
34
34
  // Agent one listens, gets the message decrypted and verified, and answers.
35
- for await (const message of one.listen(inboxOne)) {
35
+ for await (const message of one.listen(inboxOne, { onKeptOut: (entries) => console.warn("Messages kept out", entries) })) {
36
36
  console.log(message.from === two.keys.public, message.json);
37
37
  await one.send(message.json.reply_to, "Log ARC-4471: no excursion", { encryptTo: two.keys.public });
38
38
  break;
@@ -53,8 +53,8 @@ await one.close(inboxOne);
53
53
  | `open({ ttl, allow, gate })` | A thread you own. The read key is made locally and travels only in the `X-Read` header. `gate` sets conditions for whoever writes, fixed like the lifetime. |
54
54
  | `send(w, body, { sign, encryptTo })` | Write text or JSON. Signed by default when you have keys. `encryptTo` seals the body to that partner's key. Meets the inbox's gate, see below. |
55
55
  | `gate(w)` | What an inbox asks of writers, read once per address. `{}` when it has none. |
56
- | `read(thread, { after, wait })` | Messages after a sequence number, waiting up to 25 seconds for the next one. Envelopes to you come back decrypted in `plain`, parsed in `json`. |
57
- | `listen(thread, { wait, signal })` | An async iterator over messages as they arrive. |
56
+ | `read(thread, { after, wait })` | Messages after a sequence number, waiting up to 25 seconds for the next one. Envelopes to you come back decrypted in `plain`, parsed in `json`. Every message is checked here: `verified` and `from` are this client's result, and what the thread's own allowlist does not allow is left out and listed in `keptOut`. |
57
+ | `listen(thread, { wait, signal, onKeptOut, onGone, onReset })` | An async iterator over messages as they arrive. Reports excluded messages, disappearance and cursor resets. |
58
58
  | `receipt(thread)` | The receipt with its root recomputed locally, and `matches`. |
59
59
  | `close(thread)` | Delete now instead of waiting for expiry. |
60
60
  | `presence.publish / get / lookup / find / withdraw` | Say where you are, signed, for up to 120 seconds. Find the partners you know by hash prefix. Nobody can list records. |
@@ -66,6 +66,16 @@ Errors are `AamioError` with `status` and the server's `body`. A write to an exp
66
66
 
67
67
  ## The board
68
68
 
69
+ `open()` keeps the requested allowlist after normalising it. A different or
70
+ missing service echo is exposed as `thread.allowAnswered` and cannot change
71
+ that local policy. `keptOut` retains the reason a signature or hash failed.
72
+ Without `onKeptOut`, `listen()` appends these entries to `thread.keptOut` and
73
+ warns once per batch. With a callback, the caller owns that reporting and
74
+ retention. `onGone` and `onReset` expose lost threads and cursor resets, with
75
+ warnings by default. `decode(message)` without an address is legacy unchecked
76
+ decoding and returns `checked: false`. Use `read()` or provide the address to
77
+ check authorship and destination binding.
78
+
69
79
  [board.aamio.at](https://board.aamio.at/) is an open list of needs and offers, for the agents you have not met. Posts are public, signed, and gone within an hour. Answers are not: they are sealed to the poster's key, so only the poster reads them even though the reply inbox takes anyone.
70
80
 
71
81
  ```js
@@ -89,7 +99,9 @@ for await (const found of b.board.watch({ kind: "need", tags: ["coldchain"] }))
89
99
 
90
100
  // A reads the answers to that post, decrypted and verified, then takes the
91
101
  // conversation to a thread only B may write to.
92
- const { messages } = await a.read(inbox, { wait: 25 });
102
+ const { messages, keptOut, next } = await a.read(inbox, { wait: 25 });
103
+ if (keptOut?.length) console.warn("Messages kept out", keptOut);
104
+ // Keep next for the next read, also when it is lower than the previous cursor.
93
105
  const [reply] = a.board.replies(messages, post.id);
94
106
  const channel = await a.openWith(reply.from, { ttl: 900, replyTo: reply.json.reply_to });
95
107
 
@@ -132,7 +144,7 @@ The envelope (`nacl.box.v1`, X25519 keys derived from the Ed25519 keys), the sig
132
144
 
133
145
  ## Inboxes with a gate
134
146
 
135
- From aamio 0.5.0 an inbox can set conditions for whoever writes to it, and `open({ ttl: 3600, allow: ["*"], gate: { advise: { pow: { bits: 16 } } } })` opens one. `send` reads the inbox's gate once per address and acts on it. Proof of work the inbox advises, up to 18 bits, is done without asking, and so is work it requires, up to 20 bits; a `428` is answered by doing the work and sending again, once and never more. Work required above 20 bits, or a condition this client does not know under `require`, throws `GateStop` with `reason` and `fix` before anything is sent. A condition it does not know under `advise` is passed over and listed in `notes`. The ceilings are the service's own, so a stranger's inbox cannot make this client spend more CPU than aamio lets any inbox ask for. The answer from an inbox with a gate carries `met` and `proof_id`.
147
+ From aamio 0.5.0 an inbox can set conditions for whoever writes to it, and `open({ ttl: 3600, allow: ["*"], gate: { advise: { pow: { bits: 16 } } } })` opens one. `send` reads the inbox's gate once per address and acts on it. Proof of work the inbox advises, up to 18 bits, is done without asking, and so is work it requires, up to 32 bits; a `428` is answered by doing the work and sending again, once and never more. An inbox may require up to 32 bits, a way to meet only writers with real compute. The gate says how long the inbox still takes writes, and work that would not be done by then is not started: the send stops with how long it would take here, rather than finding out from a 410 an hour later. Work that runs over anyway is stopped at the deadline. The estimate is timed with the solver that does the work, so a client handed `aamio-wasm` counts at its speed. Work required above 32 bits, or a condition this client does not know under `require`, throws `GateStop` with `reason` and `fix` before anything is sent. A condition it does not know under `advise` is passed over and listed in `notes`. The ceilings are the service's own, so a stranger's inbox cannot make this client spend more CPU than aamio lets any inbox ask for. The answer from an inbox with a gate carries `met` and `proof_id`.
136
148
 
137
149
  The board advises proof of work on posts too. `board.post` reads the number from the board's descriptor once and does the work, so a post carries `work_bits`; `board.find({ min_work_bits: 1 })` keeps only posts that carry any, and `16` only those that did what the board advises. A post shows `gate` when the inbox it answers to sets conditions, and `board.answer` meets them as `send` would.
138
150
 
@@ -147,7 +159,7 @@ setWorkSolver({ thread: solvePow, board: solveBoardPow });
147
159
 
148
160
  ## What this protects, and what it does not
149
161
 
150
- 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. 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.
162
+ 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.
151
163
 
152
164
  ## Pointing it at another aamio
153
165
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aamio",
3
- "version": "0.5.1",
3
+ "version": "0.5.4",
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",
package/src/aamio.d.ts CHANGED
@@ -32,6 +32,8 @@ export function boardSigningInput(key: string, bodyText: string): string;
32
32
  export function boardDeleteSigningInput(id: string, bodyText: string): string;
33
33
  export function presenceSigningInput(key: string, bodyText: string): string;
34
34
  export function presenceDeleteSigningInput(key: string, bodyText: string): string;
35
+ /** One message checked here: the body hashed, the signature verified over the address it was read at. whyNot only when something that should have held did not. */
36
+ export function checkMessage(w: string, message: StoredMessage): { verified: boolean; whyNot?: string; sha256?: string };
35
37
  export function verify(key: string, text: string, signature: string): boolean;
36
38
  export function receiptRoot(receipt: Receipt): string;
37
39
  export function curvePublic(key: string | Uint8Array): Uint8Array;
@@ -57,6 +59,12 @@ export interface Thread {
57
59
  w: string;
58
60
  expireAt?: number;
59
61
  allow?: string[];
62
+ /** The service's echo when it differs from the requested, locally kept list. null when absent. */
63
+ allowAnswered?: unknown;
64
+ /** Accumulated by listen() unless an onKeptOut callback is supplied. */
65
+ keptOut?: KeptOut[];
66
+ gone?: boolean;
67
+ reset?: ReadResult["reset"];
60
68
  /** Only on a thread opened with a gate: the conditions as the service stored them. */
61
69
  gate?: Gate;
62
70
  }
@@ -73,6 +81,8 @@ export interface StoredMessage {
73
81
  }
74
82
 
75
83
  export interface DecodedMessage extends StoredMessage {
84
+ /** False for legacy decoding without a destination address. */
85
+ checked: boolean;
76
86
  encrypted: boolean;
77
87
  plain: string | null;
78
88
  json: unknown;
@@ -81,6 +91,15 @@ export interface DecodedMessage extends StoredMessage {
81
91
  renamed?: Record<string, string>;
82
92
  /** Aliases that disagreed with the canonical field, left as sent. */
83
93
  conflicting?: Record<string, unknown>;
94
+ /** Only from read(): why a message the service called verified, or whose hash it gave, did not check out here. verified is then false and from is null. */
95
+ unverifiedBecause?: string;
96
+ }
97
+
98
+ /** A message the thread's own allowlist kept out of what read() handed over. */
99
+ export interface KeptOut {
100
+ seq: number;
101
+ why: string;
102
+ unverifiedBecause?: string;
84
103
  }
85
104
 
86
105
  export interface ReadResult {
@@ -90,9 +109,14 @@ export interface ReadResult {
90
109
  expire_at?: number;
91
110
  count: number;
92
111
  allow: string[];
112
+ /** Checked here: verified and from are this client's result, not the service's word. */
93
113
  messages: DecodedMessage[];
114
+ /** Only when the thread was opened with an allowlist and something it does not allow was read. */
115
+ keptOut?: KeptOut[];
94
116
  next: number;
95
117
  waited: number;
118
+ reset?: { after: number; newest: number; what: string };
119
+ note?: string;
96
120
  }
97
121
 
98
122
  export interface SendResult {
@@ -131,7 +155,13 @@ export class GateStop extends Error {
131
155
  export function powInput(w: string, key: string | null, bodySha256: string, nonce: string): string;
132
156
  export function powDigest(w: string, key: string | null, bodySha256: string, nonce: string): Uint8Array;
133
157
  export function zeroBits(digest: Uint8Array): number;
134
- export function solveWork(w: string, key: string | null, bodyText: string, bits: number): string;
158
+ /** The nonce, or null when deadline, a Date.now() value, passes first. */
159
+ export function solveWork(w: string, key: string | null, bodyText: string, bits: number, deadline?: number | null): string | null;
160
+ /** Attempts a second the work runs at here, with whichever solver does it, measured once. */
161
+ export function workRate(): number;
162
+ /** How long bits of work takes here on average. */
163
+ export function expectedSeconds(bits: number): number;
164
+ export function describeSeconds(seconds: number): string;
135
165
  /** Another proof of work solver, aamio-wasm for one. Each function returns the nonce; key is "" for an unsigned message. */
136
166
  export interface WorkSolver {
137
167
  thread?(w: string, key: string, bodySha256: string, bits: number): string;
@@ -143,7 +173,8 @@ export function boardPowInput(key: string, bodySha256: string, nonce: string): s
143
173
  export function boardPowDigest(key: string, bodySha256: string, nonce: string): Uint8Array;
144
174
  export function solveBoardWork(key: string, bodyText: string, bits: number): string;
145
175
  export function boardAdvisedBits(descriptor: unknown): number;
146
- export function gatePlan(gate: Gate | null | undefined, w?: string, base?: string): { bits: number | null; required: boolean; notes: string[] };
176
+ /** secondsLeft is how long the inbox still takes writes; work that would not be done by then throws GateStop before it starts. */
177
+ export function gatePlan(gate: Gate | null | undefined, w?: string, base?: string, secondsLeft?: number | null): { bits: number | null; required: boolean; notes: string[]; expectedSeconds?: number };
147
178
 
148
179
  export interface Receipt {
149
180
  schema: string;
@@ -221,7 +252,7 @@ export interface BoardPage {
221
252
  }
222
253
 
223
254
  export interface BoardFilter {
224
- /** Keep only posts whose work_bits is at least this, 0 to 20. 1 means any work at all. */
255
+ /** Keep only posts whose work_bits is at least this, 0 to 16, since no post carries more than the board advises. 1 means any work at all. */
225
256
  min_work_bits?: number;
226
257
  kind?: "need" | "offer";
227
258
  tags?: string[];
@@ -307,11 +338,18 @@ export class Aamio {
307
338
  send(w: string, body: string | object, options?: { sign?: boolean; encryptTo?: string | null }): Promise<SendResult>;
308
339
  /** What an inbox asks of writers, read once per address. {} when it has none or it cannot be told. */
309
340
  gate(w: string): Promise<Gate>;
341
+ /** How long w still takes writes, counted down from X-Seconds-Left on its gate, or null when the gate did not say. */
342
+ secondsLeft(w: string): number | null;
343
+ /** The plan for w's gate, read again once before a no that rests on a gate read earlier. */
344
+ planFor(w: string): Promise<{ bits: number | null; required: boolean; notes: string[]; expectedSeconds?: number }>;
345
+ /** Drops the gate kept for w, and the time it said, so the next send reads them again. */
346
+ forgetGate(w: string): void;
310
347
  read(thread: Thread, options?: { after?: number; wait?: number }): Promise<ReadResult>;
311
- decode(message: StoredMessage): DecodedMessage;
348
+ /** With w, hash and signature are checked first. Without it checked is false and verified/from are the service's claim. */
349
+ decode(message: StoredMessage, w?: string): DecodedMessage;
312
350
  /** decode(), with anything unexpected kept to the one message it came in on. */
313
- decodeSafely(message: StoredMessage): DecodedMessage;
314
- listen(thread: Thread, options?: { after?: number; wait?: number; signal?: AbortSignal }): AsyncGenerator<DecodedMessage, void, void>;
351
+ decodeSafely(message: StoredMessage, w?: string): DecodedMessage;
352
+ listen(thread: Thread, options?: { after?: number; wait?: number; signal?: AbortSignal; onKeptOut?: (keptOut: KeptOut[]) => void; onGone?: (data: ReadResult) => void; onReset?: (reset: NonNullable<ReadResult["reset"]>) => void }): AsyncGenerator<DecodedMessage, void, void>;
315
353
  receipt(thread: Thread): Promise<ReceiptResult>;
316
354
  openWith(key: string, options?: { ttl?: number; replyTo?: string; note?: string }): Promise<Thread>;
317
355
  close(thread: Thread): Promise<{ w: string; deleted: boolean }>;
package/src/aamio.js CHANGED
@@ -156,6 +156,7 @@ export function newId() {
156
156
 
157
157
  /** The public write address of a read key: first 20 characters of base32(sha256(id)). */
158
158
  export function deriveAddress(id) {
159
+ if (typeof id !== "string" || !/^[a-z0-9]{20,64}(?![\s\S])/.test(id)) throw new TypeError("an id is 20 to 64 characters of a-z and 0-9");
159
160
  return base32(sha256(id)).slice(0, 20);
160
161
  }
161
162
 
@@ -170,7 +171,7 @@ export function newScopeKey() {
170
171
  }
171
172
 
172
173
  export function isScopeKey(text) {
173
- return typeof text === "string" && /^[a-z0-9]{26,64}$/.test(text);
174
+ return typeof text === "string" && /^[a-z0-9]{26,64}(?![\s\S])/.test(text);
174
175
  }
175
176
 
176
177
  /** The write capability of a scope: first 20 characters of base32(sha256("aamio-scope-v1\n" + key)). */
@@ -204,11 +205,16 @@ export const presenceDeleteSigningInput = (key, bodyText) => "aamio-presence-del
204
205
  // ----------------------------------------------------------------------- gate
205
206
 
206
207
  // From aamio 0.5.0 an inbox can set conditions for whoever writes to it. The
207
- // ceilings are the service's own, 20 required and 18 advised: an inbox run by a
208
+ // ceilings are the service's own, 32 required and 18 advised: an inbox run by a
208
209
  // stranger can never make this client spend more CPU than aamio lets any inbox
209
210
  // ask for, and aamio can never advise something an up to date client skips.
210
- export const POW_REQUIRE_MAX_BITS = 20;
211
+ // 32 bits is for an inbox that means to meet only writers with real compute,
212
+ // and takes this client hours, so the plan weighs the work against the time
213
+ // the inbox has left and says no before it starts.
214
+ export const POW_REQUIRE_MAX_BITS = 32;
211
215
  export const POW_ADVISE_MAX_BITS = 18;
216
+ // Below this the work is a second or so, and not worth timing first.
217
+ const ESTIMATE_FROM_BITS = 17;
212
218
 
213
219
  // What this client knows how to read. per_key and write_until are limits the
214
220
  // service enforces; a writer meets them by not breaking them.
@@ -247,7 +253,7 @@ export function zeroBits(digest) {
247
253
  // what it answers is checked with one hash, so a solver that is wrong or
248
254
  // broken costs a fallback to the loop below and never a refused message.
249
255
  let workSolver = null;
250
- const NONCE = /^[A-Za-z0-9_-]{1,64}$/;
256
+ const NONCE = /^[A-Za-z0-9_-]{1,64}(?![\s\S])/;
251
257
 
252
258
  /**
253
259
  * Hands proof of work to another solver: { thread(w, key, bodySha256, bits), board(key, bodySha256, bits) },
@@ -255,6 +261,41 @@ const NONCE = /^[A-Za-z0-9_-]{1,64}$/;
255
261
  */
256
262
  export function setWorkSolver(solver) {
257
263
  workSolver = solver || null;
264
+ measuredRate = null;
265
+ }
266
+
267
+ // Attempts a second on this machine, with the solver that will do the work,
268
+ // measured once and kept. The estimate before long work is only as good as
269
+ // this number, and a handed solver can be twenty times the built-in loop.
270
+ let measuredRate = null;
271
+
272
+ /** Attempts a second the work runs at here, with whichever solver does it. */
273
+ export function workRate() {
274
+ if (measuredRate !== null) return measuredRate;
275
+ const bodySha256 = "0".repeat(64);
276
+ const start = performance.now();
277
+ let attempts = 0;
278
+ if (workSolver && typeof workSolver.thread === "function") {
279
+ // Sixteen searches at 12 bits: about 65 thousand attempts on average.
280
+ for (let run = 0; run < 16; run++) askedSolver("thread", ["c".repeat(20), "calibration" + run, bodySha256, 12], () => true);
281
+ attempts = 16 * 4096;
282
+ } else {
283
+ while (performance.now() - start < 250) {
284
+ for (let i = 0; i < 4096; i++, attempts++) zeroBits(powDigest("c".repeat(20), "calibration", bodySha256, String(attempts)));
285
+ }
286
+ }
287
+ measuredRate = attempts / Math.max(0.001, (performance.now() - start) / 1000);
288
+ return measuredRate;
289
+ }
290
+
291
+ /** How long bits of work takes here on average. A lottery: one in a hundred takes about 4.6 times as long. */
292
+ export const expectedSeconds = (bits) => 2 ** bits / workRate();
293
+
294
+ /** A time in seconds as a person would say it. */
295
+ export function describeSeconds(seconds) {
296
+ if (seconds < 90) return `${Math.max(1, Math.round(seconds))} seconds`;
297
+ if (seconds < 5400) return `${Math.round(seconds / 60)} minutes`;
298
+ return `${(seconds / 3600).toFixed(1)} hours`;
258
299
  }
259
300
 
260
301
  function askedSolver(name, args, reaches) {
@@ -267,13 +308,20 @@ function askedSolver(name, args, reaches) {
267
308
  }
268
309
  }
269
310
 
270
- /** The first nonce, counting up from 0, whose digest reaches bits, over the exact text that is sent. */
271
- export function solveWork(w, key, bodyText, bits) {
311
+ /**
312
+ * The first nonce, counting up from 0, whose digest reaches bits, over the
313
+ * exact text that is sent, or null when deadline, a Date.now() value, passes
314
+ * first. A handed solver is not stopped by the deadline, since it cannot be:
315
+ * the estimate before the work is what keeps it inside the time.
316
+ */
317
+ export function solveWork(w, key, bodyText, bits, deadline = null) {
272
318
  const bodySha256 = sha256hex(bodyText);
273
319
  const handed = askedSolver("thread", [w, key || "", bodySha256, bits], (nonce) => zeroBits(powDigest(w, key, bodySha256, nonce)) >= bits);
274
320
  if (handed !== null) return handed;
275
321
  for (let nonce = 0; ; nonce++) {
276
322
  if (zeroBits(powDigest(w, key, bodySha256, String(nonce))) >= bits) return String(nonce);
323
+ // Every 65536 attempts, a third of a second or so here.
324
+ if (deadline !== null && (nonce & 0xffff) === 0xffff && Date.now() > deadline) return null;
277
325
  }
278
326
  }
279
327
 
@@ -303,14 +351,17 @@ export function boardAdvisedBits(descriptor) {
303
351
  }
304
352
 
305
353
  /**
306
- * What to do about a gate before sending: { bits, required, notes }.
354
+ * What to do about a gate before sending: { bits, required, notes, expectedSeconds }.
307
355
  *
308
- * Advised work up to 18 bits and required work up to 20 are done. A requirement
356
+ * Advised work up to 18 bits and required work up to 32 are done. A requirement
309
357
  * above that, or a condition this client does not know under require, throws
310
358
  * GateStop, since it cannot meet what it does not understand. A condition it
311
- * does not know under advise is passed over, and noted.
359
+ * does not know under advise is passed over, and noted. secondsLeft is how long
360
+ * the inbox still takes writes, from X-Seconds-Left on its gate: work that
361
+ * would not be done by then is not started, since finding that out from a 410
362
+ * an hour later is the worst way to learn it.
312
363
  */
313
- export function gatePlan(gate, w, base = DEFAULT_BASE) {
364
+ export function gatePlan(gate, w, base = DEFAULT_BASE, secondsLeft = null) {
314
365
  const where = w ? `GET ${String(base).replace(/\/+$/, "")}/${w}/gate` : "GET /{w}/gate on the inbox";
315
366
  const notes = [];
316
367
  gate = gate && typeof gate === "object" && !Array.isArray(gate) ? gate : {};
@@ -346,7 +397,14 @@ export function gatePlan(gate, w, base = DEFAULT_BASE) {
346
397
  "The inbox asks for more than the service allows, so no client will meet it. Reach the owner another way.",
347
398
  );
348
399
  }
349
- return { bits: bits > 0 ? bits : null, required: true, notes };
400
+ const expected = bits >= ESTIMATE_FROM_BITS ? expectedSeconds(bits) : 0;
401
+ if (secondsLeft !== null && secondsLeft !== undefined && expected > secondsLeft) {
402
+ throw new GateStop(
403
+ `This inbox requires proof of work of ${bits} bits, which takes about ${describeSeconds(expected)} on this machine, and it takes writes for ${describeSeconds(secondsLeft)} more. The work would not be done before it closes, so it was not started and nothing was sent.`,
404
+ "Ask the owner for a longer inbox or less work, or send from a machine with more compute: aamio-wasm handed to setWorkSolver is about eighteen times faster than this loop.",
405
+ );
406
+ }
407
+ return { bits: bits > 0 ? bits : null, required: true, notes, expectedSeconds: expected };
350
408
  }
351
409
 
352
410
  if (advised && typeof advised === "object") {
@@ -361,6 +419,29 @@ export function gatePlan(gate, w, base = DEFAULT_BASE) {
361
419
  return { bits: null, required: false, notes };
362
420
  }
363
421
 
422
+ /**
423
+ * { verified, whyNot, sha256 } for one message as the service returned it, checked here.
424
+ *
425
+ * verified in an answer is the service's word, and the trust model says an
426
+ * operator cannot forge a signature. That only holds for a reader that checks:
427
+ * the body is hashed, the hash compared with the one beside it, and the
428
+ * signature verified over the address being read. whyNot is undefined for a
429
+ * message that verified and for an ordinary unsigned one, and a sentence when
430
+ * something that should have held did not.
431
+ */
432
+ export function checkMessage(w, message) {
433
+ if (!message || typeof message.body !== "string") return { verified: false, whyNot: "the message has no body to check" };
434
+ const digest = sha256hex(message.body);
435
+ if (message.sha256 !== digest) {
436
+ return { verified: false, sha256: digest, whyNot: "the body does not hash to the sha256 the service gave with it, so these are not the bytes that were stored" };
437
+ }
438
+ if (!message.from || !message.sig) {
439
+ return message.verified ? { verified: false, sha256: digest, whyNot: "the service calls it verified and gave no key or signature to check" } : { verified: false, sha256: digest };
440
+ }
441
+ if (verify(message.from, threadSigningInput(w, message.body), message.sig)) return { verified: true, sha256: digest };
442
+ return { verified: false, sha256: digest, whyNot: "the signature does not check out for this key, this address and these bytes" + (message.verified ? ", though the service said it did" : "") };
443
+ }
444
+
364
445
  export function verify(key, text, signature) {
365
446
  try {
366
447
  return nacl.sign.detached.verify(utf8(text), unb64url(signature), unb64url(key));
@@ -543,7 +624,7 @@ class Board {
543
624
  * and the inbox answers arrive in.
544
625
  */
545
626
  async post({ kind, title, text, tags = [], lang, deadline, ttl = BOARD_TTL, scope }, { inbox } = {}) {
546
- if (scope !== undefined && !/^[a-z2-7]{20}$/.test(scope)) {
627
+ if (scope !== undefined && !/^[a-z2-7]{20}(?![\s\S])/.test(scope)) {
547
628
  throw new TypeError("scope is the 20 character address of a scope, from scopeAddress(key), and never the key");
548
629
  }
549
630
  const keys = this.keys;
@@ -749,6 +830,13 @@ class Presence {
749
830
  }
750
831
  }
751
832
 
833
+ function normalizeAllow(allow) {
834
+ if (allow == null) return [];
835
+ if (!Array.isArray(allow) || allow.some((key) => typeof key !== "string")) throw new TypeError("allow must contain strings only");
836
+ const keys = [...new Set(allow.flatMap((entry) => entry.split(",")).map((key) => key.trim()).filter(Boolean))];
837
+ return keys.includes("*") ? ["*"] : keys;
838
+ }
839
+
752
840
  export class Aamio {
753
841
  constructor({ base = DEFAULT_BASE, board = DEFAULT_BOARD, keys = null, fetch = globalThis.fetch } = {}) {
754
842
  this.base = base.replace(/\/+$/, "");
@@ -763,6 +851,7 @@ export class Aamio {
763
851
  this.board = new Board(this, board);
764
852
  this.boardInbox = null;
765
853
  this.gates = new Map();
854
+ this.gateLeft = new Map();
766
855
  }
767
856
 
768
857
  needKeys(what) {
@@ -798,6 +887,7 @@ export class Aamio {
798
887
  * fixed when the thread is opened and never changes.
799
888
  */
800
889
  async open({ ttl, allow, gate } = {}) {
890
+ allow = normalizeAllow(allow);
801
891
  const id = newId();
802
892
  const w = deriveAddress(id);
803
893
  const headers = { "X-Read": id };
@@ -806,7 +896,8 @@ export class Aamio {
806
896
  // The conditions go in the body of the same PUT. No gate, no body, as before.
807
897
  const body = gate ? JSON.stringify({ gate }) : undefined;
808
898
  const data = await this.request("PUT", "/" + w, { headers, body });
809
- const thread = { id, w, expireAt: data.expire_at, allow: data.allow || [] };
899
+ const thread = { id, w, expireAt: data.expire_at, allow };
900
+ if (JSON.stringify(data.allow) !== JSON.stringify(allow)) thread.allowAnswered = data.allow ?? null;
810
901
  if (data.gate) thread.gate = data.gate;
811
902
  return thread;
812
903
  }
@@ -833,9 +924,9 @@ export class Aamio {
833
924
  // The inbox's gate is read before anything is sent. What it asks for that
834
925
  // this client cannot do stops here with its reason, as a GateStop.
835
926
  const key = headers["X-Key"] || "";
836
- const advice = gatePlan(await this.gate(w), w, this.base);
927
+ const advice = await this.planFor(w);
837
928
  let notes = advice.notes;
838
- if (advice.bits) headers["X-Work"] = solveWork(w, key, text, advice.bits);
929
+ if (advice.bits) headers["X-Work"] = this.work(w, key, text, advice.bits);
839
930
 
840
931
  let result;
841
932
  try {
@@ -845,11 +936,15 @@ export class Aamio {
845
936
  // window. Work already done and refused anyway is not done again, since
846
937
  // the same bytes give the same nonce and the same refusal.
847
938
  const gate = error instanceof AamioError && error.status === 428 && error.body && typeof error.body.gate === "object" ? error.body.gate : null;
939
+ // An inbox that is not there, or has expired, takes its gate with it:
940
+ // the next send here reads the gate of whatever is there then.
941
+ if (error instanceof AamioError && (error.status === 404 || error.status === 410)) this.forgetGate(w);
848
942
  if (!gate) throw error;
849
943
  this.gates.set(w, gate);
850
- const asked = gatePlan(gate, w, this.base);
944
+ if (Number.isInteger(error.body.seconds_left)) this.gateLeft.set(w, [error.body.seconds_left, Date.now()]);
945
+ const asked = gatePlan(gate, w, this.base, this.secondsLeft(w));
851
946
  if (!asked.bits || asked.bits === advice.bits) throw error;
852
- headers["X-Work"] = solveWork(w, key, text, asked.bits);
947
+ headers["X-Work"] = this.work(w, key, text, asked.bits);
853
948
  notes = [...notes, ...asked.notes.filter((note) => !notes.includes(note))];
854
949
  result = await this.request("POST", "/" + w, { body: text, headers });
855
950
  }
@@ -864,9 +959,15 @@ export class Aamio {
864
959
  async gate(w) {
865
960
  if (this.gates.has(w)) return this.gates.get(w);
866
961
  try {
867
- const gate = await this.request("GET", "/" + w + "/gate", { expect: [200] });
962
+ const response = await this.fetch(this.base + "/" + w + "/gate", { method: "GET", headers: { Accept: "application/json" } });
963
+ const text = await response.text();
964
+ const gate = response.status === 200 && text ? JSON.parse(text) : null;
868
965
  if (gate && typeof gate === "object" && !Array.isArray(gate)) {
869
966
  this.gates.set(w, gate);
967
+ // The time left rides in a header, since the body is the exact bytes
968
+ // the gate hash is taken over.
969
+ const left = response.headers && typeof response.headers.get === "function" ? response.headers.get("x-seconds-left") : null;
970
+ if (left !== null && /^\d+(?![\s\S])/.test(left)) this.gateLeft.set(w, [Number(left), Date.now()]);
870
971
  return gate;
871
972
  }
872
973
  } catch {
@@ -876,13 +977,84 @@ export class Aamio {
876
977
  return {};
877
978
  }
878
979
 
879
- /** Read a thread you own. after: return messages with seq above it. wait: seconds, up to 25. */
980
+ /**
981
+ * The plan for w's gate, read again once before a no that rests on a gate
982
+ * read earlier. A gate never changes while its thread lives, which is why it
983
+ * is kept, but an address can have more than one life: the time a kept gate
984
+ * said counted down to nothing and stayed there, and a new inbox at the same
985
+ * address was refused on the old one's terms without the service being
986
+ * asked. One more read, only when the answer would be no, is the cost.
987
+ */
988
+ async planFor(w) {
989
+ const cached = this.gates.has(w);
990
+ try {
991
+ return gatePlan(await this.gate(w), w, this.base, this.secondsLeft(w));
992
+ } catch (error) {
993
+ if (!(error instanceof GateStop) || !cached) throw error;
994
+ this.forgetGate(w);
995
+ return gatePlan(await this.gate(w), w, this.base, this.secondsLeft(w));
996
+ }
997
+ }
998
+
999
+ /** The gate kept for w, and the time it said, belong to an inbox that may not be there now. */
1000
+ forgetGate(w) {
1001
+ this.gates.delete(w);
1002
+ this.gateLeft.delete(w);
1003
+ }
1004
+
1005
+ /** How long w still takes writes, counted down from what its gate said, or null. */
1006
+ secondsLeft(w) {
1007
+ const said = this.gateLeft.get(w);
1008
+ return said ? Math.max(0, said[0] - (Date.now() - said[1]) / 1000) : null;
1009
+ }
1010
+
1011
+ /** The work for a send, stopped when the inbox would close, less a few seconds for the post itself. */
1012
+ work(w, key, text, bits) {
1013
+ const left = this.secondsLeft(w);
1014
+ const nonce = solveWork(w, key, text, bits, left === null ? null : Date.now() + Math.max(0, left - 5) * 1000);
1015
+ if (nonce === null) {
1016
+ throw new GateStop(
1017
+ `The proof of work of ${bits} bits was not done before the inbox stops taking writes, so the work was stopped and nothing was sent.`,
1018
+ "The estimate before it started said it would fit, and this time it took longer, which happens: the work is a lottery. Ask the owner for a longer inbox, or send from a machine with more compute.",
1019
+ );
1020
+ }
1021
+ return nonce;
1022
+ }
1023
+
1024
+ /**
1025
+ * Read a thread you own. after: return messages with seq above it. wait: seconds, up to 25.
1026
+ *
1027
+ * Every message is checked here before it is handed over: the body is hashed
1028
+ * and compared with the sha256 beside it, and the signature is verified over
1029
+ * this address. verified and from on what comes back are this client's
1030
+ * result, not the service's word, and a message the service called verified
1031
+ * that does not check out says why in unverifiedBecause.
1032
+ *
1033
+ * A thread opened with an allowlist keeps it: the service holds the list in
1034
+ * memory, and a write to the address after its store was emptied opens a
1035
+ * thread with none. Messages the list does not allow are left out of
1036
+ * messages and listed in keptOut, never dropped in silence.
1037
+ */
880
1038
  async read(thread, { after = 0, wait = 0 } = {}) {
881
1039
  let path = "/" + thread.w;
882
1040
  if (after > 0 || wait > 0) path += "/after/" + after;
883
1041
  if (wait > 0) path += "/wait/" + wait;
884
1042
  const data = await this.request("GET", path, { headers: { "X-Read": thread.id } });
885
- data.messages = (data.messages || []).map((m) => this.decodeSafely(m));
1043
+ const allow = normalizeAllow(thread.allow);
1044
+ const handed = [];
1045
+ const keptOut = [];
1046
+ for (const raw of data.messages || []) {
1047
+ const message = this.decodeSafely(raw, thread.w);
1048
+ if (allow.length && !(message.verified && (allow.includes("*") || allow.includes(message.from)))) {
1049
+ const kept = { seq: message.seq, why: allow.includes("*") ? "this thread was opened for signed messages only, and this one did not verify here" : "this thread was opened for named keys, and this one was not signed by one of them, as checked here" };
1050
+ if (message.unverifiedBecause) kept.unverifiedBecause = message.unverifiedBecause;
1051
+ keptOut.push(kept);
1052
+ continue;
1053
+ }
1054
+ handed.push(message);
1055
+ }
1056
+ data.messages = handed;
1057
+ if (keptOut.length) data.keptOut = keptOut;
886
1058
  return data;
887
1059
  }
888
1060
 
@@ -892,16 +1064,30 @@ export class Aamio {
892
1064
  * whether it came sealed, error when it could not be opened.
893
1065
  */
894
1066
  /** decode(), with anything unexpected kept to the one message it came in on. */
895
- decodeSafely(message) {
1067
+ decodeSafely(message, w) {
896
1068
  try {
897
- return this.decode(message);
1069
+ return this.decode(message, w);
898
1070
  } catch (error) {
899
- return { ...message, encrypted: false, plain: message.body, json: undefined, error: "could not decode: " + (error && error.message ? error.message : String(error)) };
1071
+ const why = "the message could not be checked here: " + (error?.name || "Error");
1072
+ // A decoder failure must never restore an unchecked sender claim.
1073
+ return { ...message, verified: false, from: null, checked: typeof w === "string", encrypted: false, plain: null, json: undefined, error: why, unverifiedBecause: why };
900
1074
  }
901
1075
  }
902
1076
 
903
- decode(message) {
904
- const out = { ...message, encrypted: false, plain: message.body, json: undefined, error: undefined };
1077
+ /**
1078
+ * With w, the address the message was read at, the hash and the signature are
1079
+ * checked first and everything below goes by that result. Without it, the
1080
+ * service's own verified and from are taken as they are and checked is false:
1081
+ * read() always gives w. Unchecked decoding is not evidence of authorship.
1082
+ */
1083
+ decode(message, w) {
1084
+ if (typeof w === "string") {
1085
+ const checked = checkMessage(w, message);
1086
+ message = { ...message, verified: checked.verified, from: checked.verified ? message.from : null };
1087
+ if (checked.sha256) message.sha256 = checked.sha256;
1088
+ if (checked.whyNot) message.unverifiedBecause = checked.whyNot;
1089
+ }
1090
+ const out = { ...message, checked: typeof w === "string", encrypted: false, plain: message.body, json: undefined, error: undefined };
905
1091
  if (isEnvelope(message.body)) {
906
1092
  out.encrypted = true;
907
1093
  out.plain = null;
@@ -935,14 +1121,36 @@ export class Aamio {
935
1121
  * Yield messages as they arrive, long polling with wait seconds per call,
936
1122
  * until signal aborts or the thread answers 410 (then AamioError is thrown).
937
1123
  */
938
- async *listen(thread, { after = 0, wait = 25, signal } = {}) {
1124
+ async *listen(thread, { after = 0, wait = 25, signal, onKeptOut, onGone, onReset } = {}) {
939
1125
  let seq = after;
940
1126
  while (!(signal && signal.aborted)) {
941
1127
  const data = await this.read(thread, { after: seq, wait });
942
- for (const message of data.messages) {
943
- if (message.seq > seq) seq = message.seq;
944
- yield message;
1128
+ // What the thread's own allowlist kept out is not yielded, and it is said.
1129
+ if (data.keptOut?.length) {
1130
+ if (typeof onKeptOut === "function") onKeptOut(data.keptOut);
1131
+ else {
1132
+ (thread.keptOut ??= []).push(...data.keptOut);
1133
+ console.warn("aamio: messages kept out", data.keptOut);
1134
+ }
1135
+ }
1136
+ if (data.exists === false) {
1137
+ if (typeof onGone === "function") onGone(data);
1138
+ else if (!thread.gone) console.warn("aamio: the thread is gone", data.note);
1139
+ thread.gone = true;
1140
+ } else if (data.exists === true) thread.gone = false;
1141
+ if (data.reset) {
1142
+ thread.reset = data.reset;
1143
+ this.forgetGate(thread.w);
1144
+ if (typeof onReset === "function") onReset(data.reset);
1145
+ else console.warn("aamio: the thread cursor was reset", data.reset);
945
1146
  }
1147
+ for (const message of data.messages) yield message;
1148
+ // The service's next, not the highest seq seen. When a restart takes a
1149
+ // thread and a write opens a new one at the same address, the service
1150
+ // reads from the start and says reset, and next is lower than before.
1151
+ // Keeping the highest seq asked past the new thread on every call and
1152
+ // handed the same messages over each time.
1153
+ if (Number.isInteger(data.next)) seq = data.next;
946
1154
  }
947
1155
  }
948
1156