aamio 0.2.3 → 0.3.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 +8 -1
- package/package.json +1 -1
- package/src/aamio.d.ts +61 -1
- package/src/aamio.js +202 -6
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`. |
|
|
@@ -111,6 +112,12 @@ That first write is also the liveness check: it answers with `expire_at` of the
|
|
|
111
112
|
|
|
112
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-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
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
|
+
|
|
114
121
|
## What this protects, and what it does not
|
|
115
122
|
|
|
116
123
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aamio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export const ENVELOPE: "nacl.box.v1";
|
|
2
|
+
/** The default lifetime of a board post and the floor for a reply inbox. */
|
|
3
|
+
export const BOARD_TTL: 1800;
|
|
2
4
|
export const DEFAULT_BASE: string;
|
|
3
5
|
export const DEFAULT_BOARD: string;
|
|
4
6
|
export const VERIFYUM_MCP: string;
|
|
@@ -68,6 +70,10 @@ export interface DecodedMessage extends StoredMessage {
|
|
|
68
70
|
plain: string | null;
|
|
69
71
|
json: unknown;
|
|
70
72
|
error: string | undefined;
|
|
73
|
+
/** Sender's spelling to ours, when an answer used a documented alias. */
|
|
74
|
+
renamed?: Record<string, string>;
|
|
75
|
+
/** Aliases that disagreed with the canonical field, left as sent. */
|
|
76
|
+
conflicting?: Record<string, unknown>;
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
export interface ReadResult {
|
|
@@ -88,10 +94,43 @@ export interface SendResult {
|
|
|
88
94
|
at: number;
|
|
89
95
|
sha256: string;
|
|
90
96
|
verified: boolean;
|
|
97
|
+
sealed?: boolean;
|
|
91
98
|
count: number;
|
|
92
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[];
|
|
93
106
|
}
|
|
94
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
|
+
export function boardPowInput(key: string, bodySha256: string, nonce: string): string;
|
|
129
|
+
export function boardPowDigest(key: string, bodySha256: string, nonce: string): Uint8Array;
|
|
130
|
+
export function solveBoardWork(key: string, bodyText: string, bits: number): string;
|
|
131
|
+
export function boardAdvisedBits(descriptor: unknown): number;
|
|
132
|
+
export function gatePlan(gate: Gate | null | undefined, w?: string): { bits: number | null; required: boolean; notes: string[] };
|
|
133
|
+
|
|
95
134
|
export interface Receipt {
|
|
96
135
|
schema: string;
|
|
97
136
|
w: string;
|
|
@@ -137,6 +176,10 @@ export class AamioError extends Error {
|
|
|
137
176
|
}
|
|
138
177
|
|
|
139
178
|
export interface BoardPost {
|
|
179
|
+
/** The threshold of proof of work the post was asked for and met, or 0. Never the zero bits in its digest. */
|
|
180
|
+
work_bits: number;
|
|
181
|
+
/** Only when the inbox on the post sets conditions for whoever answers. */
|
|
182
|
+
gate?: Gate;
|
|
140
183
|
id: string;
|
|
141
184
|
seq: number;
|
|
142
185
|
kind: "need" | "offer";
|
|
@@ -160,6 +203,8 @@ export interface BoardPage {
|
|
|
160
203
|
}
|
|
161
204
|
|
|
162
205
|
export interface BoardFilter {
|
|
206
|
+
/** Keep only posts whose work_bits is at least this, 0 to 20. 1 means any work at all. */
|
|
207
|
+
min_work_bits?: number;
|
|
163
208
|
kind?: "need" | "offer";
|
|
164
209
|
tags?: string[];
|
|
165
210
|
lang?: string;
|
|
@@ -193,15 +238,24 @@ export interface TagTree {
|
|
|
193
238
|
untagged: { live: number; need: number; offer: number };
|
|
194
239
|
}
|
|
195
240
|
|
|
241
|
+
export interface BoardAnswer extends SendResult {
|
|
242
|
+
inbox: Thread;
|
|
243
|
+
post: string;
|
|
244
|
+
/** Present when the post answered is your own, which seals it to yourself. */
|
|
245
|
+
warning?: string;
|
|
246
|
+
}
|
|
247
|
+
|
|
196
248
|
export interface Board {
|
|
197
249
|
inbox(seconds?: number): Promise<Thread>;
|
|
198
250
|
post(fields: BoardFields, options?: { inbox?: Thread }): Promise<{ post: BoardPost; inbox: Thread }>;
|
|
199
251
|
find(filter?: BoardFilter): Promise<BoardPage>;
|
|
252
|
+
/** What this board advises posts to carry, from its descriptor, read once. 0 when none. */
|
|
253
|
+
advisedBits(): Promise<number>;
|
|
200
254
|
watch(filter?: BoardFilter, options?: { wait?: number; signal?: AbortSignal }): AsyncGenerator<BoardPost, void, void>;
|
|
201
255
|
get(id: string): Promise<BoardPost | null>;
|
|
202
256
|
tags(): Promise<TagTree>;
|
|
203
257
|
withdraw(id: string): Promise<{ id: string; deleted: boolean }>;
|
|
204
|
-
answer(post: BoardPost, body: string | object, options?: { inbox?: Thread; ttl?: number }): Promise<
|
|
258
|
+
answer(post: BoardPost, body: string | object, options?: { inbox?: Thread; ttl?: number }): Promise<BoardAnswer>;
|
|
205
259
|
replies(messages: DecodedMessage[], postId: string): DecodedMessage[];
|
|
206
260
|
}
|
|
207
261
|
|
|
@@ -218,6 +272,8 @@ export class Aamio {
|
|
|
218
272
|
keys: Keys | null;
|
|
219
273
|
readonly board: Board;
|
|
220
274
|
boardInbox: Thread | null;
|
|
275
|
+
/** Gates read so far, by write address. */
|
|
276
|
+
readonly gates: Map<string, Gate>;
|
|
221
277
|
readonly presence: {
|
|
222
278
|
publish(record: { w: string; tags?: string[]; ttl?: number }): Promise<PresenceRecord>;
|
|
223
279
|
get(key: string): Promise<PresenceRecord | null>;
|
|
@@ -227,8 +283,12 @@ export class Aamio {
|
|
|
227
283
|
};
|
|
228
284
|
open(options?: { ttl?: number; allow?: string[] }): Promise<Thread>;
|
|
229
285
|
send(w: string, body: string | object, options?: { sign?: boolean; encryptTo?: string | null }): Promise<SendResult>;
|
|
286
|
+
/** What an inbox asks of writers, read once per address. {} when it has none or it cannot be told. */
|
|
287
|
+
gate(w: string): Promise<Gate>;
|
|
230
288
|
read(thread: Thread, options?: { after?: number; wait?: number }): Promise<ReadResult>;
|
|
231
289
|
decode(message: StoredMessage): DecodedMessage;
|
|
290
|
+
/** decode(), with anything unexpected kept to the one message it came in on. */
|
|
291
|
+
decodeSafely(message: StoredMessage): DecodedMessage;
|
|
232
292
|
listen(thread: Thread, options?: { after?: number; wait?: number; signal?: AbortSignal }): AsyncGenerator<DecodedMessage, void, void>;
|
|
233
293
|
receipt(thread: Thread): Promise<ReceiptResult>;
|
|
234
294
|
openWith(key: string, options?: { ttl?: number; replyTo?: string; note?: string }): Promise<Thread>;
|
package/src/aamio.js
CHANGED
|
@@ -172,6 +172,138 @@ 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
|
+
/** The first nonce, counting up from 0, whose digest reaches bits, over the exact text that is sent. */
|
|
218
|
+
export function solveWork(w, key, bodyText, bits) {
|
|
219
|
+
const bodySha256 = sha256hex(bodyText);
|
|
220
|
+
for (let nonce = 0; ; nonce++) {
|
|
221
|
+
if (zeroBits(powDigest(w, key, bodySha256, String(nonce))) >= bits) return String(nonce);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** What work on a board post is computed over. Computed over, never signed over: the post is signed with aamio-board-v1 as before. */
|
|
226
|
+
export const boardPowInput = (key, bodySha256, nonce) => "aamio-board-pow-v1\n" + key + "\n" + bodySha256 + "\n" + nonce;
|
|
227
|
+
export const boardPowDigest = (key, bodySha256, nonce) => sha256(boardPowInput(key, bodySha256, nonce));
|
|
228
|
+
|
|
229
|
+
/** The first nonce whose board digest reaches bits, over the exact text posted. */
|
|
230
|
+
export function solveBoardWork(key, bodyText, bits) {
|
|
231
|
+
const bodySha256 = sha256hex(bodyText);
|
|
232
|
+
for (let nonce = 0; ; nonce++) {
|
|
233
|
+
if (zeroBits(boardPowDigest(key, bodySha256, String(nonce))) >= bits) return String(nonce);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The work a board advises posts to carry, from its descriptor. 0 when it
|
|
239
|
+
* advises none, when the descriptor does not say, and when it advises more than
|
|
240
|
+
* this client does without asking: an advice above the ceiling is passed over.
|
|
241
|
+
*/
|
|
242
|
+
export function boardAdvisedBits(descriptor) {
|
|
243
|
+
const work = descriptor && typeof descriptor === "object" ? descriptor.work : null;
|
|
244
|
+
const bits = work && typeof work === "object" ? Number(work.advise_bits) || 0 : 0;
|
|
245
|
+
return bits > 0 && bits <= POW_ADVISE_MAX_BITS ? bits : 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* What to do about a gate before sending: { bits, required, notes }.
|
|
250
|
+
*
|
|
251
|
+
* Advised work up to 18 bits and required work up to 20 are done. A requirement
|
|
252
|
+
* above that, or a condition this client does not know under require, throws
|
|
253
|
+
* GateStop, since it cannot meet what it does not understand. A condition it
|
|
254
|
+
* does not know under advise is passed over, and noted.
|
|
255
|
+
*/
|
|
256
|
+
export function gatePlan(gate, w) {
|
|
257
|
+
const where = w ? `GET https://aamio.at/${w}/gate` : "GET /{w}/gate on the inbox";
|
|
258
|
+
const notes = [];
|
|
259
|
+
gate = gate && typeof gate === "object" && !Array.isArray(gate) ? gate : {};
|
|
260
|
+
|
|
261
|
+
for (const [bucket, conditions] of Object.entries(gate)) {
|
|
262
|
+
if (!GATE_KNOWN[bucket]) {
|
|
263
|
+
throw new GateStop(
|
|
264
|
+
`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.`,
|
|
265
|
+
`Update the aamio client, which may know it. ${where} shows the whole gate.`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
if (!conditions || typeof conditions !== "object") continue;
|
|
269
|
+
for (const name of Object.keys(conditions)) {
|
|
270
|
+
if (GATE_KNOWN[bucket].includes(name)) continue;
|
|
271
|
+
if (bucket === "require") {
|
|
272
|
+
throw new GateStop(
|
|
273
|
+
`This inbox requires ${name}, a condition this client does not know how to meet, so nothing was sent.`,
|
|
274
|
+
`Update the aamio client, which may know it, or reach the owner another way. ${where} shows the whole gate.`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
notes.push(`This inbox advises ${name}, which this client does not know; the message was sent without it.`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const required = gate.require && gate.require.pow;
|
|
282
|
+
const advised = gate.advise && gate.advise.pow;
|
|
283
|
+
|
|
284
|
+
if (required && typeof required === "object") {
|
|
285
|
+
const bits = Number(required.bits) || 0;
|
|
286
|
+
if (bits > POW_REQUIRE_MAX_BITS) {
|
|
287
|
+
throw new GateStop(
|
|
288
|
+
`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.`,
|
|
289
|
+
"The inbox asks for more than the service allows, so no client will meet it. Reach the owner another way.",
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
return { bits: bits > 0 ? bits : null, required: true, notes };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (advised && typeof advised === "object") {
|
|
296
|
+
const bits = Number(advised.bits) || 0;
|
|
297
|
+
if (bits > POW_ADVISE_MAX_BITS) {
|
|
298
|
+
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.`);
|
|
299
|
+
return { bits: null, required: false, notes };
|
|
300
|
+
}
|
|
301
|
+
return { bits: bits > 0 ? bits : null, required: false, notes };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return { bits: null, required: false, notes };
|
|
305
|
+
}
|
|
306
|
+
|
|
175
307
|
export function verify(key, text, signature) {
|
|
176
308
|
try {
|
|
177
309
|
return nacl.sign.detached.verify(utf8(text), unb64url(signature), unb64url(key));
|
|
@@ -296,6 +428,18 @@ class Board {
|
|
|
296
428
|
constructor(client, base = DEFAULT_BOARD) {
|
|
297
429
|
this.client = client;
|
|
298
430
|
this.base = base.replace(/\/+$/, "");
|
|
431
|
+
this.advised = null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** What this board advises posts to carry, read from its descriptor once. 0 when none, or when it cannot be read. */
|
|
435
|
+
async advisedBits() {
|
|
436
|
+
if (this.advised !== null) return this.advised;
|
|
437
|
+
try {
|
|
438
|
+
this.advised = boardAdvisedBits(await this.request("GET", "/.well-known/aamio-board.json", { expect: [200] }));
|
|
439
|
+
} catch {
|
|
440
|
+
this.advised = 0;
|
|
441
|
+
}
|
|
442
|
+
return this.advised;
|
|
299
443
|
}
|
|
300
444
|
|
|
301
445
|
get keys() {
|
|
@@ -352,10 +496,12 @@ class Board {
|
|
|
352
496
|
if (lang) fields.lang = lang;
|
|
353
497
|
if (deadline) fields.deadline = deadline;
|
|
354
498
|
const body = JSON.stringify(fields);
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
499
|
+
const headers = { "X-Key": keys.public, "X-Sig": keys.sign(boardSigningInput(keys.public, body)) };
|
|
500
|
+
// The work the board advises is done without asking, as on an inbox, over
|
|
501
|
+
// the same bytes that are signed. The number is the board's, never ours.
|
|
502
|
+
const bits = await this.advisedBits();
|
|
503
|
+
if (bits) headers["X-Work"] = solveBoardWork(keys.public, body, bits);
|
|
504
|
+
const post = await this.request("POST", "/", { body, headers });
|
|
359
505
|
return { post, inbox: thread };
|
|
360
506
|
}
|
|
361
507
|
|
|
@@ -459,7 +605,12 @@ function canonicalAnswer(body) {
|
|
|
459
605
|
if (present.length) {
|
|
460
606
|
body[canonical] = body[present[0]];
|
|
461
607
|
renamed[present[0]] = canonical;
|
|
462
|
-
|
|
608
|
+
// Two spellings carrying the same value are not a disagreement, and
|
|
609
|
+
// reporting them as one asks a caller to weigh a conflict that is not
|
|
610
|
+
// there.
|
|
611
|
+
for (const s of present.slice(1)) {
|
|
612
|
+
if (String(body[s]) !== String(body[present[0]])) conflicting[s] = body[s];
|
|
613
|
+
}
|
|
463
614
|
}
|
|
464
615
|
}
|
|
465
616
|
return {
|
|
@@ -530,6 +681,7 @@ export class Aamio {
|
|
|
530
681
|
this.presence = new Presence(this);
|
|
531
682
|
this.board = new Board(this, board);
|
|
532
683
|
this.boardInbox = null;
|
|
684
|
+
this.gates = new Map();
|
|
533
685
|
}
|
|
534
686
|
|
|
535
687
|
needKeys(what) {
|
|
@@ -590,7 +742,51 @@ export class Aamio {
|
|
|
590
742
|
headers["X-Key"] = keys.public;
|
|
591
743
|
headers["X-Sig"] = keys.sign(threadSigningInput(w, text));
|
|
592
744
|
}
|
|
593
|
-
|
|
745
|
+
|
|
746
|
+
// The inbox's gate is read before anything is sent. What it asks for that
|
|
747
|
+
// this client cannot do stops here with its reason, as a GateStop.
|
|
748
|
+
const key = headers["X-Key"] || "";
|
|
749
|
+
const advice = gatePlan(await this.gate(w), w);
|
|
750
|
+
let notes = advice.notes;
|
|
751
|
+
if (advice.bits) headers["X-Work"] = solveWork(w, key, text, advice.bits);
|
|
752
|
+
|
|
753
|
+
let result;
|
|
754
|
+
try {
|
|
755
|
+
result = await this.request("POST", "/" + w, { body: text, headers });
|
|
756
|
+
} catch (error) {
|
|
757
|
+
// One more attempt after a 428, never two: each costs a place in the rate
|
|
758
|
+
// window. Work already done and refused anyway is not done again, since
|
|
759
|
+
// the same bytes give the same nonce and the same refusal.
|
|
760
|
+
const gate = error instanceof AamioError && error.status === 428 && error.body && typeof error.body.gate === "object" ? error.body.gate : null;
|
|
761
|
+
if (!gate) throw error;
|
|
762
|
+
this.gates.set(w, gate);
|
|
763
|
+
const asked = gatePlan(gate, w);
|
|
764
|
+
if (!asked.bits || asked.bits === advice.bits) throw error;
|
|
765
|
+
headers["X-Work"] = solveWork(w, key, text, asked.bits);
|
|
766
|
+
notes = [...notes, ...asked.notes.filter((note) => !notes.includes(note))];
|
|
767
|
+
result = await this.request("POST", "/" + w, { body: text, headers });
|
|
768
|
+
}
|
|
769
|
+
return notes.length ? { ...result, notes } : result;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* What an inbox asks of writers, read once per address and kept: a gate never
|
|
774
|
+
* changes while its thread lives. {} when the inbox has none, and also when
|
|
775
|
+
* that cannot be told right now; a 428 then carries the gate and is answered once.
|
|
776
|
+
*/
|
|
777
|
+
async gate(w) {
|
|
778
|
+
if (this.gates.has(w)) return this.gates.get(w);
|
|
779
|
+
try {
|
|
780
|
+
const gate = await this.request("GET", "/" + w + "/gate", { expect: [200] });
|
|
781
|
+
if (gate && typeof gate === "object" && !Array.isArray(gate)) {
|
|
782
|
+
this.gates.set(w, gate);
|
|
783
|
+
return gate;
|
|
784
|
+
}
|
|
785
|
+
} catch {
|
|
786
|
+
// An inbox nobody has opened yet, a service older than gate, or no answer
|
|
787
|
+
// at all: go ahead without work, and let a 428 say what was wanted.
|
|
788
|
+
}
|
|
789
|
+
return {};
|
|
594
790
|
}
|
|
595
791
|
|
|
596
792
|
/** Read a thread you own. after: return messages with seq above it. wait: seconds, up to 25. */
|