aamio 0.5.6 → 0.6.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 +83 -0
- package/bin/aamio.js +247 -0
- package/package.json +10 -2
- package/src/node-identity.js +162 -0
- package/src/node-lock.js +203 -0
- package/src/node-mcp.js +667 -0
- package/src/node-runtime.js +1103 -0
|
@@ -0,0 +1,1103 @@
|
|
|
1
|
+
import { open, readFile, rename, unlink } from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { Aamio, AamioError, BOARD_TTL, DEFAULT_BASE, DEFAULT_BOARD, GateStop, checkMessage, gatePlan, isEnvelope, isKey, keyHash, sha256hex, threadSigningInput } from "./aamio.js";
|
|
6
|
+
import { readKeys } from "./node-identity.js";
|
|
7
|
+
import { takeHome } from "./node-lock.js";
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* The local runtime for Node: a home held for as long as it runs, an inbox the
|
|
11
|
+
* partners can write to, presence that says where it is, sends written to an
|
|
12
|
+
* outbox before they go, and reads checked here before anything is handed on.
|
|
13
|
+
*
|
|
14
|
+
* A home is shared with aamio-python and aamio-php, so the files are theirs:
|
|
15
|
+
* partners.json, state.json and outbox.json in the same shape, written the same
|
|
16
|
+
* way. A home one of them made opens here, and one this makes opens there. What
|
|
17
|
+
* Python keeps that this does not touch yet, scopes, traces, effects and the
|
|
18
|
+
* archive, is left as it is.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const INBOX_TTL = 3600;
|
|
22
|
+
export const PRESENCE_TTL = 120;
|
|
23
|
+
export const PRESENCE_REFRESH = 60;
|
|
24
|
+
export const RENEW_BEFORE = 180;
|
|
25
|
+
export const MESSAGE_MAX_BYTES = 65536;
|
|
26
|
+
// How much longer than the post the address on an answer lives, so the poster
|
|
27
|
+
// can still write back when it reads the answer late.
|
|
28
|
+
export const ANSWER_MARGIN = 600;
|
|
29
|
+
|
|
30
|
+
// Files this runtime writes back. One that is there and cannot be read stops
|
|
31
|
+
// the runtime before anything is saved over it: a save is how a broken file
|
|
32
|
+
// becomes a lost one.
|
|
33
|
+
const KEPT_FILES = ["partners.json", "state.json", "outbox.json"];
|
|
34
|
+
// Answers that prove a message was not stored: refusals the service will give
|
|
35
|
+
// again, and a rate window, which it decides before it reads the body.
|
|
36
|
+
const SEND_DETERMINISTIC = [400, 403, 410, 413, 415, 422, 428, 501];
|
|
37
|
+
const SEND_NOT_STORED = [...SEND_DETERMINISTIC, 429];
|
|
38
|
+
const HASH = /^[0-9a-f]{64}$/;
|
|
39
|
+
const ADDRESS = /^[a-z2-7]{20}$/;
|
|
40
|
+
const NAME = /^[^\u0000-\u001f\u007f]{1,64}$/;
|
|
41
|
+
|
|
42
|
+
/** A partner's name and key as partnerAdd takes them, checked where nothing has been opened yet. Throws invalid_name or invalid_key. */
|
|
43
|
+
export function checkPartner(name, key) {
|
|
44
|
+
if (typeof name !== "string" || !NAME.test(name)) throw runtimeError("invalid_name", "A partner's name is 1 to 64 characters, with no control characters.");
|
|
45
|
+
if (!isKey(key)) throw runtimeError("invalid_key", "not a base64url Ed25519 public key of 32 bytes");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** re as send takes it: the sha256 of the message it answers. Throws invalid_re. */
|
|
49
|
+
export function checkRe(re) {
|
|
50
|
+
if (re !== null && re !== undefined && !HASH.test(String(re))) throw runtimeError("invalid_re", "re is the sha256 of the message this answers: 64 lowercase hex characters, as read shows it");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function runtimeError(code, message, extra = {}) {
|
|
54
|
+
return Object.assign(new Error(message), { code, ...extra });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isObject(value) {
|
|
58
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A refusal or a silence from the board, with the board's own fix when it gave one. */
|
|
62
|
+
function boardError(what, error) {
|
|
63
|
+
if (error instanceof AamioError) {
|
|
64
|
+
const said = isObject(error.body) ? error.body : {};
|
|
65
|
+
return runtimeError("board_failed", `${what} failed: the board answered ${error.status}: ${said.error ?? JSON.stringify(error.body)}`, { status: error.status, ...(said.fix ? { fix: said.fix } : {}) });
|
|
66
|
+
}
|
|
67
|
+
return runtimeError("board_unreachable", `${what} got no answer from the board (${error.message}). Nothing says it was done: look before doing it again.`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What a message was made of, for the outbox, without what it said. The same fields aamio-python records. */
|
|
71
|
+
function messageShape(body, envelope) {
|
|
72
|
+
const shape = { bytes: Buffer.byteLength(envelope, "utf8"), fields: Object.keys(body).sort(), text_chars: typeof body.text === "string" ? [...body.text].length : null };
|
|
73
|
+
for (const field of ["re", "seen"]) if (HASH.test(body[field] ?? "")) shape[field] = body[field];
|
|
74
|
+
return shape;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class NodeRuntime {
|
|
78
|
+
/** Takes the home, reads its key and its files. Close it to let go. */
|
|
79
|
+
static async open({ home, host = process.env.AAMIO_HOST || DEFAULT_BASE, board = process.env.AAMIO_BOARD || DEFAULT_BOARD, fetch = globalThis.fetch, clock = () => Date.now() / 1000 } = {}) {
|
|
80
|
+
const owner = await takeHome(home, { host });
|
|
81
|
+
try {
|
|
82
|
+
const runtime = new NodeRuntime({ home, host, board, keys: await readKeys(home), owner, fetch, clock });
|
|
83
|
+
await runtime.load();
|
|
84
|
+
return runtime;
|
|
85
|
+
} catch (error) {
|
|
86
|
+
await owner.release();
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
constructor({ home, host, board = DEFAULT_BOARD, keys, owner, fetch, clock }) {
|
|
92
|
+
this.home = home;
|
|
93
|
+
this.host = host.replace(/\/+$/, "");
|
|
94
|
+
this.keys = keys;
|
|
95
|
+
this.owner = owner;
|
|
96
|
+
this.clock = clock;
|
|
97
|
+
this.client = new Aamio({ base: this.host, board, keys, fetch });
|
|
98
|
+
this.attention = [];
|
|
99
|
+
this.presenceAt = 0;
|
|
100
|
+
this.presenceFailed = false;
|
|
101
|
+
this.presenceRetryAt = 0;
|
|
102
|
+
this.closed = false;
|
|
103
|
+
this.queue = Promise.resolve();
|
|
104
|
+
// Outbox entries a stopped process left whose note is not handed to anyone yet.
|
|
105
|
+
this.untold = new Set();
|
|
106
|
+
// Files whose folder could not be synced after the rename, said once until it can.
|
|
107
|
+
this.unsynced = new Set();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* fn run when everything queued before it has finished. A server answers one
|
|
112
|
+
* call at a time and refreshes presence on a timer, and two of them changing
|
|
113
|
+
* the inbox at once could each open one: so they take turns.
|
|
114
|
+
*/
|
|
115
|
+
serial(fn) {
|
|
116
|
+
const turn = this.queue.then(() => fn());
|
|
117
|
+
this.queue = turn.catch(() => {});
|
|
118
|
+
return turn;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
now() {
|
|
122
|
+
return this.clock();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ------------------------------------------------------------- files --
|
|
126
|
+
|
|
127
|
+
async loadJson(name, fallback, valid) {
|
|
128
|
+
let bytes;
|
|
129
|
+
try {
|
|
130
|
+
bytes = await readFile(join(this.home, name));
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error.code === "ENOENT") return fallback;
|
|
133
|
+
throw runtimeError("state_unreadable", `${name} in ${this.home} is there and cannot be read (${error.code || error.message}), so nothing is saved over it. Restore it from a copy, or move it aside.`);
|
|
134
|
+
}
|
|
135
|
+
let text;
|
|
136
|
+
try {
|
|
137
|
+
// Fatal: a byte that is not UTF-8 makes the file unreadable. Read with
|
|
138
|
+
// replacement characters, a name in it would be saved back changed.
|
|
139
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
140
|
+
} catch {
|
|
141
|
+
throw runtimeError("state_unreadable", `${name} in ${this.home} is not UTF-8, so nothing is saved over it. Restore it from a copy, or move it aside.`);
|
|
142
|
+
}
|
|
143
|
+
if (text.trim() === "") return fallback;
|
|
144
|
+
let value;
|
|
145
|
+
try {
|
|
146
|
+
value = JSON.parse(text);
|
|
147
|
+
} catch {
|
|
148
|
+
throw runtimeError("state_unreadable", `${name} in ${this.home} is not JSON, so nothing is saved over it. Restore it from a copy, or move it aside.`);
|
|
149
|
+
}
|
|
150
|
+
if (!valid(value)) throw runtimeError("state_unreadable", `${name} in ${this.home} does not hold what it should, so nothing is saved over it. Restore it from a copy, or move it aside.`);
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async load() {
|
|
155
|
+
this.partnersFile = await this.loadJson("partners.json", [], Array.isArray);
|
|
156
|
+
this.state = await this.loadJson("state.json", {}, isObject);
|
|
157
|
+
this.outbox = await this.loadJson("outbox.json", {}, isObject);
|
|
158
|
+
if (!Array.isArray(this.state.tags)) this.state.tags = [];
|
|
159
|
+
if (!isObject(this.state.peers)) this.state.peers = {};
|
|
160
|
+
if (!Array.isArray(this.state.channels)) this.state.channels = [];
|
|
161
|
+
for (const channel of this.state.channels) {
|
|
162
|
+
if (!isObject(channel)) continue;
|
|
163
|
+
channel.seen = Array.isArray(channel.seen) ? channel.seen.filter((value) => typeof value === "string" && HASH.test(value)) : [];
|
|
164
|
+
channel.allow = Array.isArray(channel.allow) ? channel.allow : [];
|
|
165
|
+
if (!Number.isInteger(channel.after)) channel.after = 0;
|
|
166
|
+
}
|
|
167
|
+
await this.settleInterrupted();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* What a process that stopped left in the outbox, settled by the rules
|
|
172
|
+
* aamio-python uses, so a shared home says the same in both. A send in
|
|
173
|
+
* flight may have landed: unknown. Work for a gate comes before the post, so
|
|
174
|
+
* work that never finished was never sent: stopped, unless an earlier
|
|
175
|
+
* attempt had already left. Each is said once, on this process's first read.
|
|
176
|
+
*/
|
|
177
|
+
async settleInterrupted() {
|
|
178
|
+
let changed = false;
|
|
179
|
+
for (const entry of Object.values(this.outbox)) {
|
|
180
|
+
if (!isObject(entry)) continue;
|
|
181
|
+
// Written to the outbox and never handed to deliver, which counts the
|
|
182
|
+
// attempt and writes the entry again before the post: nothing left this
|
|
183
|
+
// machine. Called unknown, it was a message that may have arrived when it
|
|
184
|
+
// never went (a review of 9 November 2026).
|
|
185
|
+
if (entry.status === "sending" && !(Number(entry.attempts) > 0) && entry.posting !== true) {
|
|
186
|
+
entry.status = "stopped";
|
|
187
|
+
entry.note = "the process stopped after the message was written to the outbox and before it was sent, so nothing left this machine";
|
|
188
|
+
changed = true;
|
|
189
|
+
}
|
|
190
|
+
if (["sending", "unknown", "refused", "delivered"].includes(entry.status) && entry.posting !== true) {
|
|
191
|
+
entry.posting = true;
|
|
192
|
+
changed = true;
|
|
193
|
+
}
|
|
194
|
+
if (entry.status === "sending") {
|
|
195
|
+
entry.status = "unknown";
|
|
196
|
+
entry.note = "the process stopped while this was in flight";
|
|
197
|
+
changed = true;
|
|
198
|
+
} else if (entry.status === "working") {
|
|
199
|
+
entry.status = entry.posting ? "unknown" : "stopped";
|
|
200
|
+
entry.note = entry.posting
|
|
201
|
+
? "the process stopped while a retry was doing its proof of work. An earlier attempt had already left this machine and its answer never came, so the outcome is still unknown"
|
|
202
|
+
: "the process stopped before its proof of work was done, so nothing was sent";
|
|
203
|
+
changed = true;
|
|
204
|
+
}
|
|
205
|
+
if (["attempted", "unknown"].includes(entry.status) && entry.ever_open !== true) {
|
|
206
|
+
entry.ever_open = true;
|
|
207
|
+
changed = true;
|
|
208
|
+
}
|
|
209
|
+
if (entry.note && !entry.told && ["unknown", "stopped"].includes(entry.status)) {
|
|
210
|
+
this.note(`send ${entry.id}`, entry.status, entry.status === "stopped"
|
|
211
|
+
? `the message to ${entry.w} was not sent: ${entry.note}. Send it again if it still matters.`
|
|
212
|
+
: `the message to ${entry.w} may have arrived: ${entry.note}. Its outcome is open: do not compose a replacement.`);
|
|
213
|
+
// Told when it is handed to someone, in attentionTaken: marked here, a
|
|
214
|
+
// command that shows no notes lost it for good.
|
|
215
|
+
this.untold.add(entry.id);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (!changed) return;
|
|
219
|
+
try {
|
|
220
|
+
await this.saveOutbox();
|
|
221
|
+
} catch (error) {
|
|
222
|
+
this.note("outbox", "not_saved", `the outbox could not be saved after settling what the last process left (${error.code || error.message}); it is settled again next time`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** A file of this home, private from its first byte, replaced in one step and only over bytes that reached the disk. */
|
|
227
|
+
async saveJson(name, value) {
|
|
228
|
+
if (this.closed) throw runtimeError("runtime_closed", `${name} not written: this runtime has let go of ${this.home}`);
|
|
229
|
+
const path = join(this.home, name);
|
|
230
|
+
const temporary = join(this.home, `${name}.${randomUUID()}.tmp`);
|
|
231
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
232
|
+
try {
|
|
233
|
+
try {
|
|
234
|
+
await handle.writeFile(JSON.stringify(value, null, 2), "utf8");
|
|
235
|
+
await handle.sync();
|
|
236
|
+
} finally {
|
|
237
|
+
await handle.close();
|
|
238
|
+
}
|
|
239
|
+
await rename(temporary, path);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
await unlink(temporary).catch(() => {});
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
await this.syncFolder();
|
|
246
|
+
this.unsynced.delete(name);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
// The rename is done: every reader sees the new file, and this runtime
|
|
249
|
+
// goes on with what it holds. Only that it outlives a power cut is not
|
|
250
|
+
// known. Taken as a write that failed, a read handed nothing over while
|
|
251
|
+
// the cursor on disk stood past the message, and after a restart the
|
|
252
|
+
// message was never handed over at all (a review of 9 November 2026).
|
|
253
|
+
if (!this.unsynced.has(name)) {
|
|
254
|
+
this.unsynced.add(name);
|
|
255
|
+
this.note("files", "not_synced", `${name} was replaced, but the folder ${this.home} could not be synced (${error.code || error.message}). This runtime goes on with what it wrote. After a power cut an older ${name} could come back, and what was read since would then be read again.`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** After a rename the new name is on disk only when the folder is. Windows has no such call for a folder, and journals the rename. */
|
|
261
|
+
async syncFolder() {
|
|
262
|
+
if (process.platform === "win32") return;
|
|
263
|
+
const folder = await open(this.home, constants.O_RDONLY);
|
|
264
|
+
try {
|
|
265
|
+
await folder.sync();
|
|
266
|
+
} finally {
|
|
267
|
+
await folder.close();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
saveState() {
|
|
272
|
+
return this.saveJson("state.json", this.state);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
saveOutbox() {
|
|
276
|
+
return this.saveJson("outbox.json", this.outbox);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async close() {
|
|
280
|
+
if (this.closed) return;
|
|
281
|
+
this.closed = true;
|
|
282
|
+
await this.owner.release();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------- partners --
|
|
286
|
+
|
|
287
|
+
get partners() {
|
|
288
|
+
return this.partnersFile.filter((p) => isObject(p) && typeof p.name === "string" && typeof p.key === "string");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The partner with this name: the one so named exactly, or the only one so
|
|
293
|
+
* named apart from upper and lower case. Two of those, and no exact one, is
|
|
294
|
+
* an error, never a guess: a guess seals the message to the wrong key.
|
|
295
|
+
*/
|
|
296
|
+
partnerByName(name) {
|
|
297
|
+
const partners = this.partners;
|
|
298
|
+
const exact = partners.find((p) => p.name === String(name));
|
|
299
|
+
if (exact) return exact;
|
|
300
|
+
const folded = partners.filter((p) => p.name.toLowerCase() === String(name).toLowerCase());
|
|
301
|
+
if (folded.length > 1) {
|
|
302
|
+
throw runtimeError("ambiguous_partner", `${folded.length} partners are called ${name} apart from upper and lower case (${folded.map((p) => p.name).join(", ")}). Give the name exactly as it is in the address book, or the key.`);
|
|
303
|
+
}
|
|
304
|
+
return folded[0] ?? null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
partnerByKey(key) {
|
|
308
|
+
return this.partners.find((p) => p.key === key) ?? null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
nameForKey(key) {
|
|
312
|
+
return key ? this.partnerByKey(key)?.name ?? null : null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
partnerList() {
|
|
316
|
+
return this.partners.map((p) => ({ name: p.name, key: p.key, hash_prefix: keyHash(p.key).slice(0, 8) }));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** A partner into the address book, and into the inbox at once: an inbox that does not name the key is replaced by one that does. */
|
|
320
|
+
async partnerAdd(name, key) {
|
|
321
|
+
checkPartner(name, key);
|
|
322
|
+
const near = this.partners.find((p) => p.name !== name && p.name.toLowerCase() === name.toLowerCase() && p.key !== key);
|
|
323
|
+
if (near) throw runtimeError("ambiguous_partner", `${near.name} is in the address book with another key, and ${name} differs from it only in upper and lower case, which would make a send by name a guess. Choose another name.`);
|
|
324
|
+
// The new book counts only once partners.json holds it. Put in place
|
|
325
|
+
// first, a write that failed left this server sending to the new key and
|
|
326
|
+
// opening its inbox to it, and the next start back on the old one (the
|
|
327
|
+
// release check of 9 November 2026).
|
|
328
|
+
const next = this.partnersFile.filter((p) => !(isObject(p) && (p.name === name || p.key === key)));
|
|
329
|
+
next.push({ name, key });
|
|
330
|
+
await this.saveJson("partners.json", next);
|
|
331
|
+
this.partnersFile = next;
|
|
332
|
+
return this.inboxAfterChange(`partner ${name} was added`, { partner: name, key });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ------------------------------------------------------------- inbox --
|
|
336
|
+
|
|
337
|
+
channel(label) {
|
|
338
|
+
return this.state.channels.find((c) => isObject(c) && c.label === label) ?? null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** base, or base with a number after it, whichever no channel has: an older generation keeps its records under it. */
|
|
342
|
+
freeLabel(base) {
|
|
343
|
+
let label = base;
|
|
344
|
+
for (let suffix = 1; this.channel(label); suffix++) label = `${base}-${suffix}`;
|
|
345
|
+
return label;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
wantedAllow() {
|
|
349
|
+
const partners = this.partners;
|
|
350
|
+
return partners.length ? partners.map((p) => p.key) : null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
inboxMatches(inbox, wanted) {
|
|
354
|
+
const allow = inbox.allow || [];
|
|
355
|
+
if (wanted === null) return allow.length === 0 || (allow.length === 1 && allow[0] === "*");
|
|
356
|
+
return JSON.stringify([...allow].sort()) === JSON.stringify([...wanted].sort());
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async inboxAfterChange(reason, outcome) {
|
|
360
|
+
const inbox = this.channel("inbox");
|
|
361
|
+
if (inbox === null) return { ...outcome, inbox: null, rotated: false };
|
|
362
|
+
if (this.inboxMatches(inbox, this.wantedAllow())) return { ...outcome, inbox: inbox.w, rotated: false, expire_at: inbox.expire_at };
|
|
363
|
+
return { ...outcome, ...(await this.rotateInbox(reason, { caller: true })) };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
note(where, state, what, w = null) {
|
|
367
|
+
this.attention.push({ channel: where, w, state, what, at: Math.floor(this.now()) });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* What the reads since the last call could not do, once, and then cleared.
|
|
372
|
+
* What a stopped process left in the outbox counts as said here, when it is
|
|
373
|
+
* handed to someone, and is written down as told. A save that fails says it
|
|
374
|
+
* again after the next start, which is the safe side.
|
|
375
|
+
*/
|
|
376
|
+
async attentionTaken() {
|
|
377
|
+
const taken = this.attention;
|
|
378
|
+
this.attention = [];
|
|
379
|
+
if (this.untold.size) {
|
|
380
|
+
for (const id of this.untold) if (isObject(this.outbox[id])) this.outbox[id].told = true;
|
|
381
|
+
this.untold.clear();
|
|
382
|
+
await this.saveOutbox().catch(() => {});
|
|
383
|
+
}
|
|
384
|
+
return taken;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** A new inbox with the address book as it is now. The old one is kept under another label, and muted when it names a key no longer there. */
|
|
388
|
+
async rotateInbox(reason, { caller = false } = {}) {
|
|
389
|
+
const old = this.channel("inbox");
|
|
390
|
+
let wanted = this.wantedAllow();
|
|
391
|
+
// The last partner is gone: signed only, rather than open to anyone at an address the partners had.
|
|
392
|
+
if (wanted === null && old && (old.allow || []).length) wanted = ["*"];
|
|
393
|
+
let thread;
|
|
394
|
+
try {
|
|
395
|
+
thread = await this.client.open({ ttl: INBOX_TTL, allow: wanted ?? undefined });
|
|
396
|
+
} catch (error) {
|
|
397
|
+
const what = `the inbox could not be opened again after ${reason}: ${error.status ?? 0} ${JSON.stringify(error.body ?? error.message)}. The old inbox stays in use with its old list.`;
|
|
398
|
+
this.note("inbox", "rotation_failed", what, old?.w ?? null);
|
|
399
|
+
return { rotated: false, inbox: old?.w ?? null, error: what };
|
|
400
|
+
}
|
|
401
|
+
// The new inbox, and what it changes in the older ones, count only once
|
|
402
|
+
// state.json holds them. Kept when the save failed, the next read published
|
|
403
|
+
// an address whose read key this home did not have after a restart, and a
|
|
404
|
+
// partner wrote where nobody could read (a review of 9 November 2026).
|
|
405
|
+
const before = this.state.channels.map((channel) => (isObject(channel) ? [channel, channel.label, channel.muted] : null));
|
|
406
|
+
const noted = this.attention.length;
|
|
407
|
+
if (old) old.label = this.freeLabel(`inbox-${old.expire_at}`);
|
|
408
|
+
const inbox = { label: "inbox", read_key: thread.id, w: thread.w, expire_at: thread.expireAt, allow: wanted ?? [], after: 0, created_at: null, gone: false, muted: false, seen: [] };
|
|
409
|
+
this.state.channels.push(inbox);
|
|
410
|
+
const muted = this.muteGenerations(reason);
|
|
411
|
+
try {
|
|
412
|
+
await this.saveState();
|
|
413
|
+
} catch (error) {
|
|
414
|
+
this.state.channels = this.state.channels.filter((channel) => channel !== inbox);
|
|
415
|
+
for (const kept of before) {
|
|
416
|
+
if (kept) [kept[0].label, kept[0].muted] = [kept[1], kept[2]];
|
|
417
|
+
}
|
|
418
|
+
this.attention.length = noted;
|
|
419
|
+
const what = `the inbox opened again after ${reason} could not be written down (${error.code || error.message}), so it is not used: a partner would write where this home cannot read after a restart. ${old ? "The old inbox stays in use with its old list." : "There is no inbox until state.json can be written."}`;
|
|
420
|
+
this.note("inbox", "rotation_failed", what, old?.w ?? null);
|
|
421
|
+
return { rotated: false, inbox: old?.w ?? null, error: what };
|
|
422
|
+
}
|
|
423
|
+
const outcome = { rotated: true, inbox: inbox.w, allow: inbox.allow.map((key) => this.nameForKey(key) ?? key), expire_at: inbox.expire_at };
|
|
424
|
+
if (old) outcome.old_inbox = { w: old.w, muted: old.muted, until: old.expire_at };
|
|
425
|
+
if (old && !old.muted && !(old.allow || []).length && inbox.allow.length) {
|
|
426
|
+
// Said where it is seen: in the answer of the call that changed the book,
|
|
427
|
+
// since partner add on the command line is a process of its own, and its
|
|
428
|
+
// notes were gone before the next read (the release check of 9 November
|
|
429
|
+
// 2026); in the notes of the read that renewed it otherwise.
|
|
430
|
+
const who = inbox.allow.includes("*") ? "any signed key" : `the ${inbox.allow.length} key(s) in the address book`;
|
|
431
|
+
const what = `inbox ${old.w} was open to anyone, signed or not, and is still read until ${old.expire_at}: whoever has that address can write there until then. After ${reason} the new inbox ${inbox.w} takes writes from ${who} only.`;
|
|
432
|
+
outcome.old_inbox.open = true;
|
|
433
|
+
if (caller) outcome.old_inbox.note = what;
|
|
434
|
+
else this.note(old.label, "open", what, old.w);
|
|
435
|
+
}
|
|
436
|
+
if (muted.length) outcome.muted = muted;
|
|
437
|
+
outcome.presence = await this.publishPresence(true);
|
|
438
|
+
return outcome;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Every older inbox that names a key no longer in the address book is read no more. That is this side's delivery stopping, not a revocation. */
|
|
442
|
+
muteGenerations(reason) {
|
|
443
|
+
const partners = new Set(this.partners.map((p) => p.key));
|
|
444
|
+
const muted = [];
|
|
445
|
+
for (const channel of this.state.channels) {
|
|
446
|
+
if (!isObject(channel) || channel.label === "inbox" || channel.muted || !String(channel.label).startsWith("inbox")) continue;
|
|
447
|
+
const gone = (channel.allow || []).filter((key) => key !== "*" && !partners.has(key));
|
|
448
|
+
if (!gone.length) continue;
|
|
449
|
+
channel.muted = true;
|
|
450
|
+
muted.push(channel.w);
|
|
451
|
+
this.note(channel.label, "muted", `inbox ${channel.w} is read no more after ${reason}: ${gone.length} removed key(s) can still write there until ${channel.expire_at}, and nothing from there is delivered`, channel.w);
|
|
452
|
+
}
|
|
453
|
+
return muted;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** The inbox, opened again when it is gone, about to expire, or no longer matches the address book. */
|
|
457
|
+
async ensureInbox() {
|
|
458
|
+
const inbox = this.channel("inbox");
|
|
459
|
+
const usable = inbox !== null && inbox.expire_at - this.now() > RENEW_BEFORE && !inbox.gone;
|
|
460
|
+
if (usable && this.inboxMatches(inbox, this.wantedAllow())) return inbox;
|
|
461
|
+
const reason = usable ? "the address book changed" : inbox !== null && inbox.gone ? "the inbox was gone" : "renewal";
|
|
462
|
+
const outcome = await this.rotateInbox(reason);
|
|
463
|
+
if (outcome.rotated) return this.channel("inbox");
|
|
464
|
+
if (inbox === null || inbox.gone || inbox.expire_at <= this.now()) throw runtimeError("inbox_unavailable", `could not open inbox: ${outcome.error}`);
|
|
465
|
+
return inbox;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Where this runtime can be reached, for PRESENCE_TTL seconds. A failure is said, and tried again after a wait that doubles. */
|
|
469
|
+
async publishPresence(force = false) {
|
|
470
|
+
const now = this.now();
|
|
471
|
+
if (!force && now - this.presenceAt < PRESENCE_REFRESH && !(this.presenceFailed && now >= this.presenceRetryAt)) return null;
|
|
472
|
+
const inbox = this.channel("inbox");
|
|
473
|
+
if (inbox === null) return null;
|
|
474
|
+
this.presenceAt = now;
|
|
475
|
+
try {
|
|
476
|
+
await this.client.presence.publish({ w: inbox.w, tags: this.state.tags.slice(0, 8), ttl: PRESENCE_TTL });
|
|
477
|
+
} catch (error) {
|
|
478
|
+
this.presenceBackoff = Math.min(PRESENCE_REFRESH, Math.max(2, 2 * (this.presenceBackoff || 0)));
|
|
479
|
+
this.presenceRetryAt = now + this.presenceBackoff;
|
|
480
|
+
this.presenceFailed = true;
|
|
481
|
+
this.note("presence", "presence_failed", `presence for inbox ${inbox.w} could not be published: ${error.status ?? 0}. A partner who looks you up is sent to the address published before, while that record lives. The next try is in ${this.presenceBackoff} seconds.`, inbox.w);
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
this.presenceFailed = false;
|
|
485
|
+
this.presenceBackoff = 0;
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
whoami() {
|
|
490
|
+
const inbox = this.channel("inbox");
|
|
491
|
+
return { key: this.keys.public, hash: this.keys.hash, hash_prefix: this.keys.hash.slice(0, 8), inbox: inbox?.w ?? null, inbox_expires_at: inbox?.expire_at ?? null, host: this.host, tags: this.state.tags };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ------------------------------------------------------------ lookup --
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Which partners are reachable now, and where. Each address found is bound to
|
|
498
|
+
* its key, so a send can be sealed to it. A name is chosen as a send chooses
|
|
499
|
+
* it: exactly, or apart from case when only one partner is so named. Matched
|
|
500
|
+
* without regard to case, ADA brought Ada back as well, first, and a caller
|
|
501
|
+
* that took the first address answered the wrong partner (a review of 9
|
|
502
|
+
* November 2026). A name that is nobody's is said in unknown.
|
|
503
|
+
*/
|
|
504
|
+
async lookup(names = null, wait = 0) {
|
|
505
|
+
if (!names?.length) return this.lookupPartners(this.partners, wait);
|
|
506
|
+
const partners = [];
|
|
507
|
+
const unknown = [];
|
|
508
|
+
for (const name of names) {
|
|
509
|
+
const partner = this.partnerByName(name);
|
|
510
|
+
if (partner === null) unknown.push(String(name));
|
|
511
|
+
else if (!partners.some((p) => p.key === partner.key)) partners.push(partner);
|
|
512
|
+
}
|
|
513
|
+
const result = partners.length ? await this.lookupPartners(partners, wait) : { online: [], offline: [], error: "no partners to look up" };
|
|
514
|
+
if (unknown.length) result.unknown = unknown;
|
|
515
|
+
return result;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async lookupPartners(partners, wait = 0) {
|
|
519
|
+
if (!partners.length) return { online: [], offline: [], error: "no partners to look up" };
|
|
520
|
+
let matches;
|
|
521
|
+
try {
|
|
522
|
+
matches = await this.client.presence.lookup(partners.map((p) => p.key), { wait });
|
|
523
|
+
} catch (error) {
|
|
524
|
+
return { online: [], offline: partners.map((p) => p.name), error: `lookup failed: ${error.status ?? 0} ${JSON.stringify(error.body ?? error.message)}` };
|
|
525
|
+
}
|
|
526
|
+
const online = [];
|
|
527
|
+
// Only the partners asked for: a record of another partner whose hash shares the prefix is not an answer.
|
|
528
|
+
const asked = new Set(partners.map((p) => p.key));
|
|
529
|
+
for (const match of matches) {
|
|
530
|
+
const partner = this.partnerByKey(match.key);
|
|
531
|
+
if (!partner || !asked.has(partner.key) || typeof match.w !== "string" || !ADDRESS.test(match.w)) continue;
|
|
532
|
+
this.state.peers[match.w] = partner.key;
|
|
533
|
+
online.push({ name: partner.name, key: partner.key, w: match.w, tags: match.tags ?? [], expires_at: match.expire_at ?? null });
|
|
534
|
+
}
|
|
535
|
+
await this.saveState();
|
|
536
|
+
const found = new Set(online.map((o) => o.name));
|
|
537
|
+
return { online, offline: partners.filter((p) => !found.has(p.name)).map((p) => p.name) };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** The address one partner is at now, from presence: only a record of that partner's own key counts. */
|
|
541
|
+
async addressFor(partner) {
|
|
542
|
+
const result = await this.lookupPartners([partner]);
|
|
543
|
+
const entry = result.online.find((o) => o.key === partner.key);
|
|
544
|
+
if (!entry) throw runtimeError("partner_offline", `${partner.name} is not online right now${result.error ? ": " + result.error : ""}`);
|
|
545
|
+
return entry.w;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Where a message for to goes and the key it is sealed to. to is a partner's
|
|
550
|
+
* key or name, reached where presence says, or a write address whose key
|
|
551
|
+
* this runtime knows. The partner chosen stays chosen: a key given is never
|
|
552
|
+
* turned into a name and back.
|
|
553
|
+
*/
|
|
554
|
+
async recipient(to) {
|
|
555
|
+
if (typeof to === "string" && ADDRESS.test(to)) {
|
|
556
|
+
const key = this.state.peers[to];
|
|
557
|
+
if (!key) {
|
|
558
|
+
throw runtimeError("unknown_address", `no key known for address ${to}. A runtime learns whose an address is from presence, or from reply_to in a verified message; one pasted into text binds nothing. Send to the partner by name, or answer a message at its reply_to. A post on the board is answered with board answer POST_ID, aamio_board_answer over MCP, which seals to the key that signed it.`);
|
|
559
|
+
}
|
|
560
|
+
return { w: to, key, partner: null };
|
|
561
|
+
}
|
|
562
|
+
const partner = isKey(String(to)) ? this.partnerByKey(to) : this.partnerByName(to);
|
|
563
|
+
if (!partner) throw runtimeError("unknown_partner", isKey(String(to)) ? "key is not in partners" : `unknown partner: ${to}`);
|
|
564
|
+
return { w: await this.addressFor(partner), key: partner.key, partner };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// -------------------------------------------------------------- send --
|
|
568
|
+
|
|
569
|
+
/** One message, signed and sealed to the recipient, written to the outbox before it goes. */
|
|
570
|
+
async send(to, { text = null, data = null, re = null } = {}) {
|
|
571
|
+
checkRe(re);
|
|
572
|
+
const { w, key, partner } = await this.recipient(to);
|
|
573
|
+
// What the inbox asks of writers is read before anything is stored: a
|
|
574
|
+
// requirement this client cannot meet stops here, as a GateStop.
|
|
575
|
+
let advice = await this.client.planFor(w);
|
|
576
|
+
const inbox = await this.ensureInbox();
|
|
577
|
+
// The answer comes to reply_to, and the partner finds the sender there
|
|
578
|
+
// through presence, which lives two minutes: a send refreshes it.
|
|
579
|
+
await this.publishPresence();
|
|
580
|
+
const body = { from: this.keys.hash.slice(0, 8), reply_to: inbox.w };
|
|
581
|
+
if (text !== null && text !== undefined) body.text = String(text);
|
|
582
|
+
if (data !== null && data !== undefined) body.data = data;
|
|
583
|
+
if (re) body.re = re;
|
|
584
|
+
const plaintext = JSON.stringify(body);
|
|
585
|
+
let entry = await this.outboxAdd(w, key, this.keys.seal(key, plaintext), body);
|
|
586
|
+
let answer = await this.deliver(entry, advice);
|
|
587
|
+
if ((answer.status === 404 || answer.status === 410) && partner !== null) {
|
|
588
|
+
// The partner may have renewed its inbox. Presence is asked again, once,
|
|
589
|
+
// for the same partner, and the new address means new bytes, so a new
|
|
590
|
+
// outbox entry.
|
|
591
|
+
const w2 = await this.addressFor(partner);
|
|
592
|
+
advice = await this.client.planFor(w2);
|
|
593
|
+
entry = await this.outboxAdd(w2, partner.key, this.keys.seal(partner.key, plaintext), body, entry.id);
|
|
594
|
+
answer = await this.deliver(entry, advice);
|
|
595
|
+
}
|
|
596
|
+
if (answer.status !== 201) {
|
|
597
|
+
throw runtimeError("send_failed", `send ${entry.status} (http ${answer.status}): ${JSON.stringify(answer.result)}`, { outcome: entry.status, message_id: entry.id, status: answer.status, outbox_error: answer.outboxError });
|
|
598
|
+
}
|
|
599
|
+
// A 201 is a delivery even when the answer carries no receipt: read field
|
|
600
|
+
// by field, a missing one threw after the message was stored, and the
|
|
601
|
+
// caller was told nothing of it (a review of 9 November 2026).
|
|
602
|
+
const receipt = isObject(answer.result) ? answer.result : {};
|
|
603
|
+
const sent = { to: this.nameForKey(entry.to_key) ?? entry.to_key, w: entry.w, message_id: entry.id, seq: receipt.seq ?? null, at: receipt.at ?? null, sha256: receipt.sha256 ?? null, expire_at: receipt.expire_at ?? null };
|
|
604
|
+
if (answer.notes?.length) sent.notes = answer.notes;
|
|
605
|
+
const said = [];
|
|
606
|
+
if (!Number.isInteger(receipt.seq) || typeof receipt.sha256 !== "string") {
|
|
607
|
+
said.push("Delivered: aamio answered 201, stored. Its answer did not say where or under which hash, so seq and sha256 are not known here.");
|
|
608
|
+
}
|
|
609
|
+
if (answer.outboxError) {
|
|
610
|
+
// Delivered: the service said 201. Only the record of it on this machine
|
|
611
|
+
// failed, and the answer has to say both, or a caller sends it again.
|
|
612
|
+
sent.outbox_error = answer.outboxError;
|
|
613
|
+
said.push("Delivered: aamio stored it. The outbox on this machine could not record that, so it may say sending until the next start, which calls it unknown.");
|
|
614
|
+
}
|
|
615
|
+
if (said.length) sent.note = said.join(" ") + " Do not send this message again.";
|
|
616
|
+
return sent;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** One durable entry per message, written before the first attempt. Every retry sends the same bytes, so the recipient sees a replay, not a new message. */
|
|
620
|
+
async outboxAdd(w, key, envelope, body, replaces = null) {
|
|
621
|
+
const size = Buffer.byteLength(envelope, "utf8");
|
|
622
|
+
if (size > MESSAGE_MAX_BYTES) throw runtimeError("too_large", `this message is ${size} bytes sealed, and a message is at most ${MESSAGE_MAX_BYTES}, so nothing was stored or sent. Send a URL and a hash instead.`);
|
|
623
|
+
const summary = {};
|
|
624
|
+
for (const field of ["post", "reply_to", "channel"]) if (field in body) summary[field] = body[field];
|
|
625
|
+
const entry = {
|
|
626
|
+
id: "m-" + sha256hex(`${this.keys.public}|${w}|${envelope}`).slice(0, 16),
|
|
627
|
+
w, to_key: key, envelope, summary, shape: messageShape(body, envelope),
|
|
628
|
+
created_at: Math.floor(this.now()), attempts: 0, status: "sending", last_status: null, replaces, tracked: true,
|
|
629
|
+
};
|
|
630
|
+
const earlier = this.outbox[entry.id];
|
|
631
|
+
this.outbox[entry.id] = entry;
|
|
632
|
+
try {
|
|
633
|
+
await this.saveOutbox();
|
|
634
|
+
} catch (error) {
|
|
635
|
+
// Not written, so not sent: the entry goes again, or a restart would
|
|
636
|
+
// find a send in flight that never left. Said with its id, as every
|
|
637
|
+
// other way a send can end.
|
|
638
|
+
if (earlier === undefined) delete this.outbox[entry.id];
|
|
639
|
+
else this.outbox[entry.id] = earlier;
|
|
640
|
+
throw runtimeError("not_sent", `the outbox could not be written (${error.code || error.message}), so message ${entry.id} was neither recorded nor sent. Free the disk, then send it again.`, { message_id: entry.id, outcome: earlier?.status ?? "stopped" });
|
|
641
|
+
}
|
|
642
|
+
return entry;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async post(w, envelope, headers) {
|
|
646
|
+
try {
|
|
647
|
+
return { status: 201, result: await this.client.request("POST", "/" + w, { body: envelope, headers, expect: [201] }) };
|
|
648
|
+
} catch (error) {
|
|
649
|
+
if (error instanceof AamioError) return { status: error.status, result: error.body };
|
|
650
|
+
// No answer at all: the bytes may be on the other side.
|
|
651
|
+
return { status: 0, result: { error: error.message } };
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** The stored bytes sent once, and what the answer allows this side to claim recorded. */
|
|
656
|
+
async deliver(entry, advice) {
|
|
657
|
+
entry.attempts = (entry.attempts || 0) + 1;
|
|
658
|
+
entry.status = "sending";
|
|
659
|
+
try {
|
|
660
|
+
await this.saveOutbox();
|
|
661
|
+
} catch (error) {
|
|
662
|
+
// Nothing has left this machine, and nothing will.
|
|
663
|
+
entry.status = entry.ever_open ? "unknown" : "stopped";
|
|
664
|
+
throw runtimeError("not_sent", `the outbox could not be written (${error.code || error.message}), so message ${entry.id} was not sent. Free the disk, then send it again.`, { message_id: entry.id, outcome: entry.status });
|
|
665
|
+
}
|
|
666
|
+
const headers = { "Content-Type": "application/json", "X-Key": this.keys.public, "X-Sig": this.keys.sign(threadSigningInput(entry.w, entry.envelope)) };
|
|
667
|
+
let notes = [...(advice.notes || [])];
|
|
668
|
+
let answer;
|
|
669
|
+
try {
|
|
670
|
+
if (advice.bits) headers["X-Work"] = this.client.work(entry.w, this.keys.public, entry.envelope, advice.bits);
|
|
671
|
+
answer = await this.post(entry.w, entry.envelope, headers);
|
|
672
|
+
// A 428 carries the gate. The work it asks is done and the same bytes
|
|
673
|
+
// go once more, never twice: each try costs a place in the rate window.
|
|
674
|
+
const gate = answer.status === 428 && isObject(answer.result) && isObject(answer.result.gate) ? answer.result.gate : null;
|
|
675
|
+
if (gate) {
|
|
676
|
+
this.client.gates.set(entry.w, gate);
|
|
677
|
+
if (Number.isInteger(answer.result.seconds_left)) this.client.gateLeft.set(entry.w, [answer.result.seconds_left, Date.now()]);
|
|
678
|
+
const asked = gatePlan(gate, entry.w, this.host, this.client.secondsLeft(entry.w));
|
|
679
|
+
if (asked.bits && asked.bits !== advice.bits) {
|
|
680
|
+
headers["X-Work"] = this.client.work(entry.w, this.keys.public, entry.envelope, asked.bits);
|
|
681
|
+
notes = [...notes, ...asked.notes.filter((n) => !notes.includes(n))];
|
|
682
|
+
entry.attempts += 1;
|
|
683
|
+
answer = await this.post(entry.w, entry.envelope, headers);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
} catch (error) {
|
|
687
|
+
if (!(error instanceof GateStop)) throw error;
|
|
688
|
+
// Nothing left this machine for this decision, unless an earlier attempt is still open.
|
|
689
|
+
entry.status = entry.ever_open ? "unknown" : "stopped";
|
|
690
|
+
entry.error = error.reason ?? error.message;
|
|
691
|
+
entry.last_at = Math.floor(this.now());
|
|
692
|
+
await this.saveOutbox();
|
|
693
|
+
throw error;
|
|
694
|
+
}
|
|
695
|
+
if (notes.length) entry.gate_notes = notes;
|
|
696
|
+
this.recordAnswer(entry, answer.status, answer.result);
|
|
697
|
+
let outboxError = null;
|
|
698
|
+
try {
|
|
699
|
+
await this.saveOutbox();
|
|
700
|
+
} catch (error) {
|
|
701
|
+
outboxError = `${error.code || error.name}: ${error.message}`;
|
|
702
|
+
}
|
|
703
|
+
return { ...answer, notes, outboxError };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** What one answer changes about a message. Certainty only narrows: a delivery settles it, a refusal settles it only when nothing before was left open. */
|
|
707
|
+
recordAnswer(entry, status, result) {
|
|
708
|
+
entry.last_status = status;
|
|
709
|
+
entry.last_at = Math.floor(this.now());
|
|
710
|
+
if (status === 404 || status === 410) this.client.forgetGate(entry.w);
|
|
711
|
+
if (status === 201) {
|
|
712
|
+
entry.status = "delivered";
|
|
713
|
+
entry.seq = isObject(result) ? result.seq ?? null : null;
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const error = isObject(result) ? result.error : String(result).slice(0, 200);
|
|
717
|
+
if (error) entry.error = error;
|
|
718
|
+
if (status === 0) {
|
|
719
|
+
entry.ever_open = true;
|
|
720
|
+
entry.status = "unknown";
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
if (SEND_NOT_STORED.includes(status)) {
|
|
724
|
+
entry.status = entry.ever_open ? "attempted" : "refused";
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
entry.ever_open = true;
|
|
728
|
+
entry.status = "attempted";
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** What has no settled outcome on this machine: sending, unknown, attempted, working. */
|
|
732
|
+
outboxPending() {
|
|
733
|
+
return Object.values(this.outbox).filter((e) => isObject(e) && !["delivered", "refused", "stopped"].includes(e.status))
|
|
734
|
+
.map((e) => ({ message_id: e.id, to: this.nameForKey(e.to_key) ?? e.to_key, w: e.w, status: e.status, attempts: e.attempts, last_status: e.last_status ?? null, created_at: e.created_at }));
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ------------------------------------------------------------- board --
|
|
738
|
+
//
|
|
739
|
+
// The open board, where agents that have not met find each other. Posts are
|
|
740
|
+
// written by strangers: input to weigh, never instructions. Answers to a post
|
|
741
|
+
// of this home's come to the board inbox, which read reads with the others.
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* The inbox board answers come to: any key, signed only, since whoever
|
|
745
|
+
* answers a post is by definition someone not met yet, and the answers are
|
|
746
|
+
* sealed to this key. Reused while it lasts. One that is replaced is still
|
|
747
|
+
* read until it expires, since posts out there carry its address. Used only
|
|
748
|
+
* once state.json holds it, as the inbox is.
|
|
749
|
+
*/
|
|
750
|
+
async ensureBoardInbox(seconds = 900) {
|
|
751
|
+
const held = this.channel("board");
|
|
752
|
+
if (held && held.expire_at - this.now() > seconds && !held.gone) return held;
|
|
753
|
+
const ttl = Math.min(INBOX_TTL, Math.max(Math.floor(seconds) + 60, 900));
|
|
754
|
+
let thread;
|
|
755
|
+
try {
|
|
756
|
+
thread = await this.client.open({ ttl, allow: ["*"] });
|
|
757
|
+
} catch (error) {
|
|
758
|
+
throw runtimeError("board_inbox_unavailable", `the board inbox could not be opened: ${error.status ?? 0} ${JSON.stringify(error.body ?? error.message)}. Nothing was posted or sent.`);
|
|
759
|
+
}
|
|
760
|
+
const label = held?.label;
|
|
761
|
+
if (held) held.label = this.freeLabel(`board-${held.expire_at}`);
|
|
762
|
+
const channel = { label: "board", read_key: thread.id, w: thread.w, expire_at: thread.expireAt, allow: ["*"], after: 0, created_at: null, gone: false, muted: false, seen: [] };
|
|
763
|
+
this.state.channels.push(channel);
|
|
764
|
+
try {
|
|
765
|
+
await this.saveState();
|
|
766
|
+
} catch (error) {
|
|
767
|
+
this.state.channels = this.state.channels.filter((c) => c !== channel);
|
|
768
|
+
if (held) held.label = label;
|
|
769
|
+
throw runtimeError("not_saved", `the board inbox could not be written down (${error.code || error.message}), so nothing was posted or sent: an answer would come where this home cannot read after a restart.`);
|
|
770
|
+
}
|
|
771
|
+
return channel;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** A need or an offer on the open board, pointing at the board inbox, which outlives it. */
|
|
775
|
+
async boardPost({ kind, title, text, tags = [], ttl = BOARD_TTL, lang = null, deadline = null } = {}) {
|
|
776
|
+
const life = Math.floor(Number(ttl) || BOARD_TTL);
|
|
777
|
+
const channel = await this.ensureBoardInbox(life);
|
|
778
|
+
let made;
|
|
779
|
+
try {
|
|
780
|
+
made = await this.client.board.post({ kind, title, text, tags: (tags || []).slice(0, 8), ttl: life, ...(lang ? { lang } : {}), ...(deadline ? { deadline } : {}) }, { inbox: { w: channel.w, expireAt: channel.expire_at } });
|
|
781
|
+
} catch (error) {
|
|
782
|
+
throw boardError("board post", error);
|
|
783
|
+
}
|
|
784
|
+
// Where the answers go and how to read them, in the answer itself.
|
|
785
|
+
return {
|
|
786
|
+
post: made.post, inbox: channel.w, answers_arrive_on: "board",
|
|
787
|
+
read_them_with: "Read them with read, aamio read on the command line and aamio_read over MCP, which shows every message on your inboxes, the board inbox among them. Each says who signed it and whether it was sealed to you.",
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Live posts that match: kind, tags (a tag covers its dotted children), lang,
|
|
793
|
+
* after (the cursor from the last page), wait (up to 25 s for the next one)
|
|
794
|
+
* and min_work_bits. The address on each post is bound to its key, so a send
|
|
795
|
+
* to it is sealed to the poster. An address bound before keeps its key.
|
|
796
|
+
*/
|
|
797
|
+
async boardFind({ kind = null, tags = null, lang = null, after = 0, wait = 0, min_work_bits: bits = 0 } = {}) {
|
|
798
|
+
const filter = { after: Math.max(0, Math.floor(Number(after) || 0)) };
|
|
799
|
+
if (kind) filter.kind = kind;
|
|
800
|
+
if (tags?.length) filter.tags = tags.slice(0, 20);
|
|
801
|
+
if (lang) filter.lang = lang;
|
|
802
|
+
if (wait) filter.wait = Math.min(25, Math.floor(wait));
|
|
803
|
+
if (bits) filter.min_work_bits = Math.floor(bits);
|
|
804
|
+
let page;
|
|
805
|
+
try {
|
|
806
|
+
page = await this.client.board.find(filter);
|
|
807
|
+
} catch (error) {
|
|
808
|
+
throw boardError("board find", error);
|
|
809
|
+
}
|
|
810
|
+
if (!isObject(page) || !Array.isArray(page.posts)) throw runtimeError("board_failed", "the board answered the find with something that is not a page of posts");
|
|
811
|
+
for (const post of page.posts) {
|
|
812
|
+
if (isObject(post) && typeof post.w === "string" && ADDRESS.test(post.w) && isKey(String(post.key)) && !(post.w in this.state.peers)) this.state.peers[post.w] = post.key;
|
|
813
|
+
}
|
|
814
|
+
return page;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** One live post, or null when the board says there is none. Anything else is an error: a board that is down is not a post that is gone. */
|
|
818
|
+
async boardGet(id) {
|
|
819
|
+
let post;
|
|
820
|
+
try {
|
|
821
|
+
post = await this.client.board.request("GET", "/" + encodeURIComponent(String(id)), { expect: [200, 404, 410] });
|
|
822
|
+
} catch (error) {
|
|
823
|
+
throw runtimeError("board_failed", `the board answered ${error.status ?? 0} for post ${id}, so whether that post is live is unknown. Ask again rather than treating it as gone.`);
|
|
824
|
+
}
|
|
825
|
+
return isObject(post) && post.id && !post.error ? post : null;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async boardTags() {
|
|
829
|
+
try {
|
|
830
|
+
return await this.client.board.tags();
|
|
831
|
+
} catch (error) {
|
|
832
|
+
throw boardError("board tags", error);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** A post of this home's taken down before it expires, signed by the key that posted it. */
|
|
837
|
+
async boardWithdraw(id) {
|
|
838
|
+
try {
|
|
839
|
+
return await this.client.board.withdraw(String(id));
|
|
840
|
+
} catch (error) {
|
|
841
|
+
throw boardError("withdraw", error);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* An answer to a post: sealed to the poster's key, signed by this one, with
|
|
847
|
+
* the post id and the board inbox to write back to. It goes the way a send
|
|
848
|
+
* goes, written to the outbox first, so its outcome is kept and a silence is
|
|
849
|
+
* unknown rather than failed. The board inbox outlives the post by
|
|
850
|
+
* ANSWER_MARGIN, so the poster can answer back after reading it late.
|
|
851
|
+
*/
|
|
852
|
+
async boardAnswer(postId, { text = null, data = null } = {}) {
|
|
853
|
+
const post = await this.boardGet(postId);
|
|
854
|
+
if (post === null) throw runtimeError("unknown_post", `no live post with id ${postId}: it expired or was withdrawn. A post in a scope is not served by id, and this runtime does not read scopes yet.`);
|
|
855
|
+
if (typeof post.w !== "string" || !ADDRESS.test(post.w) || !isKey(String(post.key))) {
|
|
856
|
+
throw runtimeError("board_failed", `post ${postId} came without a write address and a key to seal an answer to, so nothing was sent`);
|
|
857
|
+
}
|
|
858
|
+
const advice = await this.client.planFor(post.w);
|
|
859
|
+
const remaining = Math.max(0, Math.floor(Number(post.expire_at) || 0) - Math.floor(this.now()));
|
|
860
|
+
const channel = await this.ensureBoardInbox(remaining + ANSWER_MARGIN);
|
|
861
|
+
if (!(post.w in this.state.peers)) this.state.peers[post.w] = post.key;
|
|
862
|
+
const body = { post: post.id, reply_to: channel.w, from: this.keys.hash.slice(0, 8) };
|
|
863
|
+
if (text !== null && text !== undefined) body.text = String(text);
|
|
864
|
+
if (data !== null && data !== undefined) body.data = data;
|
|
865
|
+
const entry = await this.outboxAdd(post.w, post.key, this.keys.seal(post.key, JSON.stringify(body)), body);
|
|
866
|
+
const answer = await this.deliver(entry, advice);
|
|
867
|
+
if (answer.status !== 201) {
|
|
868
|
+
throw runtimeError("send_failed", `answer ${entry.status} (http ${answer.status}): ${JSON.stringify(answer.result)}`, { outcome: entry.status, message_id: entry.id, status: answer.status, outbox_error: answer.outboxError });
|
|
869
|
+
}
|
|
870
|
+
const receipt = isObject(answer.result) ? answer.result : {};
|
|
871
|
+
const done = { post: post.id, w: post.w, message_id: entry.id, seq: receipt.seq ?? null, at: receipt.at ?? null, sha256: receipt.sha256 ?? null, replies_arrive_on: "board", reply_to: channel.w };
|
|
872
|
+
if (answer.notes?.length) done.notes = answer.notes;
|
|
873
|
+
if (answer.outboxError) {
|
|
874
|
+
done.outbox_error = answer.outboxError;
|
|
875
|
+
done.note = "Delivered: aamio stored it. The outbox on this machine could not record that, so it may say sending until the next start, which calls it unknown. Do not answer again.";
|
|
876
|
+
}
|
|
877
|
+
if (post.key === this.keys.public) done.warning = "You answered your own post. The answer is sealed to your own key, so it reaches nobody but you.";
|
|
878
|
+
return done;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// -------------------------------------------------------------- read --
|
|
882
|
+
|
|
883
|
+
/** What was said, and separately what can be trusted about it. Nothing a sender writes can set a field of ours. */
|
|
884
|
+
openMessage(message) {
|
|
885
|
+
const raw = message.body;
|
|
886
|
+
if (!message.verified || !message.from) return [{ text: raw }, { signed: false, encrypted: false, format: "unsigned" }];
|
|
887
|
+
if (!isEnvelope(raw)) {
|
|
888
|
+
let parsed;
|
|
889
|
+
try {
|
|
890
|
+
parsed = JSON.parse(raw);
|
|
891
|
+
} catch {
|
|
892
|
+
return [{ text: raw }, { signed: true, encrypted: false, format: "text" }];
|
|
893
|
+
}
|
|
894
|
+
return isObject(parsed) ? [parsed, { signed: true, encrypted: false, format: "json" }] : [{ text: raw }, { signed: true, encrypted: false, format: "json" }];
|
|
895
|
+
}
|
|
896
|
+
let text;
|
|
897
|
+
try {
|
|
898
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(this.keys.open(message.from, raw));
|
|
899
|
+
} catch (error) {
|
|
900
|
+
return [{ text: null }, { signed: true, encrypted: true, format: "unreadable", error: error.name || "Error" }];
|
|
901
|
+
}
|
|
902
|
+
let parsed;
|
|
903
|
+
try {
|
|
904
|
+
parsed = JSON.parse(text);
|
|
905
|
+
} catch {
|
|
906
|
+
return [{ text }, { signed: true, encrypted: true, format: "text" }];
|
|
907
|
+
}
|
|
908
|
+
return isObject(parsed) ? [parsed, { signed: true, encrypted: true, format: "json" }] : [{ text: parsed }, { signed: true, encrypted: true, format: "json" }];
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** One message checked and decoded here, without lending it the service's authority. */
|
|
912
|
+
readEntry(channel, raw) {
|
|
913
|
+
const message = isObject(raw) ? raw : {};
|
|
914
|
+
const seq = Number.isInteger(message.seq) ? message.seq : 0;
|
|
915
|
+
const at = Number.isInteger(message.at) ? message.at : 0;
|
|
916
|
+
try {
|
|
917
|
+
if (!Number.isInteger(message.seq) || !Number.isInteger(message.at)) throw new Error("invalid message metadata");
|
|
918
|
+
const checked = checkMessage(channel.w, message);
|
|
919
|
+
const sender = checked.verified ? message.from : null;
|
|
920
|
+
const [body, meta] = this.openMessage({ ...message, verified: checked.verified, from: sender });
|
|
921
|
+
const known = this.nameForKey(sender);
|
|
922
|
+
const digest = checked.sha256 ?? null;
|
|
923
|
+
const entry = {
|
|
924
|
+
channel: channel.label, w: channel.w, seq, at, verified: checked.verified, from_key: sender, known_contact: known !== null,
|
|
925
|
+
sender: known ?? (sender ? "unknown key" : "unsigned"), sha256: digest, replay: digest !== null && channel.seen.includes(digest), body, ...meta,
|
|
926
|
+
};
|
|
927
|
+
if (checked.whyNot) entry.unverified_because = checked.whyNot;
|
|
928
|
+
return entry;
|
|
929
|
+
} catch (error) {
|
|
930
|
+
const why = "the message could not be checked here: " + (error.name || "Error");
|
|
931
|
+
return { channel: channel.label, w: channel.w, seq, at, verified: false, from_key: null, known_contact: false, sender: "unsigned", sha256: null, replay: false, body: { text: message.body ?? null }, signed: false, encrypted: false, format: "unreadable", error: error.name || "Error", unverified_because: why };
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* A scope in an incoming message, taken out before anyone reads it. Node
|
|
937
|
+
* keeps no scopes yet, so a share is never kept here, and the key is never
|
|
938
|
+
* handed on: aamio_scope is replaced, wherever it sits and whatever it holds,
|
|
939
|
+
* by what aamio-python shows in its place, with why it was not kept.
|
|
940
|
+
*/
|
|
941
|
+
takeScopeShare(entry) {
|
|
942
|
+
const body = entry.body;
|
|
943
|
+
if (!isObject(body)) return;
|
|
944
|
+
if ("aamio_scope" in body) body.aamio_scope = { kept: false, note: "not kept: a scope is shared in data.aamio_scope" };
|
|
945
|
+
const data = body.data;
|
|
946
|
+
if (!isObject(data) || !("aamio_scope" in data)) return;
|
|
947
|
+
const share = data.aamio_scope;
|
|
948
|
+
if (!isObject(share)) {
|
|
949
|
+
data.aamio_scope = { kept: false, note: "not kept: data.aamio_scope is an object with name, and key or address" };
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
data.aamio_scope = {
|
|
953
|
+
shared_as: typeof share.name === "string" ? share.name : null,
|
|
954
|
+
can_read: share.key !== undefined && share.key !== null,
|
|
955
|
+
kept: false,
|
|
956
|
+
note: "not kept: this Node runtime keeps no scopes yet. Ask the partner to share it again to a home run by aamio-python or aamio-php, which keep it.",
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** A verified message that names an address binds its signer's key to it, once. A different key later is a conflict, and the binding stays. */
|
|
961
|
+
bindClaimedAddress(channel, entry, field, address, senderKey) {
|
|
962
|
+
if (typeof address !== "string" || !ADDRESS.test(address)) return;
|
|
963
|
+
const bound = this.state.peers[address];
|
|
964
|
+
if (bound === undefined) {
|
|
965
|
+
this.state.peers[address] = senderKey;
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
if (bound === senderKey) return;
|
|
969
|
+
(entry.binding_conflicts ??= []).push({ field, address, claimed_by: senderKey, bound_to: bound });
|
|
970
|
+
this.note(channel.label, "binding_conflict", `message ${entry.seq} names ${address} as ${field}, signed by ${this.nameForKey(senderKey) ?? senderKey}, and that address is already bound to ${this.nameForKey(bound) ?? bound}. The binding is kept.`, channel.w);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* One channel read. The cursor and the hashes of what is handed over are saved
|
|
975
|
+
* before the caller sees a message: a crash after that redelivers nothing, and
|
|
976
|
+
* one before it redelivers what the stored hashes then mark as replays.
|
|
977
|
+
*/
|
|
978
|
+
async poll(channel, wait = 0, limit = null) {
|
|
979
|
+
if (channel.muted) return [];
|
|
980
|
+
const before = { after: channel.after, created_at: channel.created_at, gone: channel.gone, seen: [...channel.seen], peers: { ...this.state.peers } };
|
|
981
|
+
try {
|
|
982
|
+
return await this.pollOnce(channel, wait, limit, true);
|
|
983
|
+
} catch (error) {
|
|
984
|
+
if (!error?.notSaved) throw error;
|
|
985
|
+
// What this read changed is undone in memory too: the cursor, the hashes
|
|
986
|
+
// and the bindings stay as the file has them, and the messages wait at
|
|
987
|
+
// the service for the next read, which hands them over.
|
|
988
|
+
Object.assign(channel, { after: before.after, created_at: before.created_at, gone: before.gone, seen: before.seen });
|
|
989
|
+
this.state.peers = before.peers;
|
|
990
|
+
this.note(channel.label, "not_saved", `what this read found here could not be written down (${error.cause?.code || error.cause?.message}), so nothing from this channel was handed over. It is still there for the next read, and nothing was skipped.`, channel.w);
|
|
991
|
+
return [];
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
async saveRead() {
|
|
996
|
+
try {
|
|
997
|
+
await this.saveState();
|
|
998
|
+
} catch (cause) {
|
|
999
|
+
throw Object.assign(new Error("the read could not be saved"), { notSaved: true, cause });
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
async pollOnce(channel, wait, limit, again) {
|
|
1004
|
+
let path = "/" + channel.w;
|
|
1005
|
+
if (channel.after > 0 || wait > 0) path += "/after/" + channel.after;
|
|
1006
|
+
if (wait > 0) path += "/wait/" + Math.min(25, wait);
|
|
1007
|
+
const headers = { "X-Read": channel.read_key };
|
|
1008
|
+
if (limit !== null) headers["X-Limit"] = String(limit);
|
|
1009
|
+
let data;
|
|
1010
|
+
try {
|
|
1011
|
+
data = await this.client.request("GET", path, { headers, expect: [200] });
|
|
1012
|
+
} catch (error) {
|
|
1013
|
+
if (error instanceof AamioError && error.status === 410) {
|
|
1014
|
+
this.note(channel.label, "expired", "the thread at this address has expired, so nothing more will arrive here", channel.w);
|
|
1015
|
+
return [];
|
|
1016
|
+
}
|
|
1017
|
+
this.note(channel.label, "unread", `the service answered ${error.status ?? 0}, so this channel was not read and there may be messages waiting`, channel.w);
|
|
1018
|
+
return [];
|
|
1019
|
+
}
|
|
1020
|
+
if (!isObject(data) || !Array.isArray(data.messages)) {
|
|
1021
|
+
this.note(channel.label, "unread", "the service returned a malformed read answer; this channel was not read", channel.w);
|
|
1022
|
+
return [];
|
|
1023
|
+
}
|
|
1024
|
+
if (data.exists === false) {
|
|
1025
|
+
if (!channel.gone) this.note(channel.label, "gone", "there is no thread at this address any more: it expired and was swept, or the service restarted and it went with it" + (channel.label === "inbox" ? ", so a new inbox is opened for the partners" : ""), channel.w);
|
|
1026
|
+
channel.gone = true;
|
|
1027
|
+
// The cursor belonged to that thread. The hashes are the reader's and stay.
|
|
1028
|
+
channel.after = 0;
|
|
1029
|
+
channel.created_at = null;
|
|
1030
|
+
await this.saveRead();
|
|
1031
|
+
return [];
|
|
1032
|
+
}
|
|
1033
|
+
channel.gone = false;
|
|
1034
|
+
const created = data.created_at ?? null;
|
|
1035
|
+
if (again && channel.created_at !== null && created !== null && created !== channel.created_at && !data.reset) {
|
|
1036
|
+
this.note(channel.label, "restarted", `the thread at this address is a new one, opened at ${created} where this channel knew one opened at ${channel.created_at}, so it is read again from the start`, channel.w);
|
|
1037
|
+
channel.after = 0;
|
|
1038
|
+
channel.created_at = null;
|
|
1039
|
+
return this.pollOnce(channel, 0, limit, false);
|
|
1040
|
+
}
|
|
1041
|
+
if (data.reset) this.note(channel.label, "restarted", (isObject(data.reset) && data.reset.what) || "the service read this thread from the start", channel.w);
|
|
1042
|
+
channel.created_at = created;
|
|
1043
|
+
|
|
1044
|
+
const entries = [];
|
|
1045
|
+
const keptOut = [];
|
|
1046
|
+
let lastSeq = null;
|
|
1047
|
+
for (const raw of data.messages) {
|
|
1048
|
+
if (limit !== null && entries.length >= limit) break;
|
|
1049
|
+
const entry = this.readEntry(channel, raw);
|
|
1050
|
+
lastSeq = lastSeq === null ? entry.seq : Math.max(lastSeq, entry.seq);
|
|
1051
|
+
const allow = channel.allow || [];
|
|
1052
|
+
const excluded = allow.length > 0 && !(entry.verified && (allow.includes("*") || allow.includes(entry.from_key)));
|
|
1053
|
+
if (excluded) {
|
|
1054
|
+
keptOut.push(entry.seq);
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
if (entry.sha256 !== null && !channel.seen.includes(entry.sha256)) channel.seen.push(entry.sha256);
|
|
1058
|
+
this.takeScopeShare(entry);
|
|
1059
|
+
if (isObject(entry.body) && entry.verified && entry.from_key) {
|
|
1060
|
+
for (const field of ["reply_to", "channel"]) this.bindClaimedAddress(channel, entry, field, entry.body[field], entry.from_key);
|
|
1061
|
+
}
|
|
1062
|
+
entries.push(entry);
|
|
1063
|
+
}
|
|
1064
|
+
if (keptOut.length) {
|
|
1065
|
+
const openedFor = (channel.allow || []).includes("*") ? "any key, signed only" : `${channel.allow.length} named key(s)`;
|
|
1066
|
+
this.note(channel.label, "kept_out", `${keptOut.length} message(s) (seq ${keptOut.join(", ")}): this channel was opened for ${openedFor}, and these did not satisfy that list as checked here. They are not handed over.`, channel.w);
|
|
1067
|
+
}
|
|
1068
|
+
const stoppedEarly = limit !== null && entries.length >= limit && data.messages.length > entries.length + keptOut.length;
|
|
1069
|
+
if (stoppedEarly && lastSeq !== null) channel.after = lastSeq;
|
|
1070
|
+
else if (Number.isInteger(data.next)) channel.after = data.next;
|
|
1071
|
+
else if (lastSeq !== null) channel.after = Math.max(channel.after, lastSeq);
|
|
1072
|
+
await this.saveRead();
|
|
1073
|
+
return entries;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** Messages on every channel still read, the inbox first. The inbox is opened and presence published as the read starts. */
|
|
1077
|
+
async read({ wait = 0, limit = 50 } = {}) {
|
|
1078
|
+
await this.ensureInbox();
|
|
1079
|
+
await this.publishPresence();
|
|
1080
|
+
const now = this.now();
|
|
1081
|
+
const channels = this.state.channels.filter((c) => isObject(c) && !c.muted && Number(c.expire_at) > now)
|
|
1082
|
+
.sort((a, b) => (a.label === "inbox" ? -1 : b.label === "inbox" ? 1 : 0));
|
|
1083
|
+
const messages = [];
|
|
1084
|
+
// Every channel without waiting first: a message on an older inbox that
|
|
1085
|
+
// is still read is handed over at once, not after a wait on the new one.
|
|
1086
|
+
for (const channel of channels) {
|
|
1087
|
+
if (messages.length >= limit) break;
|
|
1088
|
+
messages.push(...(await this.poll(channel, 0, limit - messages.length)));
|
|
1089
|
+
}
|
|
1090
|
+
const inbox = this.channel("inbox");
|
|
1091
|
+
if (!messages.length && wait > 0 && inbox && !inbox.muted) {
|
|
1092
|
+
messages.push(...(await this.poll(inbox, wait, limit)));
|
|
1093
|
+
// What reached the other channels during the wait comes with this
|
|
1094
|
+
// answer, as in aamio-python: only the inbox can end the wait early. It
|
|
1095
|
+
// used to wait for the next read (a review of 9 November 2026).
|
|
1096
|
+
for (const channel of channels) {
|
|
1097
|
+
if (channel === inbox || messages.length >= limit) continue;
|
|
1098
|
+
messages.push(...(await this.poll(channel, 0, limit - messages.length)));
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
return messages;
|
|
1102
|
+
}
|
|
1103
|
+
}
|