aamio 0.6.3 → 0.7.1
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 +267 -10
- package/bin/aamio.js +410 -39
- package/package.json +7 -1
- package/src/aamio.js +12 -3
- package/src/node-addon-process.js +495 -0
- package/src/node-addons.js +790 -0
- package/src/node-archive.js +349 -0
- package/src/node-doctor.js +314 -0
- package/src/node-lock.js +11 -2
- package/src/node-mcp.js +244 -36
- package/src/node-runtime.js +1507 -73
- package/src/node-scopes.js +208 -0
- package/src/node-trace.js +170 -0
package/src/node-runtime.js
CHANGED
|
@@ -2,9 +2,12 @@ import { open, readFile, rename, unlink } from "node:fs/promises";
|
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { Aamio, AamioError, BOARD_TTL, DEFAULT_BASE, DEFAULT_BOARD, GateStop, VERIFYUM_MCP, checkMessage, gatePlan, isEnvelope, isKey, keyHash, sha256hex, threadSigningInput } from "./aamio.js";
|
|
5
|
+
import { Aamio, AamioError, BOARD_POST_MARGIN, BOARD_TTL, DEFAULT_BASE, DEFAULT_BOARD, GateStop, VERIFYUM_MCP, checkMessage, gatePlan, isEnvelope, isKey, keyHash, newScopeKey, scopeAddress, sha256hex, threadSigningInput } from "./aamio.js";
|
|
6
|
+
import { DEFAULT_POLICY, appendRecord, archiveName, archiveNames, archiveStatus, archivedRecords, isSafeLabel, pruneArchive, readPolicy } from "./node-archive.js";
|
|
6
7
|
import { readKeys } from "./node-identity.js";
|
|
7
8
|
import { takeHome } from "./node-lock.js";
|
|
9
|
+
import { SCOPE_NOT_SAVED, checkAccess, checkScopeName, longerForm, removedShares, scopeAddPlan, scopeCalled, scopeRemovePlan, scopeView, sharedScopeName, takenFrom, usableScopes, withoutScopeKey } from "./node-scopes.js";
|
|
10
|
+
import { TRACE_KEEP, TRACE_KEYS, TRACE_READ_FIELDS, TRACE_RECEIVED_FIELDS, TRACE_SENT_FIELDS, isMessageHash, normalizeTraces, traceRow, traceView } from "./node-trace.js";
|
|
8
11
|
|
|
9
12
|
/*
|
|
10
13
|
* The local runtime for Node: a home held for as long as it runs, an inbox the
|
|
@@ -12,10 +15,11 @@ import { takeHome } from "./node-lock.js";
|
|
|
12
15
|
* outbox before they go, and reads checked here before anything is handed on.
|
|
13
16
|
*
|
|
14
17
|
* A home is shared with aamio-python and aamio-php, so the files are theirs:
|
|
15
|
-
* partners.json, state.json and
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
18
|
+
* partners.json, scopes.json, state.json, outbox.json and trace.json in the
|
|
19
|
+
* same shape, written the same way, and archive/ with what was read, sent
|
|
20
|
+
* and posted, under the policy in config.json. A home one of them made opens
|
|
21
|
+
* here, and one this makes opens there. What Python keeps that this does not
|
|
22
|
+
* touch yet, effects, is left as it is.
|
|
19
23
|
*/
|
|
20
24
|
|
|
21
25
|
export const INBOX_TTL = 3600;
|
|
@@ -30,7 +34,7 @@ export const ANSWER_MARGIN = 600;
|
|
|
30
34
|
// Files this runtime writes back. One that is there and cannot be read stops
|
|
31
35
|
// the runtime before anything is saved over it: a save is how a broken file
|
|
32
36
|
// becomes a lost one.
|
|
33
|
-
const KEPT_FILES = ["partners.json", "state.json", "outbox.json"];
|
|
37
|
+
const KEPT_FILES = ["partners.json", "scopes.json", "state.json", "outbox.json"];
|
|
34
38
|
// The largest X-Max-Bytes the service takes: one to eight digits.
|
|
35
39
|
const MAX_BYTES_HEADER = 99_999_999;
|
|
36
40
|
// How long after a thread expires what this process read of it is kept for its
|
|
@@ -39,8 +43,18 @@ const MAX_BYTES_HEADER = 99_999_999;
|
|
|
39
43
|
const RECEIPT_KEEP = 120;
|
|
40
44
|
// Answers that prove a message was not stored: refusals the service will give
|
|
41
45
|
// again, and a rate window, which it decides before it reads the body.
|
|
42
|
-
|
|
46
|
+
// 401 and 409 are among them: a signature that does not verify over these
|
|
47
|
+
// bytes, a thread that is full, a key that has used up the gate's per_key. The
|
|
48
|
+
// service answers each before it stores anything. Left out, a handover
|
|
49
|
+
// answered so said the address may have arrived, and retry sent the same
|
|
50
|
+
// bytes to the same refusal (a check of 12 November 2026).
|
|
51
|
+
const SEND_DETERMINISTIC = [400, 401, 403, 409, 410, 413, 415, 422, 428, 501];
|
|
43
52
|
const SEND_NOT_STORED = [...SEND_DETERMINISTIC, 429];
|
|
53
|
+
// Nothing about the message is wrong, only the moment: a rate window, or a
|
|
54
|
+
// service that is busy. 503 is not among the answers that prove nothing was
|
|
55
|
+
// stored: it can be a front door that saw nothing or the service failing after
|
|
56
|
+
// it had written, and this side cannot tell which.
|
|
57
|
+
const SEND_TRY_LATER = [429, 503];
|
|
44
58
|
const HASH = /^[0-9a-f]{64}$/;
|
|
45
59
|
// One message a channel handed over, as the channel remembers it: the hash of
|
|
46
60
|
// its bytes and who signed them, the first 16 hex of sha256 of the key, or -
|
|
@@ -94,7 +108,166 @@ export function remember(channel, digest, fromKey) {
|
|
|
94
108
|
if (!channel.seen_from.includes(record)) channel.seen_from.push(record);
|
|
95
109
|
}
|
|
96
110
|
const ADDRESS = /^[a-z2-7]{20}$/;
|
|
97
|
-
|
|
111
|
+
// Counted in characters, as aamio-python and aamio-php count a name: without
|
|
112
|
+
// the u flag a name of 33 emoji was 66 units here and refused, and taken there
|
|
113
|
+
// (a check of 13 November 2026).
|
|
114
|
+
const NAME = /^[^\u0000-\u001f\u007f]{1,64}$/u;
|
|
115
|
+
|
|
116
|
+
// ------------------------------------------------------------- the outbox --
|
|
117
|
+
//
|
|
118
|
+
// What happened to a send, read from its entry by the rules aamio-python and
|
|
119
|
+
// aamio-php read it by, so an outbox one of them wrote says the same here.
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Which of five things happened to a send, for a caller deciding whether to
|
|
123
|
+
* replace it, or not_found. Only a stop before the first POST may be called
|
|
124
|
+
* unsent. An attempt that was answered 500 does not prove the message is
|
|
125
|
+
* absent, and one that was delivered is certainly not unsent.
|
|
126
|
+
*/
|
|
127
|
+
export function outboxOutcome(entry) {
|
|
128
|
+
if (!isObject(entry)) return "not_found";
|
|
129
|
+
const status = entry.status;
|
|
130
|
+
// Read before the statuses: a retry sets the status back to working while
|
|
131
|
+
// the first attempt is still with the other side.
|
|
132
|
+
if (entry.posting && status === "working") return "attempted";
|
|
133
|
+
if (status === "delivered") return "delivered";
|
|
134
|
+
if (status === "unknown") return "unknown";
|
|
135
|
+
if (status === "attempted") return "attempted";
|
|
136
|
+
if (status === "refused") {
|
|
137
|
+
// An earlier attempt that was left open is not undone by a later refusal.
|
|
138
|
+
// An entry from before ever_open is read by its last answer.
|
|
139
|
+
if (entry.ever_open) return "attempted";
|
|
140
|
+
return SEND_NOT_STORED.includes(entry.last_status) ? "refused" : "attempted";
|
|
141
|
+
}
|
|
142
|
+
// posting is set in the moment before the post, by the runtimes that set
|
|
143
|
+
// it: the one mark that means bytes were on their way.
|
|
144
|
+
if (entry.posting) return "attempted";
|
|
145
|
+
if (status === "working" || status === "stopped") return "never_sent";
|
|
146
|
+
return Number(entry.attempts) > 0 ? "attempted" : "never_sent";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Whether this message still has something to decide: it left, and what came back settles nothing. */
|
|
150
|
+
export function outboxOpen(entry) {
|
|
151
|
+
return ["attempted", "unknown"].includes(outboxOutcome(entry));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Whether sending these same bytes again is the right next move. Not the same
|
|
156
|
+
* question as open: a rate window is settled, nothing was stored, and it is
|
|
157
|
+
* also the one refusal whose advice is to wait and send it again. A refusal
|
|
158
|
+
* the service will repeat is not here, and a delivery is not here.
|
|
159
|
+
*/
|
|
160
|
+
export function outboxRetryable(entry) {
|
|
161
|
+
if (!isObject(entry)) return false;
|
|
162
|
+
if (outboxOpen(entry)) return true;
|
|
163
|
+
return outboxOutcome(entry) === "refused" && SEND_TRY_LATER.includes(entry.last_status);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const OUTBOX_NOTES = {
|
|
167
|
+
never_sent: "nothing had left this machine for it, and nothing will now",
|
|
168
|
+
attempted: "a send was attempted and its answer does not prove the message is absent, so its outcome stays unknown and nothing here can recall it",
|
|
169
|
+
delivered: "the service had already stored it when this was dropped; dropping the entry does not unsend it",
|
|
170
|
+
refused: "the service answered that it refused this one, so it was not stored, but the attempt did leave this machine",
|
|
171
|
+
unknown: "a send was already away when this was dropped, so its outcome stays unknown and nothing here can recall it",
|
|
172
|
+
not_found: "this outbox has no message with that id, so nothing was dropped and nothing was sent",
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* What to do about a send that did not end in a stored message: whether the
|
|
177
|
+
* same stored bytes are worth sending again, and the fix in words. true: the
|
|
178
|
+
* message is fine and the moment was not. false: the same message will fail
|
|
179
|
+
* the same way. null: it cannot be decided from what is known here. Never a
|
|
180
|
+
* permission to repeat something by itself. The words are aamio-python's.
|
|
181
|
+
*/
|
|
182
|
+
export function sendAdvice(outcome, status) {
|
|
183
|
+
if (outcome === "never_sent") {
|
|
184
|
+
return [false, "Nothing left this machine for this message, so it was not delivered and will not be: the error says what stopped it. Put that right, then send it as a new message. There is nothing to retry."];
|
|
185
|
+
}
|
|
186
|
+
if (outcome === "unknown") {
|
|
187
|
+
return [null, "No answer came back, so this message may already have been delivered. Keep its message_id. aamio_pending lists what has no settled outcome on this machine; it does not confirm delivery, and nothing here can, because the address it went to is not yours to read. Do not pass the message_id to aamio_send and do not compose a replacement. An approved retry sends the stored bytes again: aamio_outbox_retry with this id, or aamio outbox retry --id on the command line."];
|
|
188
|
+
}
|
|
189
|
+
if (SEND_TRY_LATER.includes(status)) {
|
|
190
|
+
return [true, `aamio declined this for now, not because of the message: ${status} is a rate window or a busy service. Do not change the content. Wait, then send the stored message again through the runtime's outbox retry rather than composing a new one.`];
|
|
191
|
+
}
|
|
192
|
+
if (SEND_DETERMINISTIC.includes(status)) {
|
|
193
|
+
return [false, "aamio refused this and will refuse the same bytes again. Read the error, correct the request, and send the corrected one as a new message."];
|
|
194
|
+
}
|
|
195
|
+
return [null, `aamio answered ${status}, which this client does not classify. It may or may not have stored the message before failing, so treat delivery as unsettled: keep the message_id, read the error, and do not resend blindly.`];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Said with a stop that came while a message was being sent again. The stop
|
|
199
|
+
// is that attempt's: the one before it is still with the other side, and the
|
|
200
|
+
// words of a message that never left, "not sent, send it again", read as leave
|
|
201
|
+
// to write it anew (a check of 12 November 2026).
|
|
202
|
+
const EARLIER_ATTEMPT = "An earlier attempt of it left this machine and may have arrived, so its outcome is still unknown.";
|
|
203
|
+
const RETRY_SAME_ID = "retry the same message: aamio_outbox_retry with this id, or aamio outbox retry --id on the command line. That sends the stored bytes again, which a recipient that keeps what it has seen marks a replay. Do not compose a replacement, and do not send the text as a new message.";
|
|
204
|
+
|
|
205
|
+
/** The codes of a send that did not end in a stored message. Each is said with its message id and its outcome. */
|
|
206
|
+
export const SEND_STOPS = ["send_failed", "not_sent", "retry_not_sent"];
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* What such a stop says of itself beside its words, for the command line and
|
|
210
|
+
* the local server alike. The two wrote these fields out separately, and the
|
|
211
|
+
* trace that could not be written was in neither: a command that sends once
|
|
212
|
+
* and exits never saw it (a check of 12 November 2026).
|
|
213
|
+
*/
|
|
214
|
+
export function stopFields(error) {
|
|
215
|
+
const said = {};
|
|
216
|
+
for (const field of ["outcome", "message_id", "status"]) if (error?.[field] !== undefined) said[field] = error[field];
|
|
217
|
+
for (const field of ["outbox_error", "trace_error", "archive_error"]) if (error?.[field]) said[field] = error[field];
|
|
218
|
+
// Whether the same stored bytes are worth sending again: true, false, or null when it cannot be told.
|
|
219
|
+
if (error?.retryable !== undefined) said.retryable = error.retryable;
|
|
220
|
+
if (error?.earlier_attempt) said.earlier_attempt = error.earlier_attempt;
|
|
221
|
+
// A channel that is open although the message carrying its address was stopped, or left and has no settled outcome.
|
|
222
|
+
if (isObject(error?.opened)) said.opened = error.opened;
|
|
223
|
+
return said;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// How long a channel lives: what the service takes for a thread of its own.
|
|
227
|
+
export const CHANNEL_TTL = 600;
|
|
228
|
+
const CHANNEL_TTL_MIN = 30;
|
|
229
|
+
const CHANNEL_TTL_MAX = 3600;
|
|
230
|
+
|
|
231
|
+
const GATE_SHAPE = 'A gate is an object with "require", "advise" or both, as in {"require": {"pow": {"bits": 20}, "per_key": 5}}. require refuses a write that does not meet it; advise lets the write in and reports on it.';
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The shape of a gate, refused here rather than a round trip later. Only the
|
|
235
|
+
* shape: what the numbers may be is the service's to decide, and its refusal
|
|
236
|
+
* carries the detail. Worth catching before the call because a gate is set
|
|
237
|
+
* once, when the channel is opened, and never changes: one that went nowhere
|
|
238
|
+
* leaves a channel open for its whole life without the conditions its owner
|
|
239
|
+
* meant it to have. Throws invalid_gate.
|
|
240
|
+
*/
|
|
241
|
+
export function checkGate(gate) {
|
|
242
|
+
if (!isObject(gate)) throw runtimeError("invalid_gate", `a gate must be an object, not ${Array.isArray(gate) ? "a list" : gate === null ? "null" : typeof gate}. ${GATE_SHAPE}`);
|
|
243
|
+
const unknown = Object.keys(gate).filter((key) => key !== "require" && key !== "advise").sort();
|
|
244
|
+
if (unknown.length) throw runtimeError("invalid_gate", `a gate has no field ${unknown.join(", ")}. ${GATE_SHAPE}`);
|
|
245
|
+
if (!Object.keys(gate).length) throw runtimeError("invalid_gate", `an empty gate asks for nothing; leave the gate out instead. ${GATE_SHAPE}`);
|
|
246
|
+
for (const key of ["require", "advise"]) {
|
|
247
|
+
if (key in gate && !isObject(gate[key])) throw runtimeError("invalid_gate", `gate.${key} must be an object. ${GATE_SHAPE}`);
|
|
248
|
+
}
|
|
249
|
+
return gate;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* A label for a channel of the caller's own. It names the channel in every
|
|
254
|
+
* answer and its file in the archive, so it is made of what a file's name may
|
|
255
|
+
* hold and nothing else: a label with a path in it put what was read on the
|
|
256
|
+
* channel outside the home. The labels that start with inbox or board are
|
|
257
|
+
* the runtime's own channels. Throws invalid_label.
|
|
258
|
+
*/
|
|
259
|
+
export function checkLabel(label) {
|
|
260
|
+
if (!isSafeLabel(label)) {
|
|
261
|
+
throw runtimeError("invalid_label", "a channel's label is 1 to 64 letters, digits, dots, dashes and underscores, starting with a letter or a digit, and not a name the system keeps for a device: it names the channel's file in the archive");
|
|
262
|
+
}
|
|
263
|
+
if (/^(?:inbox|board)/i.test(label)) throw runtimeError("invalid_label", `the label ${label} is the runtime's own: a label that starts with inbox or board names an inbox or a board inbox. Choose another`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** How long a channel is to live, as a whole number of seconds the service takes. Throws invalid_ttl. */
|
|
267
|
+
export function checkChannelTtl(ttl) {
|
|
268
|
+
if (!Number.isInteger(ttl) || ttl < CHANNEL_TTL_MIN || ttl > CHANNEL_TTL_MAX) throw runtimeError("invalid_ttl", `a channel lives a whole number of seconds from ${CHANNEL_TTL_MIN} to ${CHANNEL_TTL_MAX}`);
|
|
269
|
+
return ttl;
|
|
270
|
+
}
|
|
98
271
|
|
|
99
272
|
/** A partner's name and key as partnerAdd takes them, checked where nothing has been opened yet. Throws invalid_name or invalid_key. */
|
|
100
273
|
export function checkPartner(name, key) {
|
|
@@ -166,13 +339,24 @@ function messageShape(body, envelope) {
|
|
|
166
339
|
return shape;
|
|
167
340
|
}
|
|
168
341
|
|
|
342
|
+
/** Presence tags from a comma-separated list, as aamio-python reads --tags and AAMIO_TAGS. */
|
|
343
|
+
export function tagsFrom(text) {
|
|
344
|
+
return String(text ?? "").split(",").filter(Boolean);
|
|
345
|
+
}
|
|
346
|
+
|
|
169
347
|
export class NodeRuntime {
|
|
170
348
|
/** Takes the home, reads its key and its files. Close it to let go. */
|
|
171
|
-
static async open({ home, host = process.env.AAMIO_HOST || DEFAULT_BASE, board = process.env.AAMIO_BOARD || DEFAULT_BOARD, verifyum = process.env.AAMIO_VERIFYUM || VERIFYUM_MCP, fetch = globalThis.fetch, clock = () => Date.now() / 1000 } = {}) {
|
|
349
|
+
static async open({ home, host = process.env.AAMIO_HOST || DEFAULT_BASE, board = process.env.AAMIO_BOARD || DEFAULT_BOARD, verifyum = process.env.AAMIO_VERIFYUM || VERIFYUM_MCP, fetch = globalThis.fetch, clock = () => Date.now() / 1000, archive = null, tags } = {}) {
|
|
172
350
|
const owner = await takeHome(home, { host });
|
|
173
351
|
try {
|
|
174
|
-
const runtime = new NodeRuntime({ home, host, board, verifyum, keys: await readKeys(home), owner, fetch, clock });
|
|
352
|
+
const runtime = new NodeRuntime({ home, host, board, verifyum, keys: await readKeys(home), owner, fetch, clock, archive });
|
|
175
353
|
await runtime.load();
|
|
354
|
+
// The tags presence publishes, taken as aamio-python and aamio-php take
|
|
355
|
+
// them: the ones given now, else the home's own, else AAMIO_TAGS. They
|
|
356
|
+
// are the home's from the next save on. This runtime had no way to set
|
|
357
|
+
// them (a check of 13 November 2026).
|
|
358
|
+
if (Array.isArray(tags)) runtime.state.tags = tags;
|
|
359
|
+
else if (!runtime.state.tags.length) runtime.state.tags = tagsFrom(process.env.AAMIO_TAGS || "");
|
|
176
360
|
return runtime;
|
|
177
361
|
} catch (error) {
|
|
178
362
|
await owner.release();
|
|
@@ -180,8 +364,23 @@ export class NodeRuntime {
|
|
|
180
364
|
}
|
|
181
365
|
}
|
|
182
366
|
|
|
183
|
-
constructor({ home, host, board = DEFAULT_BOARD, verifyum = VERIFYUM_MCP, keys, owner, fetch, clock }) {
|
|
367
|
+
constructor({ home, host, board = DEFAULT_BOARD, verifyum = VERIFYUM_MCP, keys, owner, fetch, clock, archive = null }) {
|
|
184
368
|
this.home = home;
|
|
369
|
+
// The archive is the home's own choice, kept in config.json: keep, off or
|
|
370
|
+
// so many days. archive false turns it off for this process, which is
|
|
371
|
+
// what --no-archive does, and true turns it on whatever the home says.
|
|
372
|
+
this.archiveAsked = archive;
|
|
373
|
+
this.archivePolicy = { ...DEFAULT_POLICY };
|
|
374
|
+
this.archiveEnabled = false;
|
|
375
|
+
this.prunedAt = 0;
|
|
376
|
+
// What this process read on each channel, by write address, as it was
|
|
377
|
+
// handed over, with the thread's instance and the archive's mark: what
|
|
378
|
+
// board replies lists beside the archive. In memory only, and gone with
|
|
379
|
+
// the channel.
|
|
380
|
+
this.received = new Map();
|
|
381
|
+
// Messages the archive holds from a read whose cursor could not be
|
|
382
|
+
// written: read again, they are not written down a second time.
|
|
383
|
+
this.writtenDown = new Set();
|
|
185
384
|
this.host = host.replace(/\/+$/, "");
|
|
186
385
|
// Where a receipt's root is anchored, as aamio-python reads it: AAMIO_VERIFYUM.
|
|
187
386
|
this.verifyum = String(verifyum).replace(/\/+$/, "");
|
|
@@ -203,6 +402,13 @@ export class NodeRuntime {
|
|
|
203
402
|
this.untold = new Set();
|
|
204
403
|
// Files whose folder could not be synced after the rename, said once until it can.
|
|
205
404
|
this.unsynced = new Set();
|
|
405
|
+
// What was sent to each counterpart and what came back, as hashes and
|
|
406
|
+
// shapes: trace.json, a book per public key.
|
|
407
|
+
this.traces = {};
|
|
408
|
+
// The scopes this home holds, and the entries of scopes.json that are not
|
|
409
|
+
// scopes this runtime can use, which go back into the file as they were.
|
|
410
|
+
this.scopes = [];
|
|
411
|
+
this.scopesAside = [];
|
|
206
412
|
}
|
|
207
413
|
|
|
208
414
|
/**
|
|
@@ -234,6 +440,8 @@ export class NodeRuntime {
|
|
|
234
440
|
try {
|
|
235
441
|
// Fatal: a byte that is not UTF-8 makes the file unreadable. Read with
|
|
236
442
|
// replacement characters, a name in it would be saved back changed.
|
|
443
|
+
// The decoder drops one byte order mark at the start, which is how a
|
|
444
|
+
// file of the home is read by all three runtimes.
|
|
237
445
|
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
238
446
|
} catch {
|
|
239
447
|
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.`);
|
|
@@ -251,6 +459,10 @@ export class NodeRuntime {
|
|
|
251
459
|
|
|
252
460
|
async load() {
|
|
253
461
|
this.partnersFile = await this.loadJson("partners.json", [], Array.isArray);
|
|
462
|
+
const scopes = usableScopes(await this.loadJson("scopes.json", [], Array.isArray));
|
|
463
|
+
this.scopes = scopes.usable;
|
|
464
|
+
this.scopesAside = scopes.aside;
|
|
465
|
+
if (scopes.unusable) this.note("scopes", "scopes_unusable", `scopes.json: ${scopes.unusable} entries are not scopes this runtime can use, and stay in the file as they are`);
|
|
254
466
|
this.state = await this.loadJson("state.json", {}, isObject);
|
|
255
467
|
this.outbox = await this.loadJson("outbox.json", {}, isObject);
|
|
256
468
|
if (!Array.isArray(this.state.tags)) this.state.tags = [];
|
|
@@ -263,9 +475,152 @@ export class NodeRuntime {
|
|
|
263
475
|
channel.allow = Array.isArray(channel.allow) ? channel.allow : [];
|
|
264
476
|
if (!Number.isInteger(channel.after)) channel.after = 0;
|
|
265
477
|
}
|
|
478
|
+
this.traces = normalizeTraces(await this.loadTrace(), TRACE_KEEP, TRACE_KEYS);
|
|
479
|
+
this.dropExpired();
|
|
480
|
+
this.archivePolicy = await readPolicy(this.home);
|
|
481
|
+
this.archiveEnabled = this.archiveAsked === null || this.archiveAsked === undefined ? this.archivePolicy.mode !== "off" : Boolean(this.archiveAsked);
|
|
482
|
+
await this.prune();
|
|
266
483
|
await this.settleInterrupted();
|
|
267
484
|
}
|
|
268
485
|
|
|
486
|
+
/**
|
|
487
|
+
* Channels past their time leave the home. The thread is gone at the
|
|
488
|
+
* service with everything on it, and what was read of it is kept for its
|
|
489
|
+
* receipt for as long as the receipt can be taken. The inbox and the board
|
|
490
|
+
* inbox stay until a new one takes their place, so a command can say that
|
|
491
|
+
* the address closed, and when. Left in state.json, every inbox this home
|
|
492
|
+
* ever had was written again at each save. In memory: the file follows at
|
|
493
|
+
* the next save.
|
|
494
|
+
*/
|
|
495
|
+
dropExpired() {
|
|
496
|
+
const now = this.now();
|
|
497
|
+
const gone = (channel) => isObject(channel) && channel.label !== "inbox" && channel.label !== "board" && Number(channel.expire_at) + RECEIPT_KEEP <= now;
|
|
498
|
+
for (const channel of this.state.channels.filter(gone)) {
|
|
499
|
+
this.received.delete(channel.w);
|
|
500
|
+
// And what was written down of it at a read that could not be saved: it will not be read again.
|
|
501
|
+
for (const delivery of this.writtenDown) if (delivery.startsWith(channel.w + "\u0000")) this.writtenDown.delete(delivery);
|
|
502
|
+
}
|
|
503
|
+
this.state.channels = this.state.channels.filter((channel) => !gone(channel));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ----------------------------------------------------------- archive --
|
|
507
|
+
//
|
|
508
|
+
// What was read, sent and posted, decrypted, in archive/: one file per
|
|
509
|
+
// channel, one record per line, as aamio-python and aamio-php keep it. It
|
|
510
|
+
// is a record of what happened and never the thing itself: a write that
|
|
511
|
+
// fails costs the record, and is said, and the message, the send or the
|
|
512
|
+
// post it is about stands.
|
|
513
|
+
|
|
514
|
+
/** Removes what the policy no longer keeps. A failure is said, since the archive then keeps growing in plain text. */
|
|
515
|
+
async prune() {
|
|
516
|
+
const policy = this.archivePolicy;
|
|
517
|
+
if (!this.archiveEnabled || !(policy.days || policy.max_mb)) return;
|
|
518
|
+
this.prunedAt = this.now();
|
|
519
|
+
try {
|
|
520
|
+
await pruneArchive(this.home, policy, { now: this.now() });
|
|
521
|
+
} catch (error) {
|
|
522
|
+
this.note("archive", "unpruned", `the archive was not pruned: ${error.code || error.name}: ${error.message}. It keeps growing, and what is in it stays readable to whoever can read this folder.`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Writes one record, and says whether it wrote one. A home that keeps
|
|
528
|
+
* nothing is not a failure, and it is not a write either. Throws when the
|
|
529
|
+
* write fails: the caller knows what the record was about.
|
|
530
|
+
*/
|
|
531
|
+
async archive(label, record) {
|
|
532
|
+
if (!this.archiveEnabled || this.closed) return false;
|
|
533
|
+
await appendRecord(this.home, archiveName(label), record);
|
|
534
|
+
if (this.now() - this.prunedAt > 3600) await this.prune();
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** The same for a record of something that is done already: what went wrong is given back, said in attention, and never thrown. */
|
|
539
|
+
async archiveSafely(label, record, what) {
|
|
540
|
+
try {
|
|
541
|
+
await this.archive(label, record);
|
|
542
|
+
return null;
|
|
543
|
+
} catch (error) {
|
|
544
|
+
const how = `${error.code || error.name}: ${error.message}`;
|
|
545
|
+
this.note("archive", "archive_failed", `${what} could not be written to the archive: ${how}. The archive does not have it.`);
|
|
546
|
+
return how;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** One message of one thread, as this process knows a row it wrote down: the address, the thread's instance where the service names one, the seq and the hash. */
|
|
551
|
+
deliveryOf(channel, entry) {
|
|
552
|
+
return entry.sha256 === null || entry.sha256 === undefined ? null : [channel.w, typeof channel.instance === "string" ? channel.instance : "-", entry.seq, entry.sha256].join("\u0000");
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* What the archive holds now and what its policy means. Off stops the
|
|
557
|
+
* writing and removes nothing: what was kept before is still there, in the
|
|
558
|
+
* clear, and the words for off say nothing of it, so this does.
|
|
559
|
+
*/
|
|
560
|
+
async archiveStatus() {
|
|
561
|
+
const status = await archiveStatus(this.home, this.archivePolicy);
|
|
562
|
+
if (this.archivePolicy.mode === "off" && status.files > 0) {
|
|
563
|
+
status.note = `Nothing more is written. What was kept before the archive was turned off is still in archive/: ${status.files} file(s), ${status.bytes} bytes. aamio archive prune --all removes it.`;
|
|
564
|
+
}
|
|
565
|
+
return status;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Removes what the policy no longer keeps, or with everything the whole archive, and says how much went. */
|
|
569
|
+
async archivePrune({ everything = false } = {}) {
|
|
570
|
+
const gone = await pruneArchive(this.home, this.archivePolicy, { now: this.now(), everything });
|
|
571
|
+
return { ...(await this.archiveStatus()), removed: gone.removed };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* The home's own choice, written where the next process finds it, in any of
|
|
576
|
+
* the three runtimes. What config.json holds beside it stays. The choice
|
|
577
|
+
* counts once the file holds it.
|
|
578
|
+
*/
|
|
579
|
+
async archiveSet(policy) {
|
|
580
|
+
let config = {};
|
|
581
|
+
try {
|
|
582
|
+
config = await this.loadJson("config.json", {}, isObject);
|
|
583
|
+
} catch {
|
|
584
|
+
// A config.json that cannot be read holds no choice anyone can use: it is replaced by this one.
|
|
585
|
+
config = {};
|
|
586
|
+
}
|
|
587
|
+
try {
|
|
588
|
+
await this.saveJson("config.json", { ...config, archive: policy });
|
|
589
|
+
} catch (error) {
|
|
590
|
+
if (error?.code === "runtime_closed") throw error;
|
|
591
|
+
throw runtimeError("not_saved", `config.json could not be written (${error.code || error.message}), so the archive's policy is as it was. Free the disk, then set it again.`);
|
|
592
|
+
}
|
|
593
|
+
this.archivePolicy = policy;
|
|
594
|
+
this.archiveEnabled = policy.mode !== "off";
|
|
595
|
+
await this.prune();
|
|
596
|
+
return this.archiveStatus();
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* trace.json, or nothing when it is not there. The trace is diagnostics: a
|
|
601
|
+
* file that cannot be read never stops the runtime, as aamio-python reads
|
|
602
|
+
* it. It is said, since the next record written replaces the file.
|
|
603
|
+
*/
|
|
604
|
+
async loadTrace() {
|
|
605
|
+
let bytes;
|
|
606
|
+
try {
|
|
607
|
+
bytes = await readFile(join(this.home, "trace.json"));
|
|
608
|
+
} catch (error) {
|
|
609
|
+
if (error.code !== "ENOENT") this.untraced(`trace.json could not be read (${error.code || error.message}), so the trace starts empty and the next record replaces the file.`);
|
|
610
|
+
return {};
|
|
611
|
+
}
|
|
612
|
+
try {
|
|
613
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
614
|
+
if (text.trim() === "") return {};
|
|
615
|
+
const value = JSON.parse(text);
|
|
616
|
+
if (!isObject(value)) throw new Error("not a JSON object");
|
|
617
|
+
return value;
|
|
618
|
+
} catch (error) {
|
|
619
|
+
this.untraced(`trace.json is not what a trace holds (${error.message}), so the trace starts empty and the next record replaces the file.`);
|
|
620
|
+
return {};
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
269
624
|
/**
|
|
270
625
|
* What a process that stopped left in the outbox, settled by the rules
|
|
271
626
|
* aamio-python uses, so a shared home says the same in both. A send in
|
|
@@ -301,7 +656,13 @@ export class NodeRuntime {
|
|
|
301
656
|
: "the process stopped before its proof of work was done, so nothing was sent";
|
|
302
657
|
changed = true;
|
|
303
658
|
}
|
|
304
|
-
|
|
659
|
+
// An attempt left open before this process, by this version or an older
|
|
660
|
+
// one, stays open: a stop or a refusal from now on settles a later
|
|
661
|
+
// attempt, not that one. Asked of the outcome and not of the status: an
|
|
662
|
+
// entry an older client wrote as refused, after an answer that proved
|
|
663
|
+
// nothing, had no mark, and a 410 on its retry called a message that may
|
|
664
|
+
// have landed refused, and not stored (a check of 12 November 2026).
|
|
665
|
+
if (outboxOpen(entry) && entry.ever_open !== true) {
|
|
305
666
|
entry.ever_open = true;
|
|
306
667
|
changed = true;
|
|
307
668
|
}
|
|
@@ -351,7 +712,9 @@ export class NodeRuntime {
|
|
|
351
712
|
// message was never handed over at all (a review of 9 November 2026).
|
|
352
713
|
if (!this.unsynced.has(name)) {
|
|
353
714
|
this.unsynced.add(name);
|
|
354
|
-
|
|
715
|
+
// What an older file coming back would mean is said for the file it is
|
|
716
|
+
// true of: the cursor is in state.json, and no other file has one.
|
|
717
|
+
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${name === "state.json" ? ", and what was read since would then be read again" : ""}.`);
|
|
355
718
|
}
|
|
356
719
|
}
|
|
357
720
|
}
|
|
@@ -375,6 +738,20 @@ export class NodeRuntime {
|
|
|
375
738
|
return this.saveJson("outbox.json", this.outbox);
|
|
376
739
|
}
|
|
377
740
|
|
|
741
|
+
/**
|
|
742
|
+
* Written when it changes. A trace that cannot be written costs the trace
|
|
743
|
+
* and nothing else: the line that says so is given back, and null when the
|
|
744
|
+
* file was written.
|
|
745
|
+
*/
|
|
746
|
+
async saveTrace() {
|
|
747
|
+
try {
|
|
748
|
+
await this.saveJson("trace.json", this.traces);
|
|
749
|
+
return null;
|
|
750
|
+
} catch (error) {
|
|
751
|
+
return this.untraced(`trace.json: ${error.code || error.name || "Error"}: ${error.message}`);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
378
755
|
async close() {
|
|
379
756
|
if (this.closed) return;
|
|
380
757
|
this.closed = true;
|
|
@@ -387,6 +764,24 @@ export class NodeRuntime {
|
|
|
387
764
|
return this.partnersFile.filter((p) => isObject(p) && typeof p.name === "string" && typeof p.key === "string");
|
|
388
765
|
}
|
|
389
766
|
|
|
767
|
+
/**
|
|
768
|
+
* The keys that signed a message this process read and verified, by its
|
|
769
|
+
* hash. The same bytes may carry more than one. What this process checked
|
|
770
|
+
* lives in its memory and not in state.json, so a message read by an
|
|
771
|
+
* earlier process is not here. An observation the channel's allowlist kept
|
|
772
|
+
* out is: a signature that checked is not a permission. An add-on is given
|
|
773
|
+
* the key behind a message this way, and never a key from the conversation.
|
|
774
|
+
*/
|
|
775
|
+
verifiedSigners(sha256) {
|
|
776
|
+
const keys = [];
|
|
777
|
+
for (const held of this.observed.values()) {
|
|
778
|
+
for (const row of held.rows.values()) {
|
|
779
|
+
if (row.sha256 === sha256 && row.verified === true && !keys.includes(row.from_key)) keys.push(row.from_key);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return keys;
|
|
783
|
+
}
|
|
784
|
+
|
|
390
785
|
/**
|
|
391
786
|
* The partner with this name: the one so named exactly, or the only one so
|
|
392
787
|
* named apart from upper and lower case. Two of those, and no exact one, is
|
|
@@ -426,11 +821,242 @@ export class NodeRuntime {
|
|
|
426
821
|
// release check of 9 November 2026).
|
|
427
822
|
const next = this.partnersFile.filter((p) => !(isObject(p) && (p.name === name || p.key === key)));
|
|
428
823
|
next.push({ name, key });
|
|
429
|
-
await this.
|
|
430
|
-
this.partnersFile = next;
|
|
824
|
+
await this.savePartners(next);
|
|
431
825
|
return this.inboxAfterChange(`partner ${name} was added`, { partner: name, key });
|
|
432
826
|
}
|
|
433
827
|
|
|
828
|
+
/**
|
|
829
|
+
* A partner out of the address book, and out of the inbox at once, as
|
|
830
|
+
* aamio-python and aamio-php do it. Forgetting the name is not a
|
|
831
|
+
* revocation: the service takes the removed key's writes to the old address
|
|
832
|
+
* until that thread expires. So a new inbox opens without the key, and the
|
|
833
|
+
* one that named it is read until it expires for the partners still in the
|
|
834
|
+
* book, who may not have the new address yet, while what the removed key
|
|
835
|
+
* writes there is not handed over. Once the last partner is gone the
|
|
836
|
+
* new inbox takes signed writes from any key rather than unsigned writes
|
|
837
|
+
* from anyone at an address the partners were given. The name finds the
|
|
838
|
+
* partner the way a send does, apart from upper and lower case when only
|
|
839
|
+
* one is so named.
|
|
840
|
+
*/
|
|
841
|
+
async partnerRemove(name) {
|
|
842
|
+
// A key or a name, as a send takes them: the refusal of an ambiguous name
|
|
843
|
+
// says to give the key, and partner remove did not take one.
|
|
844
|
+
const known = isKey(String(name)) ? this.partnerByKey(String(name)) : this.partnerByName(name);
|
|
845
|
+
if (known === null) return { partner: String(name), known: false, inbox: this.channel("inbox")?.w ?? null };
|
|
846
|
+
// The book counts only once partners.json holds it, as with partnerAdd.
|
|
847
|
+
const next = this.partnersFile.filter((p) => !(isObject(p) && p.name === known.name));
|
|
848
|
+
await this.savePartners(next);
|
|
849
|
+
return this.inboxAfterChange(`partner ${known.name} was removed`, { partner: known.name, known: true });
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Writes the address book, and only then holds it. One that cannot be
|
|
854
|
+
* written is said as not_saved, with the book as it was: the command line
|
|
855
|
+
* said only that it could not complete the command.
|
|
856
|
+
*/
|
|
857
|
+
async savePartners(next) {
|
|
858
|
+
try {
|
|
859
|
+
await this.saveJson("partners.json", next);
|
|
860
|
+
} catch (error) {
|
|
861
|
+
if (error?.code === "runtime_closed") throw error;
|
|
862
|
+
throw runtimeError("not_saved", `partners.json could not be written (${error.code || error.message}), so the address book is as it was. Free the disk, or give this user write access to ${this.home}, then do it again.`);
|
|
863
|
+
}
|
|
864
|
+
this.partnersFile = next;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// ------------------------------------------------------------ scopes --
|
|
868
|
+
//
|
|
869
|
+
// A scope keeps board posts unlisted for a group. Each has a name here, and
|
|
870
|
+
// the name is all a caller passes or sees. The key stays in scopes.json, and
|
|
871
|
+
// scopeShare hands it to a partner sealed. Unlisted is not private.
|
|
872
|
+
|
|
873
|
+
/** Writes the scopes, and only then holds them: a save that fails changes nothing. */
|
|
874
|
+
async saveScopes(scopes, aside = this.scopesAside) {
|
|
875
|
+
await this.saveJson("scopes.json", [...scopes, ...aside]);
|
|
876
|
+
this.scopes = scopes;
|
|
877
|
+
this.scopesAside = aside;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/** The same for a caller that asked for the change: what went wrong, and that nothing changed. */
|
|
881
|
+
async keepScopes(scopes, aside, unchanged) {
|
|
882
|
+
try {
|
|
883
|
+
await this.saveScopes(scopes, aside);
|
|
884
|
+
} catch (error) {
|
|
885
|
+
if (error?.code === "runtime_closed") throw error;
|
|
886
|
+
throw runtimeError("not_saved", `scopes.json could not be written (${error.code || error.message}), so ${unchanged}. Free the disk, then do it again.`);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** The scope with this name, apart from upper and lower case. Throws unknown_scope. */
|
|
891
|
+
scopeNamed(name) {
|
|
892
|
+
const entry = scopeCalled(this.scopes, name);
|
|
893
|
+
if (entry === null) throw runtimeError("unknown_scope", `no scope called ${name} here. The scope list shows the ones this runtime holds`);
|
|
894
|
+
return entry;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
scopeList() {
|
|
898
|
+
return this.scopes.map(scopeView);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** A new scope, its key from the system's secure generator. The key stays here. */
|
|
902
|
+
async scopeNew(name) {
|
|
903
|
+
checkScopeName(name);
|
|
904
|
+
if (scopeCalled(this.scopes, name) !== null) throw runtimeError("scope_exists", `there is a scope called ${name} already`);
|
|
905
|
+
const key = newScopeKey();
|
|
906
|
+
const entry = { name, key, address: scopeAddress(key) };
|
|
907
|
+
await this.keepScopes([...this.scopes, entry], undefined, `scope ${name} was not made`);
|
|
908
|
+
return scopeView(entry);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** A scope made elsewhere: the key, to read and post, or the address, to post only. */
|
|
912
|
+
async scopeAdd(name, { key = null, address = null } = {}) {
|
|
913
|
+
const [held, keep] = scopeAddPlan(this.scopes, name, key ?? null, address ?? null);
|
|
914
|
+
if (keep !== null) await this.keepScopes(keep, undefined, `scope ${name} was not kept`);
|
|
915
|
+
return scopeView(held);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Removes a scope, and remembers the invitations it came from in the same
|
|
920
|
+
* write. The cursor and the hashes of what was read live in state.json and
|
|
921
|
+
* the scope in scopes.json: an invitation that comes again as a message
|
|
922
|
+
* never seen, after a state.json that could not be written, would bring a
|
|
923
|
+
* removed scope back. Remembered here, the same one read again keeps
|
|
924
|
+
* nothing. A new invitation is another message, and is taken.
|
|
925
|
+
*/
|
|
926
|
+
async scopeRemove(name) {
|
|
927
|
+
const entry = this.scopeNamed(name);
|
|
928
|
+
const [scopes, aside] = scopeRemovePlan(this.scopes, this.scopesAside, entry);
|
|
929
|
+
await this.keepScopes(scopes, aside, `scope ${entry.name} is still held`);
|
|
930
|
+
return { removed: entry.name };
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** The key itself, for a person to pass on by hand. The local server never calls this. */
|
|
934
|
+
scopeKey(name) {
|
|
935
|
+
const entry = this.scopeNamed(name);
|
|
936
|
+
if (!entry.key) throw runtimeError("scope_write_only", `scope ${entry.name} is held to post only, so there is no key here`);
|
|
937
|
+
return { name: entry.name, key: entry.key, address: entry.address };
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Hands a scope to a partner in a sealed message. read gives the key, write
|
|
942
|
+
* only the address. Only to a partner in the address book, by name or key:
|
|
943
|
+
* an address is learned from the board and from messages, so it can be
|
|
944
|
+
* anyone's, and the key would be sealed to whoever it was learned from. A
|
|
945
|
+
* post asking for a scope would get it.
|
|
946
|
+
*/
|
|
947
|
+
async scopeShare(name, to, access) {
|
|
948
|
+
const entry = this.scopeNamed(name);
|
|
949
|
+
checkAccess(access);
|
|
950
|
+
if (access === "read" && !entry.key) throw runtimeError("scope_write_only", `scope ${entry.name} is held to post only, so it can only be shared with access write`);
|
|
951
|
+
const partner = isKey(String(to)) ? this.partnerByKey(to) : this.partnerByName(to);
|
|
952
|
+
if (!partner) throw runtimeError("unknown_partner", "a scope is shared only with a partner in your address book, by name. Never with an address, which can be anyone's");
|
|
953
|
+
const share = access === "read" ? { name: entry.name, key: entry.key } : { name: entry.name, address: entry.address };
|
|
954
|
+
// By the key, so the partner chosen stays chosen. The envelope is sealed
|
|
955
|
+
// to it, and the outbox and the trace keep the envelope and the names of
|
|
956
|
+
// the fields: the scope's key is in neither in the clear.
|
|
957
|
+
const sent = await this.send(partner.key, { text: `Scope ${entry.name}, shared to ${access === "read" ? "read and post" : "post only"}.`, data: { aamio_scope: share }, archivedData: { aamio_scope: { name: entry.name, access } } });
|
|
958
|
+
return { ...sent, scope: entry.name, access };
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** What an invitation is known by: the hash of its bytes and its signer, as seen_from records it, or null. */
|
|
962
|
+
shareRecord(entry) {
|
|
963
|
+
return typeof entry.sha256 === "string" && HASH.test(entry.sha256) ? seenRecord(entry.sha256, entry.from_key) : null;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* A scope in an incoming message, before the message is weighed, and before
|
|
968
|
+
* anyone reads it. The key is taken out of the message whatever becomes of
|
|
969
|
+
* the share, and aamio_scope is replaced wherever it sits and whatever it
|
|
970
|
+
* holds, so whoever reads the message never sees a key in it.
|
|
971
|
+
*
|
|
972
|
+
* A share is taken only when it came sealed and verified from a partner in
|
|
973
|
+
* the address book, in a message not seen before, and not from an
|
|
974
|
+
* invitation whose scope was removed since. Nothing is kept here. What
|
|
975
|
+
* taking it would write is given back, or null when nothing is to be taken,
|
|
976
|
+
* and the message carries the longer of the two forms the share can end in,
|
|
977
|
+
* kept or not saved: a message has to be the size it was weighed at.
|
|
978
|
+
*/
|
|
979
|
+
scopeSharePlan(entry) {
|
|
980
|
+
const body = entry.body;
|
|
981
|
+
if (!isObject(body)) return null;
|
|
982
|
+
if ("aamio_scope" in body) body.aamio_scope = { kept: false, note: "not kept: a scope is shared in data.aamio_scope" };
|
|
983
|
+
const data = body.data;
|
|
984
|
+
if (!isObject(data) || !("aamio_scope" in data)) return null;
|
|
985
|
+
const share = data.aamio_scope;
|
|
986
|
+
if (!isObject(share)) {
|
|
987
|
+
data.aamio_scope = { kept: false, note: "not kept: data.aamio_scope is an object with name, and key or address" };
|
|
988
|
+
return null;
|
|
989
|
+
}
|
|
990
|
+
const key = share.key ?? null;
|
|
991
|
+
const view = { shared_as: typeof share.name === "string" ? share.name : null, can_read: key !== null, kept: false };
|
|
992
|
+
const partner = entry.from_key ? this.partnerByKey(entry.from_key) : null;
|
|
993
|
+
const invitation = this.shareRecord(entry);
|
|
994
|
+
let why = null;
|
|
995
|
+
if (!(entry.verified && entry.encrypted && entry.known_contact && partner !== null)) {
|
|
996
|
+
why = "not kept: a scope is only taken when it comes sealed from a partner in your address book";
|
|
997
|
+
} else if (entry.replay) {
|
|
998
|
+
why = "not kept again: this message arrived before, and a scope removed since stays removed";
|
|
999
|
+
} else if (entry.replay_unknown) {
|
|
1000
|
+
why = "not kept: these bytes arrived here before, recorded by an earlier version without who sent them, so this may be that message again, and a scope removed since stays removed";
|
|
1001
|
+
} else if (invitation !== null && removedShares(this.scopesAside).has(invitation)) {
|
|
1002
|
+
why = "not kept again: this invitation was taken here before, and its scope was removed since. A scope removed stays removed: ask for it to be shared again";
|
|
1003
|
+
}
|
|
1004
|
+
if (why !== null) {
|
|
1005
|
+
data.aamio_scope = { ...view, note: why };
|
|
1006
|
+
return null;
|
|
1007
|
+
}
|
|
1008
|
+
let held;
|
|
1009
|
+
let keep;
|
|
1010
|
+
let plain;
|
|
1011
|
+
try {
|
|
1012
|
+
const local = sharedScopeName(partner, share.name);
|
|
1013
|
+
[held, keep] = scopeAddPlan(this.scopes, local, key, key !== null ? null : share.address ?? null);
|
|
1014
|
+
// Held already: nothing but the record of this invitation to write.
|
|
1015
|
+
plain = keep === null;
|
|
1016
|
+
// The invitation goes with the scope, in the same write, so that
|
|
1017
|
+
// removing the scope can remember it.
|
|
1018
|
+
if (invitation !== null && !takenFrom(held).includes(invitation)) {
|
|
1019
|
+
const before = held;
|
|
1020
|
+
held = { ...before, taken_from: [...takenFrom(before), invitation] };
|
|
1021
|
+
keep = (keep ?? this.scopes).map((scope) => (scope === before ? held : scope));
|
|
1022
|
+
}
|
|
1023
|
+
} catch (error) {
|
|
1024
|
+
// This share alone. The rest of the messages are handed over either way.
|
|
1025
|
+
data.aamio_scope = { ...view, note: `not kept: ${error.message}` };
|
|
1026
|
+
return null;
|
|
1027
|
+
}
|
|
1028
|
+
const shown = { ...view, ...scopeView(held), kept: true };
|
|
1029
|
+
const unsaved = { ...view, note: SCOPE_NOT_SAVED };
|
|
1030
|
+
data.aamio_scope = longerForm(shown, unsaved);
|
|
1031
|
+
return { data, held, keep, plain, shown, unsaved, shared: share.name };
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* The share of a message that is taken: scopes.json is written, and the
|
|
1036
|
+
* message says kept, or it could not be, and the message says so in the form
|
|
1037
|
+
* it was weighed at while attention says why. Written before the cursor is:
|
|
1038
|
+
* a process that stops between the two reads the message again and finds
|
|
1039
|
+
* the scope held, where the other order would have passed the message by
|
|
1040
|
+
* with the scope not kept and nothing said.
|
|
1041
|
+
*/
|
|
1042
|
+
async scopeShareTake(entry, plan, said) {
|
|
1043
|
+
try {
|
|
1044
|
+
if (plan.keep !== null) await this.saveScopes(plan.keep);
|
|
1045
|
+
plan.data.aamio_scope = plan.shown;
|
|
1046
|
+
} catch (error) {
|
|
1047
|
+
const how = `${error.code || error.name}: ${error.message}`;
|
|
1048
|
+
if (plan.plain) {
|
|
1049
|
+
// Held from before, and shown as it is held: only the record of this
|
|
1050
|
+
// invitation was not written, and that is said where a reader looks.
|
|
1051
|
+
plan.data.aamio_scope = plan.shown;
|
|
1052
|
+
said.push(["scope_share_not_recorded", `message ${entry.seq} shared scope ${plan.shared}, which is held here already as ${plan.held.name}, and the invitation could not be recorded with it in scopes.json: ${how}. The scope stays as it is. Removing it will not remember this invitation, so the same message read again after that would bring the scope back.`]);
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
plan.data.aamio_scope = plan.unsaved;
|
|
1056
|
+
said.push(["scope_not_saved", `message ${entry.seq} shared scope ${plan.shared}, and scopes.json could not be written: ${how}. The scope is not kept.`]);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
434
1060
|
// ------------------------------------------------------------- inbox --
|
|
435
1061
|
|
|
436
1062
|
channel(label) {
|
|
@@ -483,7 +1109,7 @@ export class NodeRuntime {
|
|
|
483
1109
|
return taken;
|
|
484
1110
|
}
|
|
485
1111
|
|
|
486
|
-
/** 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. */
|
|
1112
|
+
/** A new inbox with the address book as it is now. The old one is kept under another label, and marked muted when it names a key no longer there. */
|
|
487
1113
|
async rotateInbox(reason, { caller = false } = {}) {
|
|
488
1114
|
const old = this.channel("inbox");
|
|
489
1115
|
let wanted = this.wantedAllow();
|
|
@@ -537,7 +1163,14 @@ export class NodeRuntime {
|
|
|
537
1163
|
return outcome;
|
|
538
1164
|
}
|
|
539
1165
|
|
|
540
|
-
/**
|
|
1166
|
+
/**
|
|
1167
|
+
* Every older inbox that names a key no longer in the address book is marked
|
|
1168
|
+
* muted. It is still read until it expires: what the partners still in the
|
|
1169
|
+
* book write there is handed over, and what a removed key writes there is
|
|
1170
|
+
* not. Read no more, a partner that had not yet learned the new address
|
|
1171
|
+
* wrote where nobody read (a check of 12 November 2026). It is this side's
|
|
1172
|
+
* delivery stopping for that key, not a revocation.
|
|
1173
|
+
*/
|
|
541
1174
|
muteGenerations(reason) {
|
|
542
1175
|
const partners = new Set(this.partners.map((p) => p.key));
|
|
543
1176
|
const muted = [];
|
|
@@ -547,11 +1180,24 @@ export class NodeRuntime {
|
|
|
547
1180
|
if (!gone.length) continue;
|
|
548
1181
|
channel.muted = true;
|
|
549
1182
|
muted.push(channel.w);
|
|
550
|
-
this.note(channel.label, "muted", `inbox ${channel.w}
|
|
1183
|
+
this.note(channel.label, "muted", `inbox ${channel.w} names ${gone.length} key(s) no longer in the address book after ${reason}. They can still write there until ${channel.expire_at}, and what they write is not handed over; what the partners still in the book write there is read until then.`, channel.w);
|
|
551
1184
|
}
|
|
552
1185
|
return muted;
|
|
553
1186
|
}
|
|
554
1187
|
|
|
1188
|
+
/**
|
|
1189
|
+
* Whether a message on this channel is signed by a key that has left the
|
|
1190
|
+
* address book: on an inbox that named the key, which is read on for the
|
|
1191
|
+
* partners still in the book. Judged message by message against the book
|
|
1192
|
+
* as it is, so a key taken back in is read again.
|
|
1193
|
+
*/
|
|
1194
|
+
leftTheBook(channel, key) {
|
|
1195
|
+
if (!isObject(channel) || typeof key !== "string" || !String(channel.label).startsWith("inbox")) return false;
|
|
1196
|
+
const allow = channel.allow || [];
|
|
1197
|
+
if (allow.includes("*") || !allow.includes(key)) return false;
|
|
1198
|
+
return !this.partners.some((p) => p.key === key);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
555
1201
|
/** The inbox, opened again when it is gone, about to expire, or no longer matches the address book. */
|
|
556
1202
|
async ensureInbox() {
|
|
557
1203
|
const inbox = this.channel("inbox");
|
|
@@ -666,7 +1312,7 @@ export class NodeRuntime {
|
|
|
666
1312
|
// -------------------------------------------------------------- send --
|
|
667
1313
|
|
|
668
1314
|
/** One message, signed and sealed to the recipient, written to the outbox before it goes. */
|
|
669
|
-
async send(to, { text = null, data = null, re = null } = {}) {
|
|
1315
|
+
async send(to, { text = null, data = null, re = null, archivedData = null } = {}) {
|
|
670
1316
|
checkRe(re);
|
|
671
1317
|
const { w, key, partner } = await this.recipient(to);
|
|
672
1318
|
// What the inbox asks of writers is read before anything is stored: a
|
|
@@ -679,7 +1325,7 @@ export class NodeRuntime {
|
|
|
679
1325
|
const body = { from: this.keys.hash.slice(0, 8), reply_to: inbox.w };
|
|
680
1326
|
if (text !== null && text !== undefined) body.text = String(text);
|
|
681
1327
|
if (data !== null && data !== undefined) body.data = data;
|
|
682
|
-
|
|
1328
|
+
Object.assign(body, this.conversation(key, re));
|
|
683
1329
|
const plaintext = JSON.stringify(body);
|
|
684
1330
|
let entry = await this.outboxAdd(w, key, this.keys.seal(key, plaintext), body);
|
|
685
1331
|
let answer = await this.deliver(entry, advice);
|
|
@@ -692,8 +1338,19 @@ export class NodeRuntime {
|
|
|
692
1338
|
entry = await this.outboxAdd(w2, partner.key, this.keys.seal(partner.key, plaintext), body, entry.id);
|
|
693
1339
|
answer = await this.deliver(entry, advice);
|
|
694
1340
|
}
|
|
1341
|
+
// The archive is a record of the send, not the send. archivedData stands
|
|
1342
|
+
// in for data there, for a message that carries what no file should: the
|
|
1343
|
+
// archive says which scope was shared and with whom, never its key.
|
|
1344
|
+
const stored = answer.status === 201 && isObject(answer.result) ? answer.result : {};
|
|
1345
|
+
const archiveError = await this.archiveSafely("sent", {
|
|
1346
|
+
kind: "sent", at: this.now(), to: this.nameForKey(entry.to_key) ?? entry.to_key, w: entry.w, status: answer.status, message_id: entry.id, outcome: entry.status,
|
|
1347
|
+
seq: stored.seq ?? null, sha256: stored.sha256 ?? null, body: archivedData === null || archivedData === undefined ? body : { ...body, data: archivedData },
|
|
1348
|
+
}, `the send ${entry.id}`);
|
|
695
1349
|
if (answer.status !== 201) {
|
|
696
|
-
|
|
1350
|
+
const [retryable, fix] = sendAdvice(outboxOutcome(entry), answer.status);
|
|
1351
|
+
// The trace that could not be written is said with the failure too: a
|
|
1352
|
+
// command that sends once and exits never fetches attention.
|
|
1353
|
+
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, trace_error: answer.traceError, archive_error: archiveError, retryable, fix });
|
|
697
1354
|
}
|
|
698
1355
|
// A 201 is a delivery even when the answer carries no receipt: read field
|
|
699
1356
|
// by field, a missing one threw after the message was stored, and the
|
|
@@ -712,6 +1369,10 @@ export class NodeRuntime {
|
|
|
712
1369
|
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.");
|
|
713
1370
|
}
|
|
714
1371
|
if (said.length) sent.note = said.join(" ") + " Do not send this message again.";
|
|
1372
|
+
// The trace that could not be written, beside the delivery it is about.
|
|
1373
|
+
if (answer.traceError) sent.trace_error = answer.traceError;
|
|
1374
|
+
// And the archive: delivered, and only the record of it here is missing.
|
|
1375
|
+
if (archiveError !== null) sent.archive_error = archiveError;
|
|
715
1376
|
return sent;
|
|
716
1377
|
}
|
|
717
1378
|
|
|
@@ -753,14 +1414,21 @@ export class NodeRuntime {
|
|
|
753
1414
|
|
|
754
1415
|
/** The stored bytes sent once, and what the answer allows this side to claim recorded. */
|
|
755
1416
|
async deliver(entry, advice) {
|
|
1417
|
+
// What an earlier attempt left open stays open, whatever this one is
|
|
1418
|
+
// answered: read here, before the status is written over.
|
|
1419
|
+
if (outboxOpen(entry)) entry.ever_open = true;
|
|
756
1420
|
entry.attempts = (entry.attempts || 0) + 1;
|
|
757
1421
|
entry.status = "sending";
|
|
758
1422
|
try {
|
|
759
1423
|
await this.saveOutbox();
|
|
760
1424
|
} catch (error) {
|
|
761
|
-
// Nothing has left this machine, and nothing will.
|
|
1425
|
+
// Nothing has left this machine for this attempt, and nothing will.
|
|
762
1426
|
entry.status = entry.ever_open ? "unknown" : "stopped";
|
|
763
|
-
|
|
1427
|
+
const why = error.code || error.message;
|
|
1428
|
+
if (entry.ever_open) {
|
|
1429
|
+
throw runtimeError("retry_not_sent", `the outbox could not be written (${why}), so this attempt of message ${entry.id} was not sent. ${EARLIER_ATTEMPT}`, { message_id: entry.id, outcome: entry.status, retryable: null, fix: "Free the disk, then " + RETRY_SAME_ID });
|
|
1430
|
+
}
|
|
1431
|
+
throw runtimeError("not_sent", `the outbox could not be written (${why}), so message ${entry.id} was not sent. Free the disk, then send it again.`, { message_id: entry.id, outcome: entry.status });
|
|
764
1432
|
}
|
|
765
1433
|
const headers = { "Content-Type": "application/json", "X-Key": this.keys.public, "X-Sig": this.keys.sign(threadSigningInput(entry.w, entry.envelope)) };
|
|
766
1434
|
let notes = [...(advice.notes || [])];
|
|
@@ -799,7 +1467,9 @@ export class NodeRuntime {
|
|
|
799
1467
|
} catch (error) {
|
|
800
1468
|
outboxError = `${error.code || error.name}: ${error.message}`;
|
|
801
1469
|
}
|
|
802
|
-
|
|
1470
|
+
// After the outcome is saved, and unable to change it.
|
|
1471
|
+
const traceError = await this.traceSafely("sent", () => this.traceSent(entry, answer.status, answer.result));
|
|
1472
|
+
return { ...answer, notes, outboxError, traceError };
|
|
803
1473
|
}
|
|
804
1474
|
|
|
805
1475
|
/** 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. */
|
|
@@ -827,12 +1497,516 @@ export class NodeRuntime {
|
|
|
827
1497
|
entry.status = "attempted";
|
|
828
1498
|
}
|
|
829
1499
|
|
|
830
|
-
/**
|
|
1500
|
+
/**
|
|
1501
|
+
* What has no settled outcome on this machine: working, in flight, or open
|
|
1502
|
+
* after an answer that settles nothing. The question retry asks, so the two
|
|
1503
|
+
* never disagree about one entry, an entry an older client wrote included.
|
|
1504
|
+
*/
|
|
831
1505
|
outboxPending() {
|
|
832
|
-
return Object.values(this.outbox).filter((e) => isObject(e) &&
|
|
1506
|
+
return Object.values(this.outbox).filter((e) => isObject(e) && (["working", "sending"].includes(e.status) || outboxOpen(e)))
|
|
833
1507
|
.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 }));
|
|
834
1508
|
}
|
|
835
1509
|
|
|
1510
|
+
/**
|
|
1511
|
+
* The same stored bytes sent again, for one message or for every one worth
|
|
1512
|
+
* sending again: one that left and got no answer that settles it, or one a
|
|
1513
|
+
* rate window or a busy service turned away. The id, the address, the key
|
|
1514
|
+
* and the envelope are the ones stored, so a recipient that keeps what it
|
|
1515
|
+
* has seen marks a second copy as a replay. A delivery, and a refusal the
|
|
1516
|
+
* service will repeat, are not sent again: nothing goes out for them.
|
|
1517
|
+
* Whether the action behind the message is safe to repeat is the
|
|
1518
|
+
* application's agreement with the other side, not this function's.
|
|
1519
|
+
*/
|
|
1520
|
+
async outboxRetry(id = null) {
|
|
1521
|
+
const out = [];
|
|
1522
|
+
for (const entry of Object.values(this.outbox)) {
|
|
1523
|
+
if (!isObject(entry) || (id !== null && entry.id !== id) || !outboxRetryable(entry)) continue;
|
|
1524
|
+
let answer;
|
|
1525
|
+
try {
|
|
1526
|
+
answer = await this.deliver(entry, await this.client.planFor(entry.w));
|
|
1527
|
+
} catch (error) {
|
|
1528
|
+
if (error instanceof Error) {
|
|
1529
|
+
// What was sent again before this one stopped is said with the stop.
|
|
1530
|
+
error.retried = out;
|
|
1531
|
+
// A stop at the gate says nothing was sent, which is true of this
|
|
1532
|
+
// attempt and not of the message: which message, and what an earlier
|
|
1533
|
+
// attempt left open, are said beside the gate's own words.
|
|
1534
|
+
if (error instanceof GateStop) {
|
|
1535
|
+
Object.assign(error, { message_id: entry.id, outcome: outboxOutcome(entry) });
|
|
1536
|
+
if (outboxOpen(entry)) error.earlier_attempt = `This stop is about this attempt only. ${EARLIER_ATTEMPT} When the gate can be met, ${RETRY_SAME_ID}`;
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
throw error;
|
|
1540
|
+
}
|
|
1541
|
+
const row = { id: entry.id, w: entry.w, status: entry.status, http: answer.status };
|
|
1542
|
+
if (answer.outboxError) {
|
|
1543
|
+
// What the service answered stands. Only the record of it on this
|
|
1544
|
+
// machine failed, and the answer says both.
|
|
1545
|
+
row.outbox_error = answer.outboxError;
|
|
1546
|
+
row.note = answer.status === 201
|
|
1547
|
+
? "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 send this message again."
|
|
1548
|
+
: "The outbox on this machine could not record this answer, so the entry may say sending until the next start, which calls it unknown.";
|
|
1549
|
+
}
|
|
1550
|
+
if (answer.traceError) row.trace_error = answer.traceError;
|
|
1551
|
+
out.push(row);
|
|
1552
|
+
}
|
|
1553
|
+
return out;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
/**
|
|
1557
|
+
* One entry dropped once its fate no longer matters. Nothing is retried
|
|
1558
|
+
* after this. A send that is already away cannot be recalled, and the answer
|
|
1559
|
+
* says so rather than reporting a clean stop: the difference decides whether
|
|
1560
|
+
* the caller may compose a replacement. The entry is gone from this runtime
|
|
1561
|
+
* only when it is gone from outbox.json.
|
|
1562
|
+
*/
|
|
1563
|
+
async outboxForget(id) {
|
|
1564
|
+
const held = Object.hasOwn(this.outbox, id) && isObject(this.outbox[id]) ? this.outbox[id] : null;
|
|
1565
|
+
const outcome = outboxOutcome(held);
|
|
1566
|
+
if (held !== null) {
|
|
1567
|
+
delete this.outbox[id];
|
|
1568
|
+
try {
|
|
1569
|
+
await this.saveOutbox();
|
|
1570
|
+
} catch (error) {
|
|
1571
|
+
this.outbox[id] = held;
|
|
1572
|
+
throw runtimeError("not_saved", `the outbox could not be written (${error.code || error.message}), so message ${id} is still in it and nothing was dropped. Free the disk, then forget it again.`, { message_id: id, outcome });
|
|
1573
|
+
}
|
|
1574
|
+
this.untold.delete(id);
|
|
1575
|
+
}
|
|
1576
|
+
return { id, forgotten: held !== null, already_sending: !["never_sent", "not_found"].includes(outcome), outcome, note: OUTBOX_NOTES[outcome] };
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// ------------------------------------------------------------- trace --
|
|
1580
|
+
//
|
|
1581
|
+
// A conversation that goes wrong is hard to take apart from one side. The
|
|
1582
|
+
// trace is what this side can show: for each counterpart, what was sent and
|
|
1583
|
+
// how the service answered, what arrived and whether it opened, as hashes and
|
|
1584
|
+
// shapes and never content. It is diagnostics: whatever goes wrong in it
|
|
1585
|
+
// costs the trace, never the send or the read it records.
|
|
1586
|
+
|
|
1587
|
+
/** A trace that did not happen, said in attention, where a caller looks. The line is given back for the answer it belongs to. */
|
|
1588
|
+
untraced(line) {
|
|
1589
|
+
this.note("trace", "untraced", line + " The sends and reads themselves are unaffected; what is behind is the record of them.");
|
|
1590
|
+
return line;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
/** update run, and whatever goes wrong in it said as a trace that did not happen. Gives back the line that says so, or null. */
|
|
1594
|
+
async traceSafely(what, update) {
|
|
1595
|
+
try {
|
|
1596
|
+
return (await update()) ?? null;
|
|
1597
|
+
} catch (error) {
|
|
1598
|
+
return this.untraced(`trace ${what}: not recorded: ${error?.name || "Error"}: ${error?.message}`);
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/** The record for one key, made when first needed and kept to a bound. */
|
|
1603
|
+
traceBook(key) {
|
|
1604
|
+
let book = this.traces[key];
|
|
1605
|
+
if (!isObject(book)) book = this.traces[key] = { sent: [], received: [], last_read: null, active: 0 };
|
|
1606
|
+
book.active = Math.floor(this.now());
|
|
1607
|
+
const keys = Object.keys(this.traces);
|
|
1608
|
+
if (keys.length > TRACE_KEYS) {
|
|
1609
|
+
const stale = keys.sort((a, b) => (this.traces[a]?.active || 0) - (this.traces[b]?.active || 0)).slice(0, keys.length - TRACE_KEYS);
|
|
1610
|
+
for (const old of stale) if (old !== key) delete this.traces[old];
|
|
1611
|
+
}
|
|
1612
|
+
return book;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
/**
|
|
1616
|
+
* The two fields a message carries about the conversation it is part of: re,
|
|
1617
|
+
* the message it answers, and seen, the last message from this key that was
|
|
1618
|
+
* read here and could be opened. A trace that cannot say leaves seen out,
|
|
1619
|
+
* and never stops a send.
|
|
1620
|
+
*/
|
|
1621
|
+
conversation(key, re = null) {
|
|
1622
|
+
const fields = {};
|
|
1623
|
+
if (re) fields.re = re;
|
|
1624
|
+
const last = isObject(this.traces[key]) ? this.traces[key].last_read : null;
|
|
1625
|
+
if (isObject(last) && isMessageHash(last.sha256)) fields.seen = last.sha256;
|
|
1626
|
+
return fields;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/** One answer to one send: what went where, as the service stored it. Once per message id: a retry replaces the row. */
|
|
1630
|
+
async traceSent(entry, status, result) {
|
|
1631
|
+
const key = entry.to_key;
|
|
1632
|
+
if (!key) return null;
|
|
1633
|
+
const stored = status === 201 && isObject(result);
|
|
1634
|
+
const record = traceRow({
|
|
1635
|
+
message_id: entry.id, at: Math.floor(this.now()), w: entry.w, status, outcome: entry.status,
|
|
1636
|
+
seq: stored ? result.seq ?? null : null, sha256: stored ? result.sha256 ?? null : null, sealed: true,
|
|
1637
|
+
...(isObject(entry.shape) ? entry.shape : {}),
|
|
1638
|
+
}, TRACE_SENT_FIELDS);
|
|
1639
|
+
const book = this.traceBook(key);
|
|
1640
|
+
book.sent = [...book.sent.filter((row) => row.message_id !== record.message_id), record].slice(-TRACE_KEEP);
|
|
1641
|
+
return this.saveTrace();
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/**
|
|
1645
|
+
* One verified message from a key, as it arrived here. Recorded whether or
|
|
1646
|
+
* not it could be opened, since one that could not is what a trace is for.
|
|
1647
|
+
* Taken as read only when it could: last_read is what the next message to
|
|
1648
|
+
* this key names as seen, and it names a message this side opened, not one
|
|
1649
|
+
* that merely arrived. A replay is neither recorded nor read: its first
|
|
1650
|
+
* arrival was, and an old message sent again must not move seen back to it.
|
|
1651
|
+
* Whether anything changed.
|
|
1652
|
+
*/
|
|
1653
|
+
traceReceived(channel, entry) {
|
|
1654
|
+
if (entry.replay) return false;
|
|
1655
|
+
const opened = (entry.format === "text" || entry.format === "json") && !entry.error;
|
|
1656
|
+
const body = opened && isObject(entry.body) ? entry.body : {};
|
|
1657
|
+
const row = traceRow({
|
|
1658
|
+
at: entry.at, channel: channel.label, w: channel.w, seq: entry.seq, sha256: entry.sha256, encrypted: entry.encrypted, format: entry.format,
|
|
1659
|
+
error: entry.error,
|
|
1660
|
+
// What a message held is known only if it opened.
|
|
1661
|
+
fields: opened ? Object.keys(body).sort() : null,
|
|
1662
|
+
text_chars: typeof body.text === "string" ? [...body.text].length : null,
|
|
1663
|
+
re: body.re, seen: body.seen,
|
|
1664
|
+
}, TRACE_RECEIVED_FIELDS);
|
|
1665
|
+
const book = this.traceBook(entry.from_key);
|
|
1666
|
+
if (row.sha256 && book.received.some((held) => held.sha256 === row.sha256)) return false;
|
|
1667
|
+
book.received = [...book.received, row].slice(-TRACE_KEEP);
|
|
1668
|
+
if (opened && entry.verified === true && isMessageHash(entry.sha256)) {
|
|
1669
|
+
book.last_read = traceRow({ sha256: entry.sha256, seq: entry.seq, w: channel.w, at: entry.at }, TRACE_READ_FIELDS);
|
|
1670
|
+
}
|
|
1671
|
+
return true;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
/** What a read handed over, recorded once the read is written down: a message that was not handed over was not read. */
|
|
1675
|
+
async traceRead(channel, entries) {
|
|
1676
|
+
await this.traceSafely("received", async () => {
|
|
1677
|
+
let changed = false;
|
|
1678
|
+
for (const entry of entries) {
|
|
1679
|
+
if (entry.verified === true && entry.from_key) changed = this.traceReceived(channel, entry) || changed;
|
|
1680
|
+
}
|
|
1681
|
+
return changed ? this.saveTrace() : null;
|
|
1682
|
+
});
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
/** A partner name, a key or a write address, as the key it stands for, or null. */
|
|
1686
|
+
traceKey(who) {
|
|
1687
|
+
const partner = typeof who === "string" ? this.partnerByName(who) : null;
|
|
1688
|
+
if (partner) return partner.key;
|
|
1689
|
+
if (isKey(String(who))) return String(who);
|
|
1690
|
+
return typeof who === "string" && Object.hasOwn(this.state.peers, who) ? this.state.peers[who] : null;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
/**
|
|
1694
|
+
* What was sent to one counterpart and what came back, as hashes and shapes.
|
|
1695
|
+
* who is a partner name, a key or a write address; without it, one line per
|
|
1696
|
+
* counterpart. Nothing here is content: the sha256 is what the service
|
|
1697
|
+
* stored, byte for byte, so the other side can hold its own trace against
|
|
1698
|
+
* this one.
|
|
1699
|
+
*/
|
|
1700
|
+
trace(who = null, limit = 20) {
|
|
1701
|
+
const most = Math.max(1, Math.min(Math.floor(Number(limit) || 20), TRACE_KEEP));
|
|
1702
|
+
if (who === null || who === undefined) {
|
|
1703
|
+
const everyone = [];
|
|
1704
|
+
for (const [key, book] of Object.entries(this.traces)) {
|
|
1705
|
+
if (!isObject(book)) continue;
|
|
1706
|
+
const sent = Array.isArray(book.sent) ? book.sent : [];
|
|
1707
|
+
const received = Array.isArray(book.received) ? book.received : [];
|
|
1708
|
+
everyone.push({
|
|
1709
|
+
with: this.nameForKey(key) ?? key, key, sent: sent.length, received: received.length,
|
|
1710
|
+
last_sent_at: sent.length ? sent[sent.length - 1].at ?? null : null,
|
|
1711
|
+
last_received_at: received.length ? received[received.length - 1].at ?? null : null,
|
|
1712
|
+
no_read_claim: traceView(book, TRACE_KEEP).no_read_claim.length,
|
|
1713
|
+
});
|
|
1714
|
+
}
|
|
1715
|
+
const latest = (row) => Math.max(row.last_sent_at || 0, row.last_received_at || 0);
|
|
1716
|
+
everyone.sort((a, b) => latest(b) - latest(a));
|
|
1717
|
+
return { counterparts: everyone, note: "Name one with who for the messages themselves, as hashes." };
|
|
1718
|
+
}
|
|
1719
|
+
const key = this.traceKey(who);
|
|
1720
|
+
const book = key && Object.hasOwn(this.traces, key) ? this.traces[key] : null;
|
|
1721
|
+
if (!isObject(book)) throw runtimeError("no_trace", `nothing sent to or received from ${who} is recorded here`);
|
|
1722
|
+
const view = traceView(book, most);
|
|
1723
|
+
return {
|
|
1724
|
+
with: this.nameForKey(key) ?? key, key, sent: view.sent, received: view.received,
|
|
1725
|
+
// What this side last read and opened from them: what the next message to them names as seen.
|
|
1726
|
+
last_read: book.last_read ?? null,
|
|
1727
|
+
no_read_claim: view.no_read_claim, note: view.note,
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
// ---------------------------------------------------------- channels --
|
|
1732
|
+
//
|
|
1733
|
+
// A channel is a thread of its own beside the inbox: its own lifetime, its
|
|
1734
|
+
// own list of who may write, and with a gate its own conditions. Its
|
|
1735
|
+
// address reaches whoever is to write there in a sealed and signed message,
|
|
1736
|
+
// which is what lets their runtime send to it: an address pasted into text
|
|
1737
|
+
// binds nothing.
|
|
1738
|
+
|
|
1739
|
+
/** The channel a label names, apart from upper and lower case: two labels that differ only so are one file in the archive where the file system says so. */
|
|
1740
|
+
channelCalled(label) {
|
|
1741
|
+
const wanted = String(label).toLowerCase();
|
|
1742
|
+
return this.state.channels.find((channel) => isObject(channel) && String(channel.label).toLowerCase() === wanted) ?? null;
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
/**
|
|
1746
|
+
* A thread opened for these keys and held as a channel under this label,
|
|
1747
|
+
* used only once state.json holds it. One that cannot be written down is
|
|
1748
|
+
* closed again at the service, as far as that can be done, and its address
|
|
1749
|
+
* is given to nobody: after a restart this home could not read there.
|
|
1750
|
+
*/
|
|
1751
|
+
async openChannelThread(label, keys, ttl, gate = null) {
|
|
1752
|
+
let thread;
|
|
1753
|
+
try {
|
|
1754
|
+
thread = await this.client.open({ ttl, allow: keys.length ? keys : undefined, gate: gate ?? undefined });
|
|
1755
|
+
} catch (error) {
|
|
1756
|
+
const said = isObject(error.body) ? error.body : {};
|
|
1757
|
+
const status = Number.isInteger(error.status) ? error.status : 0;
|
|
1758
|
+
// Only a refusal says nothing was opened. With no answer, or one that
|
|
1759
|
+
// does not say, the service may hold a thread: its read key was made for
|
|
1760
|
+
// this request and is kept nowhere, so nobody reads there, nobody was
|
|
1761
|
+
// given its address, and it goes when its time is out. "Nothing was
|
|
1762
|
+
// opened" was said of a thread the service held (a check of 12 November
|
|
1763
|
+
// 2026).
|
|
1764
|
+
const refused = status >= 400 && status < 500;
|
|
1765
|
+
const after = refused
|
|
1766
|
+
? "Nothing was opened."
|
|
1767
|
+
: `${status === 0 ? "No answer came back, so it is not known" : "That answer does not say"} whether the service opened a thread. This home holds no channel called ${label}, and nobody was given an address: a thread opened there is read by nobody and goes when its time is out.`;
|
|
1768
|
+
const fix = said.fix ?? (refused ? null : "Opening it again is safe: that makes a new address, and the one that may be there is known to nobody.");
|
|
1769
|
+
throw runtimeError("channel_unavailable", `could not open channel: ${status} ${said.error ?? JSON.stringify(error.body ?? error.message)}. ${after}`, { status, ...(fix ? { fix } : {}) });
|
|
1770
|
+
}
|
|
1771
|
+
const channel = { label, read_key: thread.id, w: thread.w, expire_at: thread.expireAt, allow: keys, after: 0, created_at: null, instance: null, gone: false, muted: false, seen: [], seen_from: [] };
|
|
1772
|
+
this.state.channels.push(channel);
|
|
1773
|
+
try {
|
|
1774
|
+
await this.saveState();
|
|
1775
|
+
} catch (error) {
|
|
1776
|
+
this.state.channels = this.state.channels.filter((held) => held !== channel);
|
|
1777
|
+
const closed = await this.client.close({ w: thread.w, id: thread.id }).then(() => true, () => false);
|
|
1778
|
+
throw runtimeError("not_saved", `the channel could not be written down (${error.code || error.message}), so it is not used and its address was given to nobody. ${closed ? "It was closed again at the service." : `It could not be closed at the service either, and stays there, unread, until ${thread.expireAt}.`}`);
|
|
1779
|
+
}
|
|
1780
|
+
return channel;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
/**
|
|
1784
|
+
* A channel with its own lifetime and, with to, its address handed to
|
|
1785
|
+
* whoever is to write there. to is a partner's name or key, or a write
|
|
1786
|
+
* address a verified message gave as reply_to or channel. That key goes on
|
|
1787
|
+
* the list beside the partners in allow, and the address goes to it sealed
|
|
1788
|
+
* and signed. Where the address goes, whose key seals it, whether the inbox
|
|
1789
|
+
* there takes it and whether the message fits are settled before anything
|
|
1790
|
+
* is opened: what stops there leaves no channel behind.
|
|
1791
|
+
*/
|
|
1792
|
+
async channelOpen(label, { ttl = CHANNEL_TTL, allow = null, gate = null, to = null, note = null } = {}) {
|
|
1793
|
+
checkLabel(label);
|
|
1794
|
+
checkChannelTtl(ttl);
|
|
1795
|
+
if (this.channelCalled(label) !== null) throw runtimeError("channel_exists", `there is a channel called ${this.channelCalled(label).label} already. Close it or choose another label`);
|
|
1796
|
+
const handing = to !== null && to !== undefined;
|
|
1797
|
+
if (note !== null && note !== undefined && !handing) throw runtimeError("invalid_note", "note travels with the address to whoever to names, so without to there is nothing to carry it");
|
|
1798
|
+
const keys = [];
|
|
1799
|
+
for (const name of allow ?? []) {
|
|
1800
|
+
const partner = this.partnerByName(name);
|
|
1801
|
+
if (partner === null) throw runtimeError("unknown_partner", `unknown partner: ${name}`);
|
|
1802
|
+
if (!keys.includes(partner.key)) keys.push(partner.key);
|
|
1803
|
+
}
|
|
1804
|
+
if (gate !== null && gate !== undefined) checkGate(gate);
|
|
1805
|
+
let handover = null;
|
|
1806
|
+
if (handing) {
|
|
1807
|
+
const { w: replyTo, key } = await this.recipient(to);
|
|
1808
|
+
// The gate of the inbox the address goes to, read as a send reads it.
|
|
1809
|
+
const advice = await this.client.planFor(replyTo);
|
|
1810
|
+
this.handoverFits(key, ttl, note);
|
|
1811
|
+
if (!keys.includes(key)) keys.push(key);
|
|
1812
|
+
handover = { replyTo, key, advice };
|
|
1813
|
+
}
|
|
1814
|
+
const channel = await this.openChannelThread(label, keys, ttl, gate ?? null);
|
|
1815
|
+
// The gate is in the answer whether or not there is one. It is not kept
|
|
1816
|
+
// here: the thread holds it and GET /{w}/gate serves it, and a second copy
|
|
1817
|
+
// on this machine could only disagree with the first.
|
|
1818
|
+
const opened = { label, w: channel.w, expire_at: channel.expire_at, allow: keys.map((key) => this.nameForKey(key) ?? key), gate: gate ?? null };
|
|
1819
|
+
if (handover === null) return opened;
|
|
1820
|
+
const told = await this.handOver(channel, handover.key, handover.replyTo, note, handover.advice);
|
|
1821
|
+
return { ...opened, handed_to: this.nameForKey(handover.key) ?? handover.key, address_sent_to: handover.replyTo, ...told };
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
/**
|
|
1825
|
+
* A private channel only that key may write to, with its address handed
|
|
1826
|
+
* over when replyTo says where. This is how a conversation leaves the open
|
|
1827
|
+
* board inbox: one answer there, then everything else in a thread nobody
|
|
1828
|
+
* else can write to. key is a key, or a partner's name.
|
|
1829
|
+
*/
|
|
1830
|
+
async channelOpenWith(key, { ttl = 900, label = null, replyTo = null, note = null } = {}) {
|
|
1831
|
+
checkChannelTtl(ttl);
|
|
1832
|
+
let theirs = String(key);
|
|
1833
|
+
if (!isKey(theirs)) {
|
|
1834
|
+
const partner = this.partnerByName(theirs);
|
|
1835
|
+
if (partner === null) throw runtimeError("unknown_partner", `not a key and not a known partner: ${key}`);
|
|
1836
|
+
theirs = partner.key;
|
|
1837
|
+
}
|
|
1838
|
+
const handing = replyTo !== null && replyTo !== undefined;
|
|
1839
|
+
if (handing && !(typeof replyTo === "string" && ADDRESS.test(replyTo))) throw runtimeError("invalid_address", "reply-to is a write address, 20 characters of a-z and 2-7, as a verified message gives it in reply_to");
|
|
1840
|
+
if (note !== null && note !== undefined && !handing) throw runtimeError("invalid_note", "note travels with the address to the inbox reply-to names, so without reply-to there is nothing to carry it");
|
|
1841
|
+
// An address this home knows as another key's is not the place to send this one's channel.
|
|
1842
|
+
const bound = handing ? this.state.peers[replyTo] : undefined;
|
|
1843
|
+
if (bound !== undefined && bound !== theirs) {
|
|
1844
|
+
throw runtimeError("binding_conflict", `the address ${replyTo} is bound to ${this.nameForKey(bound) ?? bound}, and this channel is for another key, so its address would be sealed to a key that does not read there. Nothing was opened.`);
|
|
1845
|
+
}
|
|
1846
|
+
let name = label;
|
|
1847
|
+
if (name !== null && name !== undefined) {
|
|
1848
|
+
checkLabel(name);
|
|
1849
|
+
if (this.channelCalled(name) !== null) throw runtimeError("channel_exists", `there is a channel called ${this.channelCalled(name).label} already. Close it or choose another label`);
|
|
1850
|
+
} else {
|
|
1851
|
+
name = `with-${keyHash(theirs).slice(0, 8)}`;
|
|
1852
|
+
if (this.channelCalled(name) !== null) name = this.freeLabel(`${name}-${Math.floor(this.now())}`);
|
|
1853
|
+
}
|
|
1854
|
+
let advice = null;
|
|
1855
|
+
if (handing) {
|
|
1856
|
+
advice = await this.client.planFor(replyTo);
|
|
1857
|
+
this.handoverFits(theirs, ttl, note);
|
|
1858
|
+
}
|
|
1859
|
+
const channel = await this.openChannelThread(name, [theirs], ttl);
|
|
1860
|
+
const opened = { label: name, w: channel.w, expire_at: channel.expire_at, with: this.nameForKey(theirs) ?? theirs, address_sent_to: handing ? replyTo : null };
|
|
1861
|
+
if (!handing) return opened;
|
|
1862
|
+
return { ...opened, ...(await this.handOver(channel, theirs, replyTo, note, advice)) };
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
/** The body that carries a channel's address, and the envelope it is sealed in. */
|
|
1866
|
+
invitation(key, w, expireAt, note = null) {
|
|
1867
|
+
const body = { channel: w, expire_at: expireAt };
|
|
1868
|
+
if (note !== null && note !== undefined && note !== "") body.text = String(note);
|
|
1869
|
+
Object.assign(body, this.conversation(key));
|
|
1870
|
+
return [body, this.keys.seal(key, JSON.stringify(body))];
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
/**
|
|
1874
|
+
* Whether the message that will carry a channel's address fits in one
|
|
1875
|
+
* message, asked before the channel is opened. The address and the expiry
|
|
1876
|
+
* are not known yet. Both have a fixed length, so a stand-in of the same
|
|
1877
|
+
* length seals to the same size. A note too long for it would open the
|
|
1878
|
+
* channel first, and the message carrying the address would then be refused.
|
|
1879
|
+
*/
|
|
1880
|
+
handoverFits(key, ttl, note = null) {
|
|
1881
|
+
const [body] = this.invitation(key, "a".repeat(20), Math.floor(this.now()) + ttl, note);
|
|
1882
|
+
// seen can appear before the handover, if a message from this key is read
|
|
1883
|
+
// meanwhile, and it is always 64 characters: counted as there already.
|
|
1884
|
+
if (!("seen" in body)) body.seen = "0".repeat(64);
|
|
1885
|
+
const size = Buffer.byteLength(this.keys.seal(key, JSON.stringify(body)), "utf8");
|
|
1886
|
+
if (size > MESSAGE_MAX_BYTES) {
|
|
1887
|
+
throw runtimeError("too_large", `with this note the message carrying the address would be ${size} bytes, and a message is at most ${MESSAGE_MAX_BYTES}, so no channel was opened. Shorten the note, and send the rest on the channel once it is open.`);
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
/**
|
|
1892
|
+
* The channel's address sent to replyTo, sealed to key and signed, through
|
|
1893
|
+
* the outbox like any send, so its outcome is kept and the same bytes can
|
|
1894
|
+
* go again. Gives what the caller's answer carries besides: the message's
|
|
1895
|
+
* id, and what could not be recorded of it here.
|
|
1896
|
+
*
|
|
1897
|
+
* Whatever stops it, the channel is open by now, and the failure says so,
|
|
1898
|
+
* with the channel, and says whether anything left this machine and, of
|
|
1899
|
+
* what left, whether it is known not to have arrived.
|
|
1900
|
+
*/
|
|
1901
|
+
async handOver(channel, key, replyTo, note = null, advice = null) {
|
|
1902
|
+
const opened = { label: channel.label, w: channel.w, expire_at: channel.expire_at };
|
|
1903
|
+
const listed = `The channel ${channel.label} is open and listed by channel list, aamio_channels over MCP`;
|
|
1904
|
+
// Said of a message the service turned away or a gate stopped: it is known not to be there.
|
|
1905
|
+
const still = `${listed}: only the message carrying its address did not go. `;
|
|
1906
|
+
// Said of one that left and has no answer that settles it. It may be with
|
|
1907
|
+
// them already, and "did not go" read as leave to open another channel and
|
|
1908
|
+
// invite again, which the one who has the first reads as two (a check of
|
|
1909
|
+
// 12 November 2026).
|
|
1910
|
+
const unsettled = `${listed}, and the message carrying its address may have arrived: its outcome is not known. Do not open another channel for this: a new attempt is the same message, under the same message_id. `;
|
|
1911
|
+
let entry = null;
|
|
1912
|
+
let answer;
|
|
1913
|
+
try {
|
|
1914
|
+
const [body, envelope] = this.invitation(key, channel.w, channel.expire_at, note);
|
|
1915
|
+
entry = await this.outboxAdd(replyTo, key, envelope, body);
|
|
1916
|
+
answer = await this.deliver(entry, advice ?? await this.client.planFor(replyTo));
|
|
1917
|
+
} catch (error) {
|
|
1918
|
+
if (error instanceof GateStop) {
|
|
1919
|
+
error.opened = opened;
|
|
1920
|
+
// Nothing was sent, so nobody has the address, and the channel is one
|
|
1921
|
+
// nobody will write to: closed, and opened anew when it can be handed over.
|
|
1922
|
+
error.fix = still + `Nothing was sent, so nobody has been given its address: close it with channel close ${channel.label}, aamio_close_channel over MCP, and open a new one when the inbox's conditions can be met. ` + (error.fix ?? "");
|
|
1923
|
+
throw error;
|
|
1924
|
+
}
|
|
1925
|
+
// Stopped before a byte left: the outbox could not be written, or the message could not be made.
|
|
1926
|
+
throw runtimeError("send_failed", `the channel ${channel.label} is open, and the message carrying its address was not sent: ${error.message}`, {
|
|
1927
|
+
outcome: "never_sent", message_id: entry?.id ?? error.message_id ?? null, status: 0, opened, retryable: false,
|
|
1928
|
+
fix: `Nothing left this machine, so nobody has been given the address of ${channel.label}. Close it with channel close ${channel.label}, aamio_close_channel over MCP, put right what the error says, and open a new one.`,
|
|
1929
|
+
});
|
|
1930
|
+
}
|
|
1931
|
+
if (answer.status !== 201) {
|
|
1932
|
+
const outcome = outboxOutcome(entry);
|
|
1933
|
+
const [retryable, advice] = sendAdvice(outcome, answer.status);
|
|
1934
|
+
// Only a refusal says the message is not there.
|
|
1935
|
+
const refused = outcome === "refused";
|
|
1936
|
+
// And then nobody has the address. Sending a corrected message, as a
|
|
1937
|
+
// refused send is told, would need the address, and an address goes
|
|
1938
|
+
// only with a channel: this one is closed, and a new one opened.
|
|
1939
|
+
const fix = refused && !SEND_TRY_LATER.includes(answer.status)
|
|
1940
|
+
? `aamio refused it, so nobody has been given the address of ${channel.label}. Close it with channel close ${channel.label}, aamio_close_channel over MCP, put right what the error says, and open a new one.`
|
|
1941
|
+
: advice;
|
|
1942
|
+
throw runtimeError("send_failed", `the channel ${channel.label} is open, and the message carrying its address ${refused ? "did not land" : "may have arrived"}: send ${entry.status} (http ${answer.status}): ${JSON.stringify(answer.result)}`, {
|
|
1943
|
+
outcome: entry.status, message_id: entry.id, status: answer.status, outbox_error: answer.outboxError, trace_error: answer.traceError, opened, retryable, fix: (refused ? still : unsettled) + fix,
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1946
|
+
const told = { message_id: entry.id };
|
|
1947
|
+
if (answer.notes?.length) told.notes = answer.notes;
|
|
1948
|
+
if (answer.outboxError) {
|
|
1949
|
+
told.outbox_error = answer.outboxError;
|
|
1950
|
+
told.note = "Delivered: aamio stored the message carrying the address. The outbox on this machine could not record that, so it may say sending until the next start, which calls it unknown. Do not hand the address over again.";
|
|
1951
|
+
}
|
|
1952
|
+
if (answer.traceError) told.trace_error = answer.traceError;
|
|
1953
|
+
return told;
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
/** The channels of this home: the public address, how long each has left, and how far it is read. Never a read key. */
|
|
1957
|
+
channelList() {
|
|
1958
|
+
const now = this.now();
|
|
1959
|
+
return this.state.channels.filter(isObject).map((channel) => ({
|
|
1960
|
+
label: channel.label, w: channel.w, expire_at: channel.expire_at, seconds_left: Math.max(0, Math.floor(Number(channel.expire_at) - now)), expired: Number(channel.expire_at) <= now,
|
|
1961
|
+
allow: (channel.allow || []).map((key) => this.nameForKey(key) ?? key),
|
|
1962
|
+
// What this process read here, and where the cursor stands: what a command of its own, which has read nothing, can still say.
|
|
1963
|
+
received: (this.received.get(channel.w) ?? []).length, read_to: Number.isInteger(channel.after) ? channel.after : 0, gone: channel.gone === true, muted: channel.muted === true,
|
|
1964
|
+
}));
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
/**
|
|
1968
|
+
* Closes a channel before it expires. The thread is asked gone at the
|
|
1969
|
+
* service first, and the channel leaves this home when the service says it
|
|
1970
|
+
* is gone: deleted now, or not there any more. When the service does not
|
|
1971
|
+
* say so, the channel stays and is still read: taken out of this home while
|
|
1972
|
+
* the thread lived on, whoever wrote there would write where nobody reads.
|
|
1973
|
+
* The answer says which of the two happened, here and at the service.
|
|
1974
|
+
*/
|
|
1975
|
+
async channelClose(label) {
|
|
1976
|
+
const channel = this.channel(String(label));
|
|
1977
|
+
if (!isObject(channel)) {
|
|
1978
|
+
const labels = this.state.channels.filter(isObject).map((held) => held.label);
|
|
1979
|
+
throw runtimeError("unknown_channel", `no channel called ${label} in this home. It has ${labels.length ? labels.join(", ") : "none"}.`);
|
|
1980
|
+
}
|
|
1981
|
+
let status = 200;
|
|
1982
|
+
try {
|
|
1983
|
+
await this.client.close({ w: channel.w, id: channel.read_key });
|
|
1984
|
+
} catch (error) {
|
|
1985
|
+
status = error instanceof AamioError ? error.status : 0;
|
|
1986
|
+
}
|
|
1987
|
+
const gone = status === 200 || status === 404 || status === 410;
|
|
1988
|
+
if (!gone) {
|
|
1989
|
+
return {
|
|
1990
|
+
label: channel.label, w: channel.w, status, deleted: false, closed_here: false,
|
|
1991
|
+
note: `${status === 0 ? "The service did not answer" : `The service answered ${status}`}, so the thread may still be there, and the channel is kept and still read. Close it again, or let it expire at ${channel.expire_at}.`,
|
|
1992
|
+
};
|
|
1993
|
+
}
|
|
1994
|
+
const before = this.state.channels;
|
|
1995
|
+
this.state.channels = before.filter((held) => held !== channel);
|
|
1996
|
+
try {
|
|
1997
|
+
await this.saveState();
|
|
1998
|
+
} catch (error) {
|
|
1999
|
+
this.state.channels = before;
|
|
2000
|
+
throw runtimeError("not_saved", `${status === 200 ? "the thread is deleted at the service" : "there is no thread at the service any more"}, and state.json could not be written (${error.code || error.message}), so this home still lists ${channel.label}. Close it again once the disk takes writes.`, { status, deleted: status === 200 });
|
|
2001
|
+
}
|
|
2002
|
+
this.received.delete(channel.w);
|
|
2003
|
+
this.observed.delete(channel.w);
|
|
2004
|
+
return {
|
|
2005
|
+
label: channel.label, w: channel.w, status, deleted: status === 200, closed_here: true,
|
|
2006
|
+
...(status === 200 ? {} : { note: "There was no thread at this address any more: it had expired, or was closed before. The channel is taken out of this home." }),
|
|
2007
|
+
};
|
|
2008
|
+
}
|
|
2009
|
+
|
|
836
2010
|
// ------------------------------------------------------------- board --
|
|
837
2011
|
//
|
|
838
2012
|
// The open board, where agents that have not met find each other. Posts are
|
|
@@ -870,20 +2044,37 @@ export class NodeRuntime {
|
|
|
870
2044
|
return channel;
|
|
871
2045
|
}
|
|
872
2046
|
|
|
873
|
-
/**
|
|
874
|
-
|
|
2047
|
+
/**
|
|
2048
|
+
* A need or an offer on the open board, pointing at the board inbox, which
|
|
2049
|
+
* outlives it. With scope, the name of a scope held here, the post carries
|
|
2050
|
+
* that scope's address and is unlisted: only a find with the scope's key
|
|
2051
|
+
* returns it. Unlisted is not private.
|
|
2052
|
+
*/
|
|
2053
|
+
async boardPost({ kind, title, text, tags = [], ttl = BOARD_TTL, lang = null, deadline = null, scope = null } = {}) {
|
|
2054
|
+
// Before the inbox is opened, so a name that is not here costs nothing.
|
|
2055
|
+
const held = scope !== null && scope !== undefined ? this.scopeNamed(scope) : null;
|
|
875
2056
|
const life = Math.floor(Number(ttl) || BOARD_TTL);
|
|
876
|
-
|
|
2057
|
+
// An inbox that outlives the post by the margin the library leaves for the work and the round trip.
|
|
2058
|
+
const channel = await this.ensureBoardInbox(life + BOARD_POST_MARGIN);
|
|
877
2059
|
let made;
|
|
878
2060
|
try {
|
|
879
|
-
|
|
2061
|
+
// The address goes inside the signed body, so nobody can post the same bytes without it.
|
|
2062
|
+
made = await this.client.board.post({ kind, title, text, tags: (tags || []).slice(0, 8), ttl: life, ...(lang ? { lang } : {}), ...(deadline ? { deadline } : {}), ...(held ? { scope: held.address } : {}) }, { inbox: { w: channel.w, expireAt: channel.expire_at } });
|
|
880
2063
|
} catch (error) {
|
|
881
2064
|
throw boardError("board post", error);
|
|
882
2065
|
}
|
|
883
|
-
//
|
|
2066
|
+
// The post is made. A record of it that cannot be written is said beside
|
|
2067
|
+
// it, and is no reason to tell the caller the post failed: it would be
|
|
2068
|
+
// made again.
|
|
2069
|
+
const archiveError = await this.archiveSafely("board", { kind: "posted", at: this.now(), post: made.post }, `the post ${isObject(made.post) ? made.post.id : ""}`);
|
|
2070
|
+
// Where the answers go, until when that inbox is open and how to read
|
|
2071
|
+
// them, in the answer itself. The inbox's time is not the post's:
|
|
2072
|
+
// post.expire_at is when the post leaves the board.
|
|
884
2073
|
return {
|
|
885
|
-
post: made.post, inbox: channel.w, answers_arrive_on: "board",
|
|
886
|
-
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.
|
|
2074
|
+
post: made.post, inbox: channel.w, inbox_expires_at: channel.expire_at, answers_arrive_on: "board",
|
|
2075
|
+
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. board replies on the command line lists only the answers, the messages that name a post of yours or arrived on a board inbox, and says how many it left out.",
|
|
2076
|
+
...(held ? { scope: held.name } : {}),
|
|
2077
|
+
...(archiveError !== null ? { archive_error: archiveError } : {}),
|
|
887
2078
|
};
|
|
888
2079
|
}
|
|
889
2080
|
|
|
@@ -892,21 +2083,42 @@ export class NodeRuntime {
|
|
|
892
2083
|
* after (the cursor from the last page), wait (up to 25 s for the next one)
|
|
893
2084
|
* and min_work_bits. The address on each post is bound to its key, so a send
|
|
894
2085
|
* to it is sealed to the poster. An address bound before keeps its key.
|
|
2086
|
+
*
|
|
2087
|
+
* With scope, the name of a scope held here with its key, the find reads
|
|
2088
|
+
* that scope instead of the public board. The key goes to the board in the
|
|
2089
|
+
* body of the find and nowhere else, and is in nothing this gives back.
|
|
895
2090
|
*/
|
|
896
|
-
async boardFind({ kind = null, tags = null, lang = null, after = 0, wait = 0, min_work_bits: bits = 0 } = {}) {
|
|
2091
|
+
async boardFind({ kind = null, tags = null, lang = null, after = 0, wait = 0, min_work_bits: bits = 0, scope = null } = {}) {
|
|
2092
|
+
const held = scope !== null && scope !== undefined ? this.scopeNamed(scope) : null;
|
|
2093
|
+
if (held !== null && !held.key) throw runtimeError("scope_write_only", `scope ${held.name} is held to post only. Reading it takes the key, which a partner can share with access read`);
|
|
897
2094
|
const filter = { after: Math.max(0, Math.floor(Number(after) || 0)) };
|
|
898
2095
|
if (kind) filter.kind = kind;
|
|
899
2096
|
if (tags?.length) filter.tags = tags.slice(0, 20);
|
|
900
2097
|
if (lang) filter.lang = lang;
|
|
901
2098
|
if (wait) filter.wait = Math.min(25, Math.floor(wait));
|
|
902
2099
|
if (bits) filter.min_work_bits = Math.floor(bits);
|
|
2100
|
+
const unread = () => runtimeError("board_failed", `the board did not say it read scope ${held.name}, so these posts are not shown`);
|
|
903
2101
|
let page;
|
|
904
2102
|
try {
|
|
905
|
-
|
|
2103
|
+
// A board older than scopes answers 400 to the field, so it never reads the public board instead.
|
|
2104
|
+
page = await this.client.board.find(held ? { ...filter, scopeKey: held.key } : filter);
|
|
906
2105
|
} catch (error) {
|
|
907
|
-
|
|
2106
|
+
// The client believes only an answer that names the scope, and says so as an answer of 200 that is not one.
|
|
2107
|
+
if (held && error instanceof AamioError && error.status === 200) throw unread();
|
|
2108
|
+
const failure = boardError("board find", error);
|
|
2109
|
+
if (held) {
|
|
2110
|
+
failure.message = withoutScopeKey(failure.message, held.key);
|
|
2111
|
+
if (failure.fix) failure.fix = withoutScopeKey(String(failure.fix), held.key);
|
|
2112
|
+
}
|
|
2113
|
+
throw failure;
|
|
908
2114
|
}
|
|
909
2115
|
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");
|
|
2116
|
+
if (held) {
|
|
2117
|
+
// The answer names the scope it read. Without that it did not read this one.
|
|
2118
|
+
if (page.scope !== held.address) throw unread();
|
|
2119
|
+
page = withoutScopeKey(page, held.key);
|
|
2120
|
+
page.scope_name = held.name;
|
|
2121
|
+
}
|
|
910
2122
|
for (const post of page.posts) {
|
|
911
2123
|
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;
|
|
912
2124
|
}
|
|
@@ -934,11 +2146,14 @@ export class NodeRuntime {
|
|
|
934
2146
|
|
|
935
2147
|
/** A post of this home's taken down before it expires, signed by the key that posted it. */
|
|
936
2148
|
async boardWithdraw(id) {
|
|
2149
|
+
let done;
|
|
937
2150
|
try {
|
|
938
|
-
|
|
2151
|
+
done = await this.client.board.withdraw(String(id));
|
|
939
2152
|
} catch (error) {
|
|
940
2153
|
throw boardError("withdraw", error);
|
|
941
2154
|
}
|
|
2155
|
+
const archiveError = await this.archiveSafely("board", { kind: "withdrawn", at: this.now(), id: String(id) }, `the withdrawal of post ${id}`);
|
|
2156
|
+
return archiveError !== null && isObject(done) ? { ...done, archive_error: archiveError } : done;
|
|
942
2157
|
}
|
|
943
2158
|
|
|
944
2159
|
/**
|
|
@@ -948,9 +2163,15 @@ export class NodeRuntime {
|
|
|
948
2163
|
* unknown rather than failed. The board inbox outlives the post by
|
|
949
2164
|
* ANSWER_MARGIN, so the poster can answer back after reading it late.
|
|
950
2165
|
*/
|
|
951
|
-
async boardAnswer(postId, { text = null, data = null } = {}) {
|
|
952
|
-
const
|
|
953
|
-
|
|
2166
|
+
async boardAnswer(postId, { text = null, data = null, scope = null } = {}) {
|
|
2167
|
+
const scoped = scope !== null && scope !== undefined;
|
|
2168
|
+
// A post in a scope is never served by id alone, so with scope the post is looked up in that scope.
|
|
2169
|
+
const post = scoped ? await this.scopedPost(scope, postId) : await this.boardGet(postId);
|
|
2170
|
+
if (post === null) {
|
|
2171
|
+
throw runtimeError("unknown_post", scoped
|
|
2172
|
+
? `no live post with id ${postId} in scope ${scope}: it expired or was withdrawn, or it is in another scope.`
|
|
2173
|
+
: `no live post with id ${postId}: it expired or was withdrawn. A post in a scope is found with the scope's name: board answer --scope NAME, or scope over MCP.`);
|
|
2174
|
+
}
|
|
954
2175
|
if (typeof post.w !== "string" || !ADDRESS.test(post.w) || !isKey(String(post.key))) {
|
|
955
2176
|
throw runtimeError("board_failed", `post ${postId} came without a write address and a key to seal an answer to, so nothing was sent`);
|
|
956
2177
|
}
|
|
@@ -961,10 +2182,13 @@ export class NodeRuntime {
|
|
|
961
2182
|
const body = { post: post.id, reply_to: channel.w, from: this.keys.hash.slice(0, 8) };
|
|
962
2183
|
if (text !== null && text !== undefined) body.text = String(text);
|
|
963
2184
|
if (data !== null && data !== undefined) body.data = data;
|
|
2185
|
+
Object.assign(body, this.conversation(post.key));
|
|
964
2186
|
const entry = await this.outboxAdd(post.w, post.key, this.keys.seal(post.key, JSON.stringify(body)), body);
|
|
965
2187
|
const answer = await this.deliver(entry, advice);
|
|
2188
|
+
const archiveError = await this.archiveSafely("board", { kind: "answered", at: this.now(), post: post.id, w: post.w, status: answer.status, message_id: entry.id, outcome: entry.status, body }, `the answer ${entry.id}`);
|
|
966
2189
|
if (answer.status !== 201) {
|
|
967
|
-
|
|
2190
|
+
const [retryable, fix] = sendAdvice(outboxOutcome(entry), answer.status);
|
|
2191
|
+
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, trace_error: answer.traceError, archive_error: archiveError, retryable, fix });
|
|
968
2192
|
}
|
|
969
2193
|
const receipt = isObject(answer.result) ? answer.result : {};
|
|
970
2194
|
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 };
|
|
@@ -974,9 +2198,166 @@ export class NodeRuntime {
|
|
|
974
2198
|
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.";
|
|
975
2199
|
}
|
|
976
2200
|
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.";
|
|
2201
|
+
if (answer.traceError) done.trace_error = answer.traceError;
|
|
2202
|
+
if (archiveError !== null) done.archive_error = archiveError;
|
|
977
2203
|
return done;
|
|
978
2204
|
}
|
|
979
2205
|
|
|
2206
|
+
/**
|
|
2207
|
+
* Reads the board inboxes, and only those: the one posts point at now and
|
|
2208
|
+
* the older ones still open. What waits on the other channels is left where
|
|
2209
|
+
* it is, for read, and the inbox's cursor stands. With wait, the newest
|
|
2210
|
+
* board inbox is waited on, and the others are asked once.
|
|
2211
|
+
*/
|
|
2212
|
+
async boardPoll(wait = 0) {
|
|
2213
|
+
this.dropExpired();
|
|
2214
|
+
const now = this.now();
|
|
2215
|
+
const boards = this.state.channels.filter((channel) => isObject(channel) && (channel.label === "board" || String(channel.label).startsWith("board-")) && !channel.muted && Number(channel.expire_at) > now)
|
|
2216
|
+
.sort((a, b) => (a.label === "board" ? -1 : b.label === "board" ? 1 : 0));
|
|
2217
|
+
const collected = [];
|
|
2218
|
+
for (const [index, channel] of boards.entries()) collected.push(...(await this.poll(channel, index === 0 ? Math.min(25, Math.max(0, wait)) : 0, null, null)));
|
|
2219
|
+
return collected;
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
/**
|
|
2223
|
+
* Answers to this home's posts, decrypted and verified, oldest first: what
|
|
2224
|
+
* this process read, and what the archive holds from before. A command is a
|
|
2225
|
+
* process of its own, so without the archive a run would show only what
|
|
2226
|
+
* arrived inside its own wait, though the rest was read and written down.
|
|
2227
|
+
* With postId, the answers that name that post, wherever they arrived.
|
|
2228
|
+
* Without it, everything that arrived on a board inbox and everything that
|
|
2229
|
+
* names a post. How many messages were left out is kept in
|
|
2230
|
+
* boardRepliesLeftOut.
|
|
2231
|
+
*
|
|
2232
|
+
* One delivery is one row. A row with its thread's instance is one
|
|
2233
|
+
* delivery by its address, instance and seq, and the first of its copies
|
|
2234
|
+
* stands, as it was first handed over: the one the archive has from before,
|
|
2235
|
+
* when it has one this process did not write. Any other row is told by all
|
|
2236
|
+
* it shows, and two such rows that show the same are both kept: nothing
|
|
2237
|
+
* says they are one. The rule is aamio-python's, row for row.
|
|
2238
|
+
*/
|
|
2239
|
+
async boardReplies(postId = null) {
|
|
2240
|
+
const one = (entry) => (typeof entry.instance === "string" ? [entry.w, entry.instance, entry.seq].join("\u0000") : null);
|
|
2241
|
+
const shown = (entry) => JSON.stringify([entry.w ?? null, entry.seq ?? null, entry.at ?? null, entry.sha256 ?? null, entry.from_key ?? null]);
|
|
2242
|
+
const wanted = (entry) => {
|
|
2243
|
+
const body = entry.body;
|
|
2244
|
+
if (postId !== null && postId !== undefined) return isObject(body) && body.post === postId;
|
|
2245
|
+
// An answer names the post it answers, and most do. One that does not is
|
|
2246
|
+
// still an answer if it arrived on the address a post gave out.
|
|
2247
|
+
if (String(entry.channel ?? "").startsWith("board")) return true;
|
|
2248
|
+
return isObject(body) && typeof body.post === "string";
|
|
2249
|
+
};
|
|
2250
|
+
const count = (keys) => {
|
|
2251
|
+
const counted = new Map();
|
|
2252
|
+
for (const key of keys) if (key !== null) counted.set(key, (counted.get(key) ?? 0) + 1);
|
|
2253
|
+
return counted;
|
|
2254
|
+
};
|
|
2255
|
+
// Every channel, not only the ones named board: an answer can arrive on a
|
|
2256
|
+
// private channel opened for the conversation.
|
|
2257
|
+
const held = [];
|
|
2258
|
+
for (const channel of this.state.channels) if (isObject(channel)) held.push(...(this.received.get(channel.w) ?? []));
|
|
2259
|
+
// And every file of the archive: a board inbox is renewed while the old
|
|
2260
|
+
// one still holds answers, and the old one keeps its own label and its own
|
|
2261
|
+
// file after its channel has left this home. What an earlier run wrote is
|
|
2262
|
+
// read whether the archive is on or off now: it stays the record of what
|
|
2263
|
+
// arrived until it is pruned.
|
|
2264
|
+
const rows = [];
|
|
2265
|
+
let names = [];
|
|
2266
|
+
try {
|
|
2267
|
+
names = await archiveNames(this.home);
|
|
2268
|
+
} catch (error) {
|
|
2269
|
+
this.note("archive", "unread", `the archive could not be listed (${error.code || error.name}), so answers written down earlier are not in this result`);
|
|
2270
|
+
}
|
|
2271
|
+
for (const name of names) {
|
|
2272
|
+
try {
|
|
2273
|
+
rows.push(...(await archivedRecords(this.home, name, "received")));
|
|
2274
|
+
} catch (error) {
|
|
2275
|
+
this.note("archive", "unread", `the archive ${name}.jsonl could not be read (${error.code || error.name}), so answers written down earlier are not in this result`);
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
// A delivery the archive has more rows of than this process wrote was
|
|
2279
|
+
// written down before this process read it: that row is its first copy,
|
|
2280
|
+
// and the copies held here are later readings of it.
|
|
2281
|
+
const stored = count(rows.map(one));
|
|
2282
|
+
const written = count(held.filter((entry) => entry.archived !== false).map(one));
|
|
2283
|
+
const earlier = new Set([...stored].filter(([delivery, rowsOfIt]) => rowsOfIt > (written.get(delivery) ?? 0)).map(([delivery]) => delivery));
|
|
2284
|
+
const out = [];
|
|
2285
|
+
const skipped = [];
|
|
2286
|
+
const named = new Set();
|
|
2287
|
+
for (const entry of held) {
|
|
2288
|
+
const delivery = one(entry);
|
|
2289
|
+
if (delivery !== null) {
|
|
2290
|
+
if (named.has(delivery) || earlier.has(delivery)) continue;
|
|
2291
|
+
named.add(delivery);
|
|
2292
|
+
}
|
|
2293
|
+
(wanted(entry) ? out : skipped).push(entry);
|
|
2294
|
+
}
|
|
2295
|
+
// A copy in memory without the instance, from a service that names none,
|
|
2296
|
+
// has its row in the archive as well when it was written down, and stands
|
|
2297
|
+
// for one row there that shows the same. One that was not written down
|
|
2298
|
+
// has no row there and stands for none.
|
|
2299
|
+
const onDisk = count(held.filter((entry) => one(entry) === null && entry.archived !== false).map(shown));
|
|
2300
|
+
for (const entry of rows) {
|
|
2301
|
+
const delivery = one(entry);
|
|
2302
|
+
if (delivery !== null) {
|
|
2303
|
+
if (named.has(delivery)) continue;
|
|
2304
|
+
named.add(delivery);
|
|
2305
|
+
} else if ((onDisk.get(shown(entry)) ?? 0) > 0) {
|
|
2306
|
+
onDisk.set(shown(entry), onDisk.get(shown(entry)) - 1);
|
|
2307
|
+
continue;
|
|
2308
|
+
}
|
|
2309
|
+
if (wanted(entry)) out.push({ ...entry, from_archive: true });
|
|
2310
|
+
else skipped.push(entry);
|
|
2311
|
+
}
|
|
2312
|
+
out.sort((a, b) => (Number(a.at) || 0) - (Number(b.at) || 0) || (Number(a.seq) || 0) - (Number(b.seq) || 0));
|
|
2313
|
+
this.boardRepliesLeftOut = skipped.length;
|
|
2314
|
+
// An empty list is easily read as an empty inbox, and the reader goes
|
|
2315
|
+
// looking for the fault at the other end. Whatever this passed over is
|
|
2316
|
+
// still a message, so it says how many there are and where.
|
|
2317
|
+
if (skipped.length && !out.length) {
|
|
2318
|
+
const unopened = skipped.filter((entry) => !entry.verified).length;
|
|
2319
|
+
const unread = unopened ? `, and ${unopened} of them arrived unsigned, so the body was never opened` : "";
|
|
2320
|
+
this.note("board replies", "filtered", postId !== null && postId !== undefined
|
|
2321
|
+
? `Nothing here answers post ${postId}, but ${skipped.length} other message(s) are on your channels${unread}. Run board replies without a post, or read, to see them.`
|
|
2322
|
+
: `${skipped.length} message(s) are here and none of them looks like a board answer, because they name no post and did not arrive on a board inbox${unread}. Run read to see them.`);
|
|
2323
|
+
}
|
|
2324
|
+
return out;
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
/**
|
|
2328
|
+
* Where answers to this home's posts and answers arrive, and whether it is
|
|
2329
|
+
* still open. A board inbox that has expired is the difference between
|
|
2330
|
+
* nobody replied and nobody could.
|
|
2331
|
+
*/
|
|
2332
|
+
boardReplyAddress() {
|
|
2333
|
+
const channel = this.channel("board");
|
|
2334
|
+
if (channel === null) return { w: null, open: false, why: "No board inbox on this machine. One is opened when you answer or post." };
|
|
2335
|
+
const left = Math.floor(Number(channel.expire_at) - this.now());
|
|
2336
|
+
if (left > 0) return { w: channel.w, open: true, expires_at: Math.floor(Number(channel.expire_at)), seconds_left: left };
|
|
2337
|
+
return {
|
|
2338
|
+
w: channel.w, open: false, expires_at: Math.floor(Number(channel.expire_at)),
|
|
2339
|
+
why: `The address you answered from closed ${-left} s ago. Anything sent to it after that was refused at the door, so an empty result here does not mean nobody wrote back.`,
|
|
2340
|
+
};
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
/**
|
|
2344
|
+
* One live post of a scope, by its id, or null. A page holds up to 200 posts
|
|
2345
|
+
* and a scope can hold more: the cursor goes on until the post turns up or
|
|
2346
|
+
* the pages run out.
|
|
2347
|
+
*/
|
|
2348
|
+
async scopedPost(scope, postId) {
|
|
2349
|
+
let after = 0;
|
|
2350
|
+
for (let pages = 0; pages < 50; pages++) {
|
|
2351
|
+
const page = await this.boardFind({ scope, after });
|
|
2352
|
+
const post = page.posts.find((found) => isObject(found) && found.id === postId);
|
|
2353
|
+
if (post) return post;
|
|
2354
|
+
const next = Math.floor(Number(page.next) || 0);
|
|
2355
|
+
if (!page.posts.length || next <= after) return null;
|
|
2356
|
+
after = next;
|
|
2357
|
+
}
|
|
2358
|
+
return null;
|
|
2359
|
+
}
|
|
2360
|
+
|
|
980
2361
|
// -------------------------------------------------------------- read --
|
|
981
2362
|
|
|
982
2363
|
/** What was said, and separately what can be trusted about it. Nothing a sender writes can set a field of ours. */
|
|
@@ -994,7 +2375,10 @@ export class NodeRuntime {
|
|
|
994
2375
|
}
|
|
995
2376
|
let text;
|
|
996
2377
|
try {
|
|
997
|
-
|
|
2378
|
+
// The mark is kept: with a byte order mark in front the text is not
|
|
2379
|
+
// JSON, and aamio-python and aamio-php hand it over as text. Dropped,
|
|
2380
|
+
// this runtime alone read the rest as JSON, a channel in it included.
|
|
2381
|
+
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(this.keys.open(message.from, raw));
|
|
998
2382
|
} catch (error) {
|
|
999
2383
|
return [{ text: null }, { signed: true, encrypted: true, format: "unreadable", error: error.name || "Error" }];
|
|
1000
2384
|
}
|
|
@@ -1033,31 +2417,6 @@ export class NodeRuntime {
|
|
|
1033
2417
|
}
|
|
1034
2418
|
}
|
|
1035
2419
|
|
|
1036
|
-
/**
|
|
1037
|
-
* A scope in an incoming message, taken out before anyone reads it. Node
|
|
1038
|
-
* keeps no scopes yet, so a share is never kept here, and the key is never
|
|
1039
|
-
* handed on: aamio_scope is replaced, wherever it sits and whatever it holds,
|
|
1040
|
-
* by what aamio-python shows in its place, with why it was not kept.
|
|
1041
|
-
*/
|
|
1042
|
-
takeScopeShare(entry) {
|
|
1043
|
-
const body = entry.body;
|
|
1044
|
-
if (!isObject(body)) return;
|
|
1045
|
-
if ("aamio_scope" in body) body.aamio_scope = { kept: false, note: "not kept: a scope is shared in data.aamio_scope" };
|
|
1046
|
-
const data = body.data;
|
|
1047
|
-
if (!isObject(data) || !("aamio_scope" in data)) return;
|
|
1048
|
-
const share = data.aamio_scope;
|
|
1049
|
-
if (!isObject(share)) {
|
|
1050
|
-
data.aamio_scope = { kept: false, note: "not kept: data.aamio_scope is an object with name, and key or address" };
|
|
1051
|
-
return;
|
|
1052
|
-
}
|
|
1053
|
-
data.aamio_scope = {
|
|
1054
|
-
shared_as: typeof share.name === "string" ? share.name : null,
|
|
1055
|
-
can_read: share.key !== undefined && share.key !== null,
|
|
1056
|
-
kept: false,
|
|
1057
|
-
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.",
|
|
1058
|
-
};
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
2420
|
/**
|
|
1062
2421
|
* What a verified message that names an address in this field would bind: its
|
|
1063
2422
|
* signer's key to the address, once, or a conflict when the address is bound to
|
|
@@ -1086,7 +2445,6 @@ export class NodeRuntime {
|
|
|
1086
2445
|
* one before it redelivers what the stored hashes then mark as replays.
|
|
1087
2446
|
*/
|
|
1088
2447
|
async poll(channel, wait = 0, limit = null, maxBytes = null) {
|
|
1089
|
-
if (channel.muted) return [];
|
|
1090
2448
|
const before = { after: channel.after, created_at: channel.created_at, instance: channel.instance ?? null, expire_at: channel.expire_at, gone: channel.gone, seen: [...channel.seen], seen_from: [...(channel.seen_from ?? [])], peers: { ...this.state.peers } };
|
|
1091
2449
|
try {
|
|
1092
2450
|
return await this.pollOnce(channel, wait, limit, true, maxBytes);
|
|
@@ -1225,6 +2583,9 @@ export class NodeRuntime {
|
|
|
1225
2583
|
|
|
1226
2584
|
const entries = [];
|
|
1227
2585
|
const keptOut = [];
|
|
2586
|
+
// Messages signed by a key that has left the address book, on an inbox
|
|
2587
|
+
// that named it: passed by like the ones the list keeps out, and said.
|
|
2588
|
+
const withheld = [];
|
|
1228
2589
|
let lastSeq = null;
|
|
1229
2590
|
// The budget is kept here as well, for what is handed over, measured as it
|
|
1230
2591
|
// is handed over. The service counts its own form of each message, which is
|
|
@@ -1241,18 +2602,26 @@ export class NodeRuntime {
|
|
|
1241
2602
|
// replay_unknown for rows it never handed over: count 0 and no messages,
|
|
1242
2603
|
// with a note on two of them (a review of 11 November 2026).
|
|
1243
2604
|
const said = [];
|
|
2605
|
+
// What this read wrote to the archive, by delivery: kept in mind when the
|
|
2606
|
+
// cursor cannot be written, so the same rows are not written down again
|
|
2607
|
+
// when this process reads them again.
|
|
2608
|
+
const written = [];
|
|
1244
2609
|
for (const raw of data.messages) {
|
|
1245
2610
|
if (limit !== null && entries.length >= limit) break;
|
|
1246
2611
|
const entry = this.readEntry(channel, raw);
|
|
1247
2612
|
const allow = channel.allow || [];
|
|
1248
2613
|
const excluded = allow.length > 0 && !(entry.verified && (allow.includes("*") || allow.includes(entry.from_key)));
|
|
2614
|
+
const gone = !excluded && entry.verified === true && this.leftTheBook(channel, entry.from_key);
|
|
1249
2615
|
// What the message's addresses would bind is worked out before it is
|
|
1250
2616
|
// weighed, so a conflict it carries is counted with it, and bound only when
|
|
1251
2617
|
// it is taken: a conflict added after the weighing let 1146 bytes through a
|
|
1252
2618
|
// budget of 900 (a review of 10 November 2026).
|
|
1253
2619
|
const bindings = [];
|
|
1254
|
-
|
|
1255
|
-
|
|
2620
|
+
// A scope in the message: its key comes out here, before the message is
|
|
2621
|
+
// weighed, observed or shown, and goes to scopes.json or nowhere.
|
|
2622
|
+
let share = null;
|
|
2623
|
+
if (!excluded && !gone) {
|
|
2624
|
+
share = this.scopeSharePlan(entry);
|
|
1256
2625
|
if (isObject(entry.body) && entry.verified && entry.from_key) {
|
|
1257
2626
|
for (const field of ["reply_to", "channel"]) {
|
|
1258
2627
|
const planned = this.bindingFor(field, entry.body[field], entry.from_key);
|
|
@@ -1261,6 +2630,12 @@ export class NodeRuntime {
|
|
|
1261
2630
|
const conflicts = bindings.filter((planned) => planned.conflict !== null).map((planned) => planned.conflict);
|
|
1262
2631
|
if (conflicts.length) entry.binding_conflicts = conflicts;
|
|
1263
2632
|
}
|
|
2633
|
+
// The archive's mark is weighed at its longer value, false, and with
|
|
2634
|
+
// archive_off where nothing is kept: a write that fails turns true
|
|
2635
|
+
// into false after the weighing, and a budget has to hold for what is
|
|
2636
|
+
// handed over. One that is written is a byte under.
|
|
2637
|
+
entry.archived = false;
|
|
2638
|
+
if (!this.archiveEnabled) entry.archive_off = true;
|
|
1264
2639
|
if (maxBytes !== null) {
|
|
1265
2640
|
const weight = Buffer.byteLength(JSON.stringify(entry), "utf8");
|
|
1266
2641
|
if (entries.length > 0 && used + weight > maxBytes) {
|
|
@@ -1280,7 +2655,12 @@ export class NodeRuntime {
|
|
|
1280
2655
|
keptOut.push(entry.seq);
|
|
1281
2656
|
continue;
|
|
1282
2657
|
}
|
|
2658
|
+
if (gone) {
|
|
2659
|
+
withheld.push(entry.seq);
|
|
2660
|
+
continue;
|
|
2661
|
+
}
|
|
1283
2662
|
if (entry.sha256 !== null) remember(channel, entry.sha256, entry.from_key);
|
|
2663
|
+
if (share !== null) await this.scopeShareTake(entry, share, said);
|
|
1284
2664
|
for (const planned of bindings) {
|
|
1285
2665
|
if (planned.conflict === null) {
|
|
1286
2666
|
this.state.peers[planned.address] = entry.from_key;
|
|
@@ -1288,6 +2668,31 @@ export class NodeRuntime {
|
|
|
1288
2668
|
said.push(["binding_conflict", `message ${entry.seq} names ${planned.address} as ${planned.field}, signed by ${this.nameForKey(entry.from_key) ?? entry.from_key}, and that address is already bound to ${this.nameForKey(planned.conflict.bound_to) ?? planned.conflict.bound_to}. The binding is kept.`]);
|
|
1289
2669
|
}
|
|
1290
2670
|
}
|
|
2671
|
+
// Received, readable, archived and handled are four different things.
|
|
2672
|
+
// The message is written down as it is handed over, with the thread's
|
|
2673
|
+
// instance and without the archive's own mark, before the cursor is: a
|
|
2674
|
+
// process that stops between the two reads it again, and one that did
|
|
2675
|
+
// it the other way round passed it by with nothing kept. A write that
|
|
2676
|
+
// fails never costs the rest of the batch, and is said in attention,
|
|
2677
|
+
// not on the message, which has to be the size it was weighed at.
|
|
2678
|
+
const { archived: weighedAs, archive_off: off, ...row } = entry;
|
|
2679
|
+
row.kind = "received";
|
|
2680
|
+
if (typeof channel.instance === "string") row.instance = channel.instance;
|
|
2681
|
+
const delivery = this.deliveryOf(channel, entry);
|
|
2682
|
+
if (this.archiveEnabled) {
|
|
2683
|
+
if (delivery !== null && this.writtenDown.has(delivery)) {
|
|
2684
|
+
// Written down at a read whose cursor could not be saved, by this process.
|
|
2685
|
+
entry.archived = true;
|
|
2686
|
+
} else {
|
|
2687
|
+
try {
|
|
2688
|
+
entry.archived = (await this.archive(channel.label, row)) !== false;
|
|
2689
|
+
if (entry.archived && delivery !== null) written.push(delivery);
|
|
2690
|
+
} catch (error) {
|
|
2691
|
+
entry.archived = false;
|
|
2692
|
+
said.push(["archive_failed", `message ${entry.seq} was handed over and could not be written to the archive: ${error.code || error.name}: ${error.message}. The archive does not have it.`]);
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
1291
2696
|
entries.push(entry);
|
|
1292
2697
|
}
|
|
1293
2698
|
// Messages whose bytes this channel handed over before, which an earlier
|
|
@@ -1301,13 +2706,16 @@ export class NodeRuntime {
|
|
|
1301
2706
|
const openedFor = (channel.allow || []).includes("*") ? "any key, signed only" : `${channel.allow.length} named key(s)`;
|
|
1302
2707
|
said.push(["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.`]);
|
|
1303
2708
|
}
|
|
2709
|
+
if (withheld.length) {
|
|
2710
|
+
said.push(["removed_key", `${withheld.length} message(s) (seq ${withheld.join(", ")}) are signed by a key that is no longer in the address book, on an inbox that was given to it. They are not handed over and not archived. Add the key as a partner again to take what it writes from then on.`]);
|
|
2711
|
+
}
|
|
1304
2712
|
if (oversized) {
|
|
1305
2713
|
said.push(["over_budget", `message ${oversized.seq} on this channel is ${oversized.weight} bytes as handed over here, larger than the whole byte budget of ${maxBytes} this read asked for. It had already come, so it is handed over on its own rather than kept back for ever, and this read is over its budget on purpose.`]);
|
|
1306
2714
|
}
|
|
1307
2715
|
// Rows the service sent that this read left because its own limit was
|
|
1308
2716
|
// reached: a service that does not take X-Limit sends them all, and the read
|
|
1309
2717
|
// that stopped on its own said nothing (a review of 10 November 2026).
|
|
1310
|
-
const leftHere = !stoppedAtBudget && limit !== null && entries.length >= limit && data.messages.length > entries.length + keptOut.length;
|
|
2718
|
+
const leftHere = !stoppedAtBudget && limit !== null && entries.length >= limit && data.messages.length > entries.length + keptOut.length + withheld.length;
|
|
1311
2719
|
if (stoppedAtBudget) {
|
|
1312
2720
|
said.push(["more", `this read stopped at the byte budget it asked for, ${maxBytes} bytes of messages as they are handed over here, and left the rest at the service. Nothing was passed over: the cursor stands at the last message handed over, so read again for the rest.`]);
|
|
1313
2721
|
} else if (leftHere || (data.more === true && !isObject(data.too_large))) {
|
|
@@ -1328,8 +2736,28 @@ export class NodeRuntime {
|
|
|
1328
2736
|
if (lastSeq !== null) channel.after = lastSeq;
|
|
1329
2737
|
} else if (Number.isInteger(data.next)) channel.after = data.next;
|
|
1330
2738
|
else if (lastSeq !== null) channel.after = Math.max(channel.after, lastSeq);
|
|
1331
|
-
|
|
2739
|
+
try {
|
|
2740
|
+
await this.saveRead();
|
|
2741
|
+
} catch (error) {
|
|
2742
|
+
for (const delivery of written) this.writtenDown.add(delivery);
|
|
2743
|
+
throw error;
|
|
2744
|
+
}
|
|
1332
2745
|
for (const [state, what] of said) this.note(channel.label, state, what, channel.w);
|
|
2746
|
+
// The copies board replies keep, with the archive's mark and the thread's
|
|
2747
|
+
// instance, which the message handed over does not carry: one delivery is
|
|
2748
|
+
// known by its address, instance and seq in memory as it is in the archive.
|
|
2749
|
+
if (entries.length) {
|
|
2750
|
+
const held = this.received.get(channel.w) ?? [];
|
|
2751
|
+
for (const entry of entries) {
|
|
2752
|
+
held.push(typeof channel.instance === "string" ? { ...entry, instance: channel.instance } : { ...entry });
|
|
2753
|
+
this.writtenDown.delete(this.deliveryOf(channel, entry));
|
|
2754
|
+
}
|
|
2755
|
+
this.received.set(channel.w, held);
|
|
2756
|
+
}
|
|
2757
|
+
// The trace follows what was handed over: recorded after the read is
|
|
2758
|
+
// written down, in attention when it cannot be, and never on the messages,
|
|
2759
|
+
// which are the size they were weighed at.
|
|
2760
|
+
await this.traceRead(channel, entries);
|
|
1333
2761
|
return entries;
|
|
1334
2762
|
}
|
|
1335
2763
|
|
|
@@ -1429,6 +2857,10 @@ export class NodeRuntime {
|
|
|
1429
2857
|
result.anchor = { error: error.status ?? 0, detail: typeof said === "string" ? said : error.body ?? error.message };
|
|
1430
2858
|
}
|
|
1431
2859
|
}
|
|
2860
|
+
// Taken, and anchored when that was asked: a record of it that cannot be
|
|
2861
|
+
// written is said beside it.
|
|
2862
|
+
const archiveError = await this.archiveSafely(channel.label, { kind: "receipt", at: this.now(), result }, `the receipt for ${channel.label}`);
|
|
2863
|
+
if (archiveError !== null) result.archive_error = archiveError;
|
|
1432
2864
|
return result;
|
|
1433
2865
|
}
|
|
1434
2866
|
|
|
@@ -1437,6 +2869,7 @@ export class NodeRuntime {
|
|
|
1437
2869
|
const noted = this.attention.length;
|
|
1438
2870
|
await this.ensureInbox();
|
|
1439
2871
|
await this.publishPresence();
|
|
2872
|
+
this.dropExpired();
|
|
1440
2873
|
const now = this.now();
|
|
1441
2874
|
// What was read of a thread is kept for its receipt while the receipt can
|
|
1442
2875
|
// still be taken, and no longer: a server that ran for days held the rows
|
|
@@ -1445,7 +2878,8 @@ export class NodeRuntime {
|
|
|
1445
2878
|
const known = this.state.channels.find((c) => isObject(c) && c.w === w);
|
|
1446
2879
|
if (!known || Number(known.expire_at) + RECEIPT_KEEP < now) this.observed.delete(w);
|
|
1447
2880
|
}
|
|
1448
|
-
|
|
2881
|
+
// A muted inbox too: it is read until it expires, for the partners still in the book.
|
|
2882
|
+
const channels = this.state.channels.filter((c) => isObject(c) && Number(c.expire_at) > now)
|
|
1449
2883
|
.sort((a, b) => (a.label === "inbox" ? -1 : b.label === "inbox" ? 1 : 0));
|
|
1450
2884
|
const messages = [];
|
|
1451
2885
|
// A channel this read had no room left to ask is named, as aamio-python
|