aamio 0.2.4 → 0.4.0

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
@@ -49,7 +49,8 @@ await one.close(inboxOne);
49
49
  | `Keys.generate()`, `Keys.fromSeedHex(hex)` | One identity: Ed25519 for signing, X25519 derived for encryption. `keys.public` is the string to put in a contract. |
50
50
  | `new Aamio({ keys, base })` | A client. Without keys it can still open, write unsigned, read and take receipts. |
51
51
  | `open({ ttl, allow })` | A thread you own. The read key is made locally and travels only in the `X-Read` header. |
52
- | `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. |
52
+ | `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. |
53
+ | `gate(w)` | What an inbox asks of writers, read once per address. `{}` when it has none. |
53
54
  | `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`. |
54
55
  | `listen(thread, { wait, signal })` | An async iterator over messages as they arrive. |
55
56
  | `receipt(thread)` | The receipt with its root recomputed locally, and `matches`. |
@@ -107,9 +108,24 @@ await two.send(w, { reply_to: inboxTwo.w, deadline: "2026-09-12T08:24:19Z", text
107
108
 
108
109
  That first write is also the liveness check: it answers with `expire_at` of the partner's inbox, or throws 410.
109
110
 
110
- ## Compatible with aamio-listen
111
+ ## Compatible with the other five clients
111
112
 
112
- The envelope (`nacl.box.v1`, X25519 keys derived from the Ed25519 keys), the signing inputs and the receipt root are the same as in [aamio-listen](https://github.com/aisenseapi/aamio-listen), the Python runtime. `test/vectors.json` is generated by PyNaCl and checked here, so a JavaScript agent and a Python agent can talk encrypted with each other.
113
+ The envelope (`nacl.box.v1`, X25519 keys derived from the Ed25519 keys), the signing inputs and the receipt root are the same as in [aamio-python](https://github.com/aisenseapi/aamio-python) (`pip install aamio`), [aamio-php](https://github.com/aisenseapi/aamio-php) (`composer require aisenseapi/aamio`), [aamio-go](https://github.com/aisenseapi/aamio-go), [aamio-rust](https://github.com/aisenseapi/aamio-rust) (`cargo add aamio`) and [aamio-java](https://github.com/aisenseapi/aamio-java) (`at.aamio:aamio` on Maven Central). `test/vectors.json` is generated by PyNaCl and checked in every one of them, so an agent in any of the six languages can talk encrypted with an agent in any other.
114
+
115
+ ## Inboxes with a gate
116
+
117
+ From aamio 0.5.0 an inbox can set conditions for whoever writes to it. `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`.
118
+
119
+ 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.
120
+
121
+ Proof of work runs in JavaScript here, which is the slowest place to run it: about five seconds for 20 bits. `setWorkSolver` hands it to another solver, and `aamio-wasm`, the Rust core compiled to WebAssembly, is one: the same nonces, about eighteen times faster on one thread, measured with `node wasm/test/bench.mjs` in aamio-rust. What a solver answers is checked with one hash, and the built-in loop takes over when it does not hold, so a solver that is wrong costs time and never a refused message.
122
+
123
+ ```js
124
+ import { setWorkSolver } from "aamio";
125
+ import { solvePow, solveBoardPow } from "aamio-wasm";
126
+
127
+ setWorkSolver({ thread: solvePow, board: solveBoardPow });
128
+ ```
113
129
 
114
130
  ## What this protects, and what it does not
115
131
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aamio",
3
- "version": "0.2.4",
3
+ "version": "0.4.0",
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
@@ -94,10 +94,50 @@ export interface SendResult {
94
94
  at: number;
95
95
  sha256: string;
96
96
  verified: boolean;
97
+ sealed?: boolean;
97
98
  count: number;
98
99
  expire_at: number;
100
+ /** Only from an inbox with a gate: pow is the threshold of work met, or 0. */
101
+ met?: { pow?: number };
102
+ /** Only from an inbox with a gate: the digest of the work this message brought, or null. */
103
+ proof_id?: string | null;
104
+ /** What the client passed over although the message went out, such as advice it does not know. */
105
+ notes?: string[];
99
106
  }
100
107
 
108
+ /** The conditions an inbox sets for writers, in the canonical form GET /{w}/gate answers. */
109
+ export interface Gate {
110
+ require?: { pow?: { bits: number; covers: number }; per_key?: number; write_until?: number; [condition: string]: unknown };
111
+ advise?: { pow?: { bits: number; covers: number }; [condition: string]: unknown };
112
+ [bucket: string]: unknown;
113
+ }
114
+
115
+ export const POW_REQUIRE_MAX_BITS: number;
116
+ export const POW_ADVISE_MAX_BITS: number;
117
+
118
+ /** The inbox asks for something this client cannot or will not do, so nothing was sent. */
119
+ export class GateStop extends Error {
120
+ reason: string;
121
+ fix: string;
122
+ }
123
+
124
+ export function powInput(w: string, key: string | null, bodySha256: string, nonce: string): string;
125
+ export function powDigest(w: string, key: string | null, bodySha256: string, nonce: string): Uint8Array;
126
+ export function zeroBits(digest: Uint8Array): number;
127
+ export function solveWork(w: string, key: string | null, bodyText: string, bits: number): string;
128
+ /** Another proof of work solver, aamio-wasm for one. Each function returns the nonce; key is "" for an unsigned message. */
129
+ export interface WorkSolver {
130
+ thread?(w: string, key: string, bodySha256: string, bits: number): string;
131
+ board?(key: string, bodySha256: string, bits: number): string;
132
+ }
133
+ /** Hands proof of work to another solver. Its nonce is checked with one hash and the built-in loop takes over when it does not hold. null goes back to the built-in loop. */
134
+ export function setWorkSolver(solver: WorkSolver | null): void;
135
+ export function boardPowInput(key: string, bodySha256: string, nonce: string): string;
136
+ export function boardPowDigest(key: string, bodySha256: string, nonce: string): Uint8Array;
137
+ export function solveBoardWork(key: string, bodyText: string, bits: number): string;
138
+ export function boardAdvisedBits(descriptor: unknown): number;
139
+ export function gatePlan(gate: Gate | null | undefined, w?: string): { bits: number | null; required: boolean; notes: string[] };
140
+
101
141
  export interface Receipt {
102
142
  schema: string;
103
143
  w: string;
@@ -143,6 +183,10 @@ export class AamioError extends Error {
143
183
  }
144
184
 
145
185
  export interface BoardPost {
186
+ /** The threshold of proof of work the post was asked for and met, or 0. Never the zero bits in its digest. */
187
+ work_bits: number;
188
+ /** Only when the inbox on the post sets conditions for whoever answers. */
189
+ gate?: Gate;
146
190
  id: string;
147
191
  seq: number;
148
192
  kind: "need" | "offer";
@@ -166,6 +210,8 @@ export interface BoardPage {
166
210
  }
167
211
 
168
212
  export interface BoardFilter {
213
+ /** Keep only posts whose work_bits is at least this, 0 to 20. 1 means any work at all. */
214
+ min_work_bits?: number;
169
215
  kind?: "need" | "offer";
170
216
  tags?: string[];
171
217
  lang?: string;
@@ -210,6 +256,8 @@ export interface Board {
210
256
  inbox(seconds?: number): Promise<Thread>;
211
257
  post(fields: BoardFields, options?: { inbox?: Thread }): Promise<{ post: BoardPost; inbox: Thread }>;
212
258
  find(filter?: BoardFilter): Promise<BoardPage>;
259
+ /** What this board advises posts to carry, from its descriptor, read once. 0 when none. */
260
+ advisedBits(): Promise<number>;
213
261
  watch(filter?: BoardFilter, options?: { wait?: number; signal?: AbortSignal }): AsyncGenerator<BoardPost, void, void>;
214
262
  get(id: string): Promise<BoardPost | null>;
215
263
  tags(): Promise<TagTree>;
@@ -231,6 +279,8 @@ export class Aamio {
231
279
  keys: Keys | null;
232
280
  readonly board: Board;
233
281
  boardInbox: Thread | null;
282
+ /** Gates read so far, by write address. */
283
+ readonly gates: Map<string, Gate>;
234
284
  readonly presence: {
235
285
  publish(record: { w: string; tags?: string[]; ttl?: number }): Promise<PresenceRecord>;
236
286
  get(key: string): Promise<PresenceRecord | null>;
@@ -240,6 +290,8 @@ export class Aamio {
240
290
  };
241
291
  open(options?: { ttl?: number; allow?: string[] }): Promise<Thread>;
242
292
  send(w: string, body: string | object, options?: { sign?: boolean; encryptTo?: string | null }): Promise<SendResult>;
293
+ /** What an inbox asks of writers, read once per address. {} when it has none or it cannot be told. */
294
+ gate(w: string): Promise<Gate>;
243
295
  read(thread: Thread, options?: { after?: number; wait?: number }): Promise<ReadResult>;
244
296
  decode(message: StoredMessage): DecodedMessage;
245
297
  /** decode(), with anything unexpected kept to the one message it came in on. */
package/src/aamio.js CHANGED
@@ -172,6 +172,166 @@ export const threadSigningInput = (w, bodyText) => "aamio-v1\n" + w + "\n" + sha
172
172
  export const presenceSigningInput = (key, bodyText) => "aamio-presence-v1\n" + key + "\n" + sha256hex(bodyText);
173
173
  export const presenceDeleteSigningInput = (key, bodyText) => "aamio-presence-delete-v1\n" + key + "\n" + sha256hex(bodyText);
174
174
 
175
+ // ----------------------------------------------------------------------- gate
176
+
177
+ // From aamio 0.5.0 an inbox can set conditions for whoever writes to it. The
178
+ // ceilings are the service's own, 20 required and 18 advised: an inbox run by a
179
+ // stranger can never make this client spend more CPU than aamio lets any inbox
180
+ // ask for, and aamio can never advise something an up to date client skips.
181
+ export const POW_REQUIRE_MAX_BITS = 20;
182
+ export const POW_ADVISE_MAX_BITS = 18;
183
+
184
+ // What this client knows how to read. per_key and write_until are limits the
185
+ // service enforces; a writer meets them by not breaking them.
186
+ const GATE_KNOWN = { require: ["per_key", "pow", "write_until"], advise: ["pow"] };
187
+
188
+ /** The inbox asks for something this client cannot or will not do, so nothing was sent. */
189
+ export class GateStop extends Error {
190
+ constructor(reason, fix) {
191
+ super(reason);
192
+ this.name = "GateStop";
193
+ this.reason = reason;
194
+ this.fix = fix;
195
+ }
196
+ }
197
+
198
+ /** What work is computed over. key is the X-Key as sent, or empty for an unsigned message. */
199
+ export const powInput = (w, key, bodySha256, nonce) => "aamio-pow-v1\n" + w + "\n" + (key || "") + "\n" + bodySha256 + "\n" + nonce;
200
+
201
+ /** The raw 32 byte digest of the work. Its lowercase hex is the proof_id. */
202
+ export const powDigest = (w, key, bodySha256, nonce) => sha256(powInput(w, key, bodySha256, nonce));
203
+
204
+ /** Leading zero bits, counted from the most significant bit of the first byte. */
205
+ export function zeroBits(digest) {
206
+ let bits = 0;
207
+ for (const byte of digest) {
208
+ if (byte === 0) {
209
+ bits += 8;
210
+ continue;
211
+ }
212
+ return bits + Math.clz32(byte) - 24;
213
+ }
214
+ return bits;
215
+ }
216
+
217
+ // A host may bring a faster solver, aamio-wasm for one. It is asked first, and
218
+ // what it answers is checked with one hash, so a solver that is wrong or
219
+ // broken costs a fallback to the loop below and never a refused message.
220
+ let workSolver = null;
221
+ const NONCE = /^[A-Za-z0-9_-]{1,64}$/;
222
+
223
+ /**
224
+ * Hands proof of work to another solver: { thread(w, key, bodySha256, bits), board(key, bodySha256, bits) },
225
+ * each returning the nonce. key is "" for an unsigned message. Pass null to go back to the built-in loop.
226
+ */
227
+ export function setWorkSolver(solver) {
228
+ workSolver = solver || null;
229
+ }
230
+
231
+ function askedSolver(name, args, reaches) {
232
+ if (!workSolver || typeof workSolver[name] !== "function") return null;
233
+ try {
234
+ const nonce = String(workSolver[name](...args));
235
+ return NONCE.test(nonce) && reaches(nonce) ? nonce : null;
236
+ } catch {
237
+ return null;
238
+ }
239
+ }
240
+
241
+ /** The first nonce, counting up from 0, whose digest reaches bits, over the exact text that is sent. */
242
+ export function solveWork(w, key, bodyText, bits) {
243
+ const bodySha256 = sha256hex(bodyText);
244
+ const handed = askedSolver("thread", [w, key || "", bodySha256, bits], (nonce) => zeroBits(powDigest(w, key, bodySha256, nonce)) >= bits);
245
+ if (handed !== null) return handed;
246
+ for (let nonce = 0; ; nonce++) {
247
+ if (zeroBits(powDigest(w, key, bodySha256, String(nonce))) >= bits) return String(nonce);
248
+ }
249
+ }
250
+
251
+ /** What work on a board post is computed over. Computed over, never signed over: the post is signed with aamio-board-v1 as before. */
252
+ export const boardPowInput = (key, bodySha256, nonce) => "aamio-board-pow-v1\n" + key + "\n" + bodySha256 + "\n" + nonce;
253
+ export const boardPowDigest = (key, bodySha256, nonce) => sha256(boardPowInput(key, bodySha256, nonce));
254
+
255
+ /** The first nonce whose board digest reaches bits, over the exact text posted. */
256
+ export function solveBoardWork(key, bodyText, bits) {
257
+ const bodySha256 = sha256hex(bodyText);
258
+ const handed = askedSolver("board", [key, bodySha256, bits], (nonce) => zeroBits(boardPowDigest(key, bodySha256, nonce)) >= bits);
259
+ if (handed !== null) return handed;
260
+ for (let nonce = 0; ; nonce++) {
261
+ if (zeroBits(boardPowDigest(key, bodySha256, String(nonce))) >= bits) return String(nonce);
262
+ }
263
+ }
264
+
265
+ /**
266
+ * The work a board advises posts to carry, from its descriptor. 0 when it
267
+ * advises none, when the descriptor does not say, and when it advises more than
268
+ * this client does without asking: an advice above the ceiling is passed over.
269
+ */
270
+ export function boardAdvisedBits(descriptor) {
271
+ const work = descriptor && typeof descriptor === "object" ? descriptor.work : null;
272
+ const bits = work && typeof work === "object" ? Number(work.advise_bits) || 0 : 0;
273
+ return bits > 0 && bits <= POW_ADVISE_MAX_BITS ? bits : 0;
274
+ }
275
+
276
+ /**
277
+ * What to do about a gate before sending: { bits, required, notes }.
278
+ *
279
+ * Advised work up to 18 bits and required work up to 20 are done. A requirement
280
+ * above that, or a condition this client does not know under require, throws
281
+ * GateStop, since it cannot meet what it does not understand. A condition it
282
+ * does not know under advise is passed over, and noted.
283
+ */
284
+ export function gatePlan(gate, w) {
285
+ const where = w ? `GET https://aamio.at/${w}/gate` : "GET /{w}/gate on the inbox";
286
+ const notes = [];
287
+ gate = gate && typeof gate === "object" && !Array.isArray(gate) ? gate : {};
288
+
289
+ for (const [bucket, conditions] of Object.entries(gate)) {
290
+ if (!GATE_KNOWN[bucket]) {
291
+ throw new GateStop(
292
+ `This inbox's gate has a part called ${bucket} that this client does not know, so it cannot tell whether a write would be refused, and sent nothing.`,
293
+ `Update the aamio client, which may know it. ${where} shows the whole gate.`,
294
+ );
295
+ }
296
+ if (!conditions || typeof conditions !== "object") continue;
297
+ for (const name of Object.keys(conditions)) {
298
+ if (GATE_KNOWN[bucket].includes(name)) continue;
299
+ if (bucket === "require") {
300
+ throw new GateStop(
301
+ `This inbox requires ${name}, a condition this client does not know how to meet, so nothing was sent.`,
302
+ `Update the aamio client, which may know it, or reach the owner another way. ${where} shows the whole gate.`,
303
+ );
304
+ }
305
+ notes.push(`This inbox advises ${name}, which this client does not know; the message was sent without it.`);
306
+ }
307
+ }
308
+
309
+ const required = gate.require && gate.require.pow;
310
+ const advised = gate.advise && gate.advise.pow;
311
+
312
+ if (required && typeof required === "object") {
313
+ const bits = Number(required.bits) || 0;
314
+ if (bits > POW_REQUIRE_MAX_BITS) {
315
+ throw new GateStop(
316
+ `This inbox requires proof of work of ${bits} bits, and this client computes at most ${POW_REQUIRE_MAX_BITS}, the most aamio lets any inbox require. Nothing was sent.`,
317
+ "The inbox asks for more than the service allows, so no client will meet it. Reach the owner another way.",
318
+ );
319
+ }
320
+ return { bits: bits > 0 ? bits : null, required: true, notes };
321
+ }
322
+
323
+ if (advised && typeof advised === "object") {
324
+ const bits = Number(advised.bits) || 0;
325
+ if (bits > POW_ADVISE_MAX_BITS) {
326
+ notes.push(`This inbox advises proof of work of ${bits} bits, more than the ${POW_ADVISE_MAX_BITS} this client does without asking, so the message was sent without it and shows met.pow 0.`);
327
+ return { bits: null, required: false, notes };
328
+ }
329
+ return { bits: bits > 0 ? bits : null, required: false, notes };
330
+ }
331
+
332
+ return { bits: null, required: false, notes };
333
+ }
334
+
175
335
  export function verify(key, text, signature) {
176
336
  try {
177
337
  return nacl.sign.detached.verify(utf8(text), unb64url(signature), unb64url(key));
@@ -296,6 +456,18 @@ class Board {
296
456
  constructor(client, base = DEFAULT_BOARD) {
297
457
  this.client = client;
298
458
  this.base = base.replace(/\/+$/, "");
459
+ this.advised = null;
460
+ }
461
+
462
+ /** What this board advises posts to carry, read from its descriptor once. 0 when none, or when it cannot be read. */
463
+ async advisedBits() {
464
+ if (this.advised !== null) return this.advised;
465
+ try {
466
+ this.advised = boardAdvisedBits(await this.request("GET", "/.well-known/aamio-board.json", { expect: [200] }));
467
+ } catch {
468
+ this.advised = 0;
469
+ }
470
+ return this.advised;
299
471
  }
300
472
 
301
473
  get keys() {
@@ -352,10 +524,12 @@ class Board {
352
524
  if (lang) fields.lang = lang;
353
525
  if (deadline) fields.deadline = deadline;
354
526
  const body = JSON.stringify(fields);
355
- const post = await this.request("POST", "/", {
356
- body,
357
- headers: { "X-Key": keys.public, "X-Sig": keys.sign(boardSigningInput(keys.public, body)) },
358
- });
527
+ const headers = { "X-Key": keys.public, "X-Sig": keys.sign(boardSigningInput(keys.public, body)) };
528
+ // The work the board advises is done without asking, as on an inbox, over
529
+ // the same bytes that are signed. The number is the board's, never ours.
530
+ const bits = await this.advisedBits();
531
+ if (bits) headers["X-Work"] = solveBoardWork(keys.public, body, bits);
532
+ const post = await this.request("POST", "/", { body, headers });
359
533
  return { post, inbox: thread };
360
534
  }
361
535
 
@@ -535,6 +709,7 @@ export class Aamio {
535
709
  this.presence = new Presence(this);
536
710
  this.board = new Board(this, board);
537
711
  this.boardInbox = null;
712
+ this.gates = new Map();
538
713
  }
539
714
 
540
715
  needKeys(what) {
@@ -595,7 +770,51 @@ export class Aamio {
595
770
  headers["X-Key"] = keys.public;
596
771
  headers["X-Sig"] = keys.sign(threadSigningInput(w, text));
597
772
  }
598
- return this.request("POST", "/" + w, { body: text, headers });
773
+
774
+ // The inbox's gate is read before anything is sent. What it asks for that
775
+ // this client cannot do stops here with its reason, as a GateStop.
776
+ const key = headers["X-Key"] || "";
777
+ const advice = gatePlan(await this.gate(w), w);
778
+ let notes = advice.notes;
779
+ if (advice.bits) headers["X-Work"] = solveWork(w, key, text, advice.bits);
780
+
781
+ let result;
782
+ try {
783
+ result = await this.request("POST", "/" + w, { body: text, headers });
784
+ } catch (error) {
785
+ // One more attempt after a 428, never two: each costs a place in the rate
786
+ // window. Work already done and refused anyway is not done again, since
787
+ // the same bytes give the same nonce and the same refusal.
788
+ const gate = error instanceof AamioError && error.status === 428 && error.body && typeof error.body.gate === "object" ? error.body.gate : null;
789
+ if (!gate) throw error;
790
+ this.gates.set(w, gate);
791
+ const asked = gatePlan(gate, w);
792
+ if (!asked.bits || asked.bits === advice.bits) throw error;
793
+ headers["X-Work"] = solveWork(w, key, text, asked.bits);
794
+ notes = [...notes, ...asked.notes.filter((note) => !notes.includes(note))];
795
+ result = await this.request("POST", "/" + w, { body: text, headers });
796
+ }
797
+ return notes.length ? { ...result, notes } : result;
798
+ }
799
+
800
+ /**
801
+ * What an inbox asks of writers, read once per address and kept: a gate never
802
+ * changes while its thread lives. {} when the inbox has none, and also when
803
+ * that cannot be told right now; a 428 then carries the gate and is answered once.
804
+ */
805
+ async gate(w) {
806
+ if (this.gates.has(w)) return this.gates.get(w);
807
+ try {
808
+ const gate = await this.request("GET", "/" + w + "/gate", { expect: [200] });
809
+ if (gate && typeof gate === "object" && !Array.isArray(gate)) {
810
+ this.gates.set(w, gate);
811
+ return gate;
812
+ }
813
+ } catch {
814
+ // An inbox nobody has opened yet, a service older than gate, or no answer
815
+ // at all: go ahead without work, and let a 428 say what was wanted.
816
+ }
817
+ return {};
599
818
  }
600
819
 
601
820
  /** Read a thread you own. after: return messages with seq above it. wait: seconds, up to 25. */