aamio 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +270 -12
- package/bin/aamio.js +406 -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 +292 -0
- package/src/node-lock.js +11 -2
- package/src/node-mcp.js +202 -27
- package/src/node-runtime.js +1587 -86
- 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,11 +43,231 @@ 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}$/;
|
|
59
|
+
// One message a channel handed over, as the channel remembers it: the hash of
|
|
60
|
+
// its bytes and who signed them, the first 16 hex of sha256 of the key, or -
|
|
61
|
+
// when nobody did. The hash alone is of the text, and two signers who both sent
|
|
62
|
+
// "OK" made the second a replay of the first (a review of 10 November 2026).
|
|
63
|
+
// aamio-python and aamio-php write the same records, so a home reads the same.
|
|
64
|
+
// ? stands beside a hash an earlier version kept without its signer, once that
|
|
65
|
+
// hash is met again: who sent it then is not known, and stays not known.
|
|
66
|
+
const SEEN_FROM = /^[0-9a-f]{64}:(?:[0-9a-f]{16}|-|\?)$/;
|
|
67
|
+
|
|
68
|
+
/** The record of a message handed over: its hash, and the signer it was verified from, or -. */
|
|
69
|
+
export function seenRecord(digest, fromKey) {
|
|
70
|
+
return `${digest}:${typeof fromKey === "string" && fromKey ? sha256hex(fromKey).slice(0, 16) : "-"}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whether this channel handed these bytes over before from this signer, or
|
|
75
|
+
* unsigned when they are unsigned: true when it did, and false when it did not,
|
|
76
|
+
* since bytes it handed over from another signer make this another message.
|
|
77
|
+
* null when it cannot be told: an earlier version kept the hash without its
|
|
78
|
+
* signer, and this signer has no record of its own. Taken as a copy, another
|
|
79
|
+
* signer's first answer with the same text was passed over, and taken as new,
|
|
80
|
+
* the same signer's copy was acted on twice (a review of 11 November 2026), so
|
|
81
|
+
* it is said as it is.
|
|
82
|
+
*/
|
|
83
|
+
export function replayState(channel, digest, fromKey) {
|
|
84
|
+
if (digest === null || digest === undefined) return false;
|
|
85
|
+
const records = Array.isArray(channel.seen_from) ? channel.seen_from : [];
|
|
86
|
+
if (records.includes(seenRecord(digest, fromKey))) return true;
|
|
87
|
+
const mine = records.filter((record) => record.startsWith(digest + ":"));
|
|
88
|
+
if (mine.includes(digest + ":?") || (mine.length === 0 && channel.seen.includes(digest))) return null;
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Whether this is known to be a copy: replayState, with not known taken as no. */
|
|
93
|
+
export function isReplay(channel, digest, fromKey) {
|
|
94
|
+
return replayState(channel, digest, fromKey) === true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A message handed over: its hash, and who it came from. A hash an earlier
|
|
99
|
+
* version kept without its signer keeps that, as ?, beside the first signer
|
|
100
|
+
* recorded for it, so the next signer with the same bytes is not taken for a
|
|
101
|
+
* new one with certainty.
|
|
102
|
+
*/
|
|
103
|
+
export function remember(channel, digest, fromKey) {
|
|
104
|
+
if (!Array.isArray(channel.seen_from)) channel.seen_from = [];
|
|
105
|
+
if (channel.seen.includes(digest) && !channel.seen_from.some((record) => record.startsWith(digest + ":"))) channel.seen_from.push(digest + ":?");
|
|
106
|
+
if (!channel.seen.includes(digest)) channel.seen.push(digest);
|
|
107
|
+
const record = seenRecord(digest, fromKey);
|
|
108
|
+
if (!channel.seen_from.includes(record)) channel.seen_from.push(record);
|
|
109
|
+
}
|
|
45
110
|
const ADDRESS = /^[a-z2-7]{20}$/;
|
|
46
|
-
|
|
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
|
+
}
|
|
47
271
|
|
|
48
272
|
/** A partner's name and key as partnerAdd takes them, checked where nothing has been opened yet. Throws invalid_name or invalid_key. */
|
|
49
273
|
export function checkPartner(name, key) {
|
|
@@ -115,13 +339,24 @@ function messageShape(body, envelope) {
|
|
|
115
339
|
return shape;
|
|
116
340
|
}
|
|
117
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
|
+
|
|
118
347
|
export class NodeRuntime {
|
|
119
348
|
/** Takes the home, reads its key and its files. Close it to let go. */
|
|
120
|
-
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 } = {}) {
|
|
121
350
|
const owner = await takeHome(home, { host });
|
|
122
351
|
try {
|
|
123
|
-
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 });
|
|
124
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 || "");
|
|
125
360
|
return runtime;
|
|
126
361
|
} catch (error) {
|
|
127
362
|
await owner.release();
|
|
@@ -129,8 +364,23 @@ export class NodeRuntime {
|
|
|
129
364
|
}
|
|
130
365
|
}
|
|
131
366
|
|
|
132
|
-
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 }) {
|
|
133
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();
|
|
134
384
|
this.host = host.replace(/\/+$/, "");
|
|
135
385
|
// Where a receipt's root is anchored, as aamio-python reads it: AAMIO_VERIFYUM.
|
|
136
386
|
this.verifyum = String(verifyum).replace(/\/+$/, "");
|
|
@@ -152,6 +402,13 @@ export class NodeRuntime {
|
|
|
152
402
|
this.untold = new Set();
|
|
153
403
|
// Files whose folder could not be synced after the rename, said once until it can.
|
|
154
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 = [];
|
|
155
412
|
}
|
|
156
413
|
|
|
157
414
|
/**
|
|
@@ -183,6 +440,8 @@ export class NodeRuntime {
|
|
|
183
440
|
try {
|
|
184
441
|
// Fatal: a byte that is not UTF-8 makes the file unreadable. Read with
|
|
185
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.
|
|
186
445
|
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
187
446
|
} catch {
|
|
188
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.`);
|
|
@@ -200,6 +459,10 @@ export class NodeRuntime {
|
|
|
200
459
|
|
|
201
460
|
async load() {
|
|
202
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`);
|
|
203
466
|
this.state = await this.loadJson("state.json", {}, isObject);
|
|
204
467
|
this.outbox = await this.loadJson("outbox.json", {}, isObject);
|
|
205
468
|
if (!Array.isArray(this.state.tags)) this.state.tags = [];
|
|
@@ -208,12 +471,156 @@ export class NodeRuntime {
|
|
|
208
471
|
for (const channel of this.state.channels) {
|
|
209
472
|
if (!isObject(channel)) continue;
|
|
210
473
|
channel.seen = Array.isArray(channel.seen) ? channel.seen.filter((value) => typeof value === "string" && HASH.test(value)) : [];
|
|
474
|
+
channel.seen_from = Array.isArray(channel.seen_from) ? channel.seen_from.filter((value) => typeof value === "string" && SEEN_FROM.test(value)) : [];
|
|
211
475
|
channel.allow = Array.isArray(channel.allow) ? channel.allow : [];
|
|
212
476
|
if (!Number.isInteger(channel.after)) channel.after = 0;
|
|
213
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();
|
|
214
483
|
await this.settleInterrupted();
|
|
215
484
|
}
|
|
216
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
|
+
|
|
217
624
|
/**
|
|
218
625
|
* What a process that stopped left in the outbox, settled by the rules
|
|
219
626
|
* aamio-python uses, so a shared home says the same in both. A send in
|
|
@@ -249,7 +656,13 @@ export class NodeRuntime {
|
|
|
249
656
|
: "the process stopped before its proof of work was done, so nothing was sent";
|
|
250
657
|
changed = true;
|
|
251
658
|
}
|
|
252
|
-
|
|
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) {
|
|
253
666
|
entry.ever_open = true;
|
|
254
667
|
changed = true;
|
|
255
668
|
}
|
|
@@ -299,7 +712,9 @@ export class NodeRuntime {
|
|
|
299
712
|
// message was never handed over at all (a review of 9 November 2026).
|
|
300
713
|
if (!this.unsynced.has(name)) {
|
|
301
714
|
this.unsynced.add(name);
|
|
302
|
-
|
|
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" : ""}.`);
|
|
303
718
|
}
|
|
304
719
|
}
|
|
305
720
|
}
|
|
@@ -323,6 +738,20 @@ export class NodeRuntime {
|
|
|
323
738
|
return this.saveJson("outbox.json", this.outbox);
|
|
324
739
|
}
|
|
325
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
|
+
|
|
326
755
|
async close() {
|
|
327
756
|
if (this.closed) return;
|
|
328
757
|
this.closed = true;
|
|
@@ -335,6 +764,24 @@ export class NodeRuntime {
|
|
|
335
764
|
return this.partnersFile.filter((p) => isObject(p) && typeof p.name === "string" && typeof p.key === "string");
|
|
336
765
|
}
|
|
337
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
|
+
|
|
338
785
|
/**
|
|
339
786
|
* The partner with this name: the one so named exactly, or the only one so
|
|
340
787
|
* named apart from upper and lower case. Two of those, and no exact one, is
|
|
@@ -374,11 +821,242 @@ export class NodeRuntime {
|
|
|
374
821
|
// release check of 9 November 2026).
|
|
375
822
|
const next = this.partnersFile.filter((p) => !(isObject(p) && (p.name === name || p.key === key)));
|
|
376
823
|
next.push({ name, key });
|
|
377
|
-
await this.
|
|
378
|
-
this.partnersFile = next;
|
|
824
|
+
await this.savePartners(next);
|
|
379
825
|
return this.inboxAfterChange(`partner ${name} was added`, { partner: name, key });
|
|
380
826
|
}
|
|
381
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
|
+
|
|
382
1060
|
// ------------------------------------------------------------- inbox --
|
|
383
1061
|
|
|
384
1062
|
channel(label) {
|
|
@@ -431,7 +1109,7 @@ export class NodeRuntime {
|
|
|
431
1109
|
return taken;
|
|
432
1110
|
}
|
|
433
1111
|
|
|
434
|
-
/** 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. */
|
|
435
1113
|
async rotateInbox(reason, { caller = false } = {}) {
|
|
436
1114
|
const old = this.channel("inbox");
|
|
437
1115
|
let wanted = this.wantedAllow();
|
|
@@ -452,7 +1130,7 @@ export class NodeRuntime {
|
|
|
452
1130
|
const before = this.state.channels.map((channel) => (isObject(channel) ? [channel, channel.label, channel.muted] : null));
|
|
453
1131
|
const noted = this.attention.length;
|
|
454
1132
|
if (old) old.label = this.freeLabel(`inbox-${old.expire_at}`);
|
|
455
|
-
const inbox = { label: "inbox", read_key: thread.id, w: thread.w, expire_at: thread.expireAt, allow: wanted ?? [], after: 0, created_at: null, instance: null, gone: false, muted: false, seen: [] };
|
|
1133
|
+
const inbox = { label: "inbox", read_key: thread.id, w: thread.w, expire_at: thread.expireAt, allow: wanted ?? [], after: 0, created_at: null, instance: null, gone: false, muted: false, seen: [], seen_from: [] };
|
|
456
1134
|
this.state.channels.push(inbox);
|
|
457
1135
|
const muted = this.muteGenerations(reason);
|
|
458
1136
|
try {
|
|
@@ -485,7 +1163,14 @@ export class NodeRuntime {
|
|
|
485
1163
|
return outcome;
|
|
486
1164
|
}
|
|
487
1165
|
|
|
488
|
-
/**
|
|
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
|
+
*/
|
|
489
1174
|
muteGenerations(reason) {
|
|
490
1175
|
const partners = new Set(this.partners.map((p) => p.key));
|
|
491
1176
|
const muted = [];
|
|
@@ -495,11 +1180,24 @@ export class NodeRuntime {
|
|
|
495
1180
|
if (!gone.length) continue;
|
|
496
1181
|
channel.muted = true;
|
|
497
1182
|
muted.push(channel.w);
|
|
498
|
-
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);
|
|
499
1184
|
}
|
|
500
1185
|
return muted;
|
|
501
1186
|
}
|
|
502
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
|
+
|
|
503
1201
|
/** The inbox, opened again when it is gone, about to expire, or no longer matches the address book. */
|
|
504
1202
|
async ensureInbox() {
|
|
505
1203
|
const inbox = this.channel("inbox");
|
|
@@ -614,7 +1312,7 @@ export class NodeRuntime {
|
|
|
614
1312
|
// -------------------------------------------------------------- send --
|
|
615
1313
|
|
|
616
1314
|
/** One message, signed and sealed to the recipient, written to the outbox before it goes. */
|
|
617
|
-
async send(to, { text = null, data = null, re = null } = {}) {
|
|
1315
|
+
async send(to, { text = null, data = null, re = null, archivedData = null } = {}) {
|
|
618
1316
|
checkRe(re);
|
|
619
1317
|
const { w, key, partner } = await this.recipient(to);
|
|
620
1318
|
// What the inbox asks of writers is read before anything is stored: a
|
|
@@ -627,7 +1325,7 @@ export class NodeRuntime {
|
|
|
627
1325
|
const body = { from: this.keys.hash.slice(0, 8), reply_to: inbox.w };
|
|
628
1326
|
if (text !== null && text !== undefined) body.text = String(text);
|
|
629
1327
|
if (data !== null && data !== undefined) body.data = data;
|
|
630
|
-
|
|
1328
|
+
Object.assign(body, this.conversation(key, re));
|
|
631
1329
|
const plaintext = JSON.stringify(body);
|
|
632
1330
|
let entry = await this.outboxAdd(w, key, this.keys.seal(key, plaintext), body);
|
|
633
1331
|
let answer = await this.deliver(entry, advice);
|
|
@@ -640,8 +1338,19 @@ export class NodeRuntime {
|
|
|
640
1338
|
entry = await this.outboxAdd(w2, partner.key, this.keys.seal(partner.key, plaintext), body, entry.id);
|
|
641
1339
|
answer = await this.deliver(entry, advice);
|
|
642
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}`);
|
|
643
1349
|
if (answer.status !== 201) {
|
|
644
|
-
|
|
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 });
|
|
645
1354
|
}
|
|
646
1355
|
// A 201 is a delivery even when the answer carries no receipt: read field
|
|
647
1356
|
// by field, a missing one threw after the message was stored, and the
|
|
@@ -660,6 +1369,10 @@ export class NodeRuntime {
|
|
|
660
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.");
|
|
661
1370
|
}
|
|
662
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;
|
|
663
1376
|
return sent;
|
|
664
1377
|
}
|
|
665
1378
|
|
|
@@ -701,14 +1414,21 @@ export class NodeRuntime {
|
|
|
701
1414
|
|
|
702
1415
|
/** The stored bytes sent once, and what the answer allows this side to claim recorded. */
|
|
703
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;
|
|
704
1420
|
entry.attempts = (entry.attempts || 0) + 1;
|
|
705
1421
|
entry.status = "sending";
|
|
706
1422
|
try {
|
|
707
1423
|
await this.saveOutbox();
|
|
708
1424
|
} catch (error) {
|
|
709
|
-
// Nothing has left this machine, and nothing will.
|
|
1425
|
+
// Nothing has left this machine for this attempt, and nothing will.
|
|
710
1426
|
entry.status = entry.ever_open ? "unknown" : "stopped";
|
|
711
|
-
|
|
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 });
|
|
712
1432
|
}
|
|
713
1433
|
const headers = { "Content-Type": "application/json", "X-Key": this.keys.public, "X-Sig": this.keys.sign(threadSigningInput(entry.w, entry.envelope)) };
|
|
714
1434
|
let notes = [...(advice.notes || [])];
|
|
@@ -747,7 +1467,9 @@ export class NodeRuntime {
|
|
|
747
1467
|
} catch (error) {
|
|
748
1468
|
outboxError = `${error.code || error.name}: ${error.message}`;
|
|
749
1469
|
}
|
|
750
|
-
|
|
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 };
|
|
751
1473
|
}
|
|
752
1474
|
|
|
753
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. */
|
|
@@ -775,12 +1497,516 @@ export class NodeRuntime {
|
|
|
775
1497
|
entry.status = "attempted";
|
|
776
1498
|
}
|
|
777
1499
|
|
|
778
|
-
/**
|
|
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
|
+
*/
|
|
779
1505
|
outboxPending() {
|
|
780
|
-
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)))
|
|
781
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 }));
|
|
782
1508
|
}
|
|
783
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
|
+
|
|
784
2010
|
// ------------------------------------------------------------- board --
|
|
785
2011
|
//
|
|
786
2012
|
// The open board, where agents that have not met find each other. Posts are
|
|
@@ -806,7 +2032,7 @@ export class NodeRuntime {
|
|
|
806
2032
|
}
|
|
807
2033
|
const label = held?.label;
|
|
808
2034
|
if (held) held.label = this.freeLabel(`board-${held.expire_at}`);
|
|
809
|
-
const channel = { label: "board", read_key: thread.id, w: thread.w, expire_at: thread.expireAt, allow: ["*"], after: 0, created_at: null, instance: null, gone: false, muted: false, seen: [] };
|
|
2035
|
+
const channel = { label: "board", read_key: thread.id, w: thread.w, expire_at: thread.expireAt, allow: ["*"], after: 0, created_at: null, instance: null, gone: false, muted: false, seen: [], seen_from: [] };
|
|
810
2036
|
this.state.channels.push(channel);
|
|
811
2037
|
try {
|
|
812
2038
|
await this.saveState();
|
|
@@ -818,20 +2044,37 @@ export class NodeRuntime {
|
|
|
818
2044
|
return channel;
|
|
819
2045
|
}
|
|
820
2046
|
|
|
821
|
-
/**
|
|
822
|
-
|
|
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;
|
|
823
2056
|
const life = Math.floor(Number(ttl) || BOARD_TTL);
|
|
824
|
-
|
|
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);
|
|
825
2059
|
let made;
|
|
826
2060
|
try {
|
|
827
|
-
|
|
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 } });
|
|
828
2063
|
} catch (error) {
|
|
829
2064
|
throw boardError("board post", error);
|
|
830
2065
|
}
|
|
831
|
-
//
|
|
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.
|
|
832
2073
|
return {
|
|
833
|
-
post: made.post, inbox: channel.w, answers_arrive_on: "board",
|
|
834
|
-
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 } : {}),
|
|
835
2078
|
};
|
|
836
2079
|
}
|
|
837
2080
|
|
|
@@ -840,21 +2083,42 @@ export class NodeRuntime {
|
|
|
840
2083
|
* after (the cursor from the last page), wait (up to 25 s for the next one)
|
|
841
2084
|
* and min_work_bits. The address on each post is bound to its key, so a send
|
|
842
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.
|
|
843
2090
|
*/
|
|
844
|
-
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`);
|
|
845
2094
|
const filter = { after: Math.max(0, Math.floor(Number(after) || 0)) };
|
|
846
2095
|
if (kind) filter.kind = kind;
|
|
847
2096
|
if (tags?.length) filter.tags = tags.slice(0, 20);
|
|
848
2097
|
if (lang) filter.lang = lang;
|
|
849
2098
|
if (wait) filter.wait = Math.min(25, Math.floor(wait));
|
|
850
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`);
|
|
851
2101
|
let page;
|
|
852
2102
|
try {
|
|
853
|
-
|
|
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);
|
|
854
2105
|
} catch (error) {
|
|
855
|
-
|
|
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;
|
|
856
2114
|
}
|
|
857
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
|
+
}
|
|
858
2122
|
for (const post of page.posts) {
|
|
859
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;
|
|
860
2124
|
}
|
|
@@ -882,11 +2146,14 @@ export class NodeRuntime {
|
|
|
882
2146
|
|
|
883
2147
|
/** A post of this home's taken down before it expires, signed by the key that posted it. */
|
|
884
2148
|
async boardWithdraw(id) {
|
|
2149
|
+
let done;
|
|
885
2150
|
try {
|
|
886
|
-
|
|
2151
|
+
done = await this.client.board.withdraw(String(id));
|
|
887
2152
|
} catch (error) {
|
|
888
2153
|
throw boardError("withdraw", error);
|
|
889
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;
|
|
890
2157
|
}
|
|
891
2158
|
|
|
892
2159
|
/**
|
|
@@ -896,9 +2163,15 @@ export class NodeRuntime {
|
|
|
896
2163
|
* unknown rather than failed. The board inbox outlives the post by
|
|
897
2164
|
* ANSWER_MARGIN, so the poster can answer back after reading it late.
|
|
898
2165
|
*/
|
|
899
|
-
async boardAnswer(postId, { text = null, data = null } = {}) {
|
|
900
|
-
const
|
|
901
|
-
|
|
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
|
+
}
|
|
902
2175
|
if (typeof post.w !== "string" || !ADDRESS.test(post.w) || !isKey(String(post.key))) {
|
|
903
2176
|
throw runtimeError("board_failed", `post ${postId} came without a write address and a key to seal an answer to, so nothing was sent`);
|
|
904
2177
|
}
|
|
@@ -909,10 +2182,13 @@ export class NodeRuntime {
|
|
|
909
2182
|
const body = { post: post.id, reply_to: channel.w, from: this.keys.hash.slice(0, 8) };
|
|
910
2183
|
if (text !== null && text !== undefined) body.text = String(text);
|
|
911
2184
|
if (data !== null && data !== undefined) body.data = data;
|
|
2185
|
+
Object.assign(body, this.conversation(post.key));
|
|
912
2186
|
const entry = await this.outboxAdd(post.w, post.key, this.keys.seal(post.key, JSON.stringify(body)), body);
|
|
913
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}`);
|
|
914
2189
|
if (answer.status !== 201) {
|
|
915
|
-
|
|
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 });
|
|
916
2192
|
}
|
|
917
2193
|
const receipt = isObject(answer.result) ? answer.result : {};
|
|
918
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 };
|
|
@@ -922,9 +2198,166 @@ export class NodeRuntime {
|
|
|
922
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.";
|
|
923
2199
|
}
|
|
924
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;
|
|
925
2203
|
return done;
|
|
926
2204
|
}
|
|
927
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
|
+
|
|
928
2361
|
// -------------------------------------------------------------- read --
|
|
929
2362
|
|
|
930
2363
|
/** What was said, and separately what can be trusted about it. Nothing a sender writes can set a field of ours. */
|
|
@@ -942,7 +2375,10 @@ export class NodeRuntime {
|
|
|
942
2375
|
}
|
|
943
2376
|
let text;
|
|
944
2377
|
try {
|
|
945
|
-
|
|
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));
|
|
946
2382
|
} catch (error) {
|
|
947
2383
|
return [{ text: null }, { signed: true, encrypted: true, format: "unreadable", error: error.name || "Error" }];
|
|
948
2384
|
}
|
|
@@ -967,10 +2403,12 @@ export class NodeRuntime {
|
|
|
967
2403
|
const [body, meta] = this.openMessage({ ...message, verified: checked.verified, from: sender });
|
|
968
2404
|
const known = this.nameForKey(sender);
|
|
969
2405
|
const digest = checked.sha256 ?? null;
|
|
2406
|
+
const replay = replayState(channel, digest, sender);
|
|
970
2407
|
const entry = {
|
|
971
2408
|
channel: channel.label, w: channel.w, seq, at, verified: checked.verified, from_key: sender, known_contact: known !== null,
|
|
972
|
-
sender: known ?? (sender ? "unknown key" : "unsigned"), sha256: digest, replay:
|
|
2409
|
+
sender: known ?? (sender ? "unknown key" : "unsigned"), sha256: digest, replay: replay === true, body, ...meta,
|
|
973
2410
|
};
|
|
2411
|
+
if (replay === null) entry.replay_unknown = true;
|
|
974
2412
|
if (checked.whyNot) entry.unverified_because = checked.whyNot;
|
|
975
2413
|
return entry;
|
|
976
2414
|
} catch (error) {
|
|
@@ -979,31 +2417,6 @@ export class NodeRuntime {
|
|
|
979
2417
|
}
|
|
980
2418
|
}
|
|
981
2419
|
|
|
982
|
-
/**
|
|
983
|
-
* A scope in an incoming message, taken out before anyone reads it. Node
|
|
984
|
-
* keeps no scopes yet, so a share is never kept here, and the key is never
|
|
985
|
-
* handed on: aamio_scope is replaced, wherever it sits and whatever it holds,
|
|
986
|
-
* by what aamio-python shows in its place, with why it was not kept.
|
|
987
|
-
*/
|
|
988
|
-
takeScopeShare(entry) {
|
|
989
|
-
const body = entry.body;
|
|
990
|
-
if (!isObject(body)) return;
|
|
991
|
-
if ("aamio_scope" in body) body.aamio_scope = { kept: false, note: "not kept: a scope is shared in data.aamio_scope" };
|
|
992
|
-
const data = body.data;
|
|
993
|
-
if (!isObject(data) || !("aamio_scope" in data)) return;
|
|
994
|
-
const share = data.aamio_scope;
|
|
995
|
-
if (!isObject(share)) {
|
|
996
|
-
data.aamio_scope = { kept: false, note: "not kept: data.aamio_scope is an object with name, and key or address" };
|
|
997
|
-
return;
|
|
998
|
-
}
|
|
999
|
-
data.aamio_scope = {
|
|
1000
|
-
shared_as: typeof share.name === "string" ? share.name : null,
|
|
1001
|
-
can_read: share.key !== undefined && share.key !== null,
|
|
1002
|
-
kept: false,
|
|
1003
|
-
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.",
|
|
1004
|
-
};
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
2420
|
/**
|
|
1008
2421
|
* What a verified message that names an address in this field would bind: its
|
|
1009
2422
|
* signer's key to the address, once, or a conflict when the address is bound to
|
|
@@ -1032,8 +2445,7 @@ export class NodeRuntime {
|
|
|
1032
2445
|
* one before it redelivers what the stored hashes then mark as replays.
|
|
1033
2446
|
*/
|
|
1034
2447
|
async poll(channel, wait = 0, limit = null, maxBytes = null) {
|
|
1035
|
-
|
|
1036
|
-
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], peers: { ...this.state.peers } };
|
|
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 } };
|
|
1037
2449
|
try {
|
|
1038
2450
|
return await this.pollOnce(channel, wait, limit, true, maxBytes);
|
|
1039
2451
|
} catch (error) {
|
|
@@ -1041,7 +2453,7 @@ export class NodeRuntime {
|
|
|
1041
2453
|
// What this read changed is undone in memory too: the cursor, the hashes
|
|
1042
2454
|
// and the bindings stay as the file has them, and the messages wait at
|
|
1043
2455
|
// the service for the next read, which hands them over.
|
|
1044
|
-
Object.assign(channel, { after: before.after, created_at: before.created_at, instance: before.instance, expire_at: before.expire_at, gone: before.gone, seen: before.seen });
|
|
2456
|
+
Object.assign(channel, { after: before.after, created_at: before.created_at, instance: before.instance, expire_at: before.expire_at, gone: before.gone, seen: before.seen, seen_from: before.seen_from });
|
|
1045
2457
|
this.state.peers = before.peers;
|
|
1046
2458
|
this.note(channel.label, "not_saved", `what this read found here could not be written down (${error.cause?.code || error.cause?.message}), so nothing from this channel was handed over. It is still there for the next read, and nothing was skipped.`, channel.w);
|
|
1047
2459
|
return [];
|
|
@@ -1144,7 +2556,7 @@ export class NodeRuntime {
|
|
|
1144
2556
|
// is passed over by its hash, which is the hash of the text alone: another
|
|
1145
2557
|
// sender's message with the same text has it too (the same review).
|
|
1146
2558
|
if (!another && !data.reset && channel.after > 0 && typeof channel.instance !== "string" && instance !== null) {
|
|
1147
|
-
this.note(channel.label, "reread", `this channel's cursor, ${channel.after}, was kept by a client that did not keep the thread's instance, so it cannot be told whether the thread here is the one that cursor was kept on. It is read again from the start this once: what this channel handed over before comes again, marked replay, and nothing is passed over.`, channel.w);
|
|
2559
|
+
this.note(channel.label, "reread", `this channel's cursor, ${channel.after}, was kept by a client that did not keep the thread's instance, so it cannot be told whether the thread here is the one that cursor was kept on. It is read again from the start this once: what this channel handed over before comes again, marked replay, or replay_unknown where an earlier version kept it without who sent it, and nothing is passed over.`, channel.w);
|
|
1148
2560
|
channel.after = 0;
|
|
1149
2561
|
return this.pollOnce(channel, 0, limit, false, maxBytes);
|
|
1150
2562
|
}
|
|
@@ -1171,6 +2583,9 @@ export class NodeRuntime {
|
|
|
1171
2583
|
|
|
1172
2584
|
const entries = [];
|
|
1173
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 = [];
|
|
1174
2589
|
let lastSeq = null;
|
|
1175
2590
|
// The budget is kept here as well, for what is handed over, measured as it
|
|
1176
2591
|
// is handed over. The service counts its own form of each message, which is
|
|
@@ -1182,18 +2597,31 @@ export class NodeRuntime {
|
|
|
1182
2597
|
let used = 0;
|
|
1183
2598
|
let stoppedAtBudget = false;
|
|
1184
2599
|
let oversized = null;
|
|
2600
|
+
// What this read says of the messages it takes is said once the read is
|
|
2601
|
+
// written down. One that cannot be saved hands nothing over, and it said
|
|
2602
|
+
// replay_unknown for rows it never handed over: count 0 and no messages,
|
|
2603
|
+
// with a note on two of them (a review of 11 November 2026).
|
|
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 = [];
|
|
1185
2609
|
for (const raw of data.messages) {
|
|
1186
2610
|
if (limit !== null && entries.length >= limit) break;
|
|
1187
2611
|
const entry = this.readEntry(channel, raw);
|
|
1188
2612
|
const allow = channel.allow || [];
|
|
1189
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);
|
|
1190
2615
|
// What the message's addresses would bind is worked out before it is
|
|
1191
2616
|
// weighed, so a conflict it carries is counted with it, and bound only when
|
|
1192
2617
|
// it is taken: a conflict added after the weighing let 1146 bytes through a
|
|
1193
2618
|
// budget of 900 (a review of 10 November 2026).
|
|
1194
2619
|
const bindings = [];
|
|
1195
|
-
|
|
1196
|
-
|
|
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);
|
|
1197
2625
|
if (isObject(entry.body) && entry.verified && entry.from_key) {
|
|
1198
2626
|
for (const field of ["reply_to", "channel"]) {
|
|
1199
2627
|
const planned = this.bindingFor(field, entry.body[field], entry.from_key);
|
|
@@ -1202,6 +2630,12 @@ export class NodeRuntime {
|
|
|
1202
2630
|
const conflicts = bindings.filter((planned) => planned.conflict !== null).map((planned) => planned.conflict);
|
|
1203
2631
|
if (conflicts.length) entry.binding_conflicts = conflicts;
|
|
1204
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;
|
|
1205
2639
|
if (maxBytes !== null) {
|
|
1206
2640
|
const weight = Buffer.byteLength(JSON.stringify(entry), "utf8");
|
|
1207
2641
|
if (entries.length > 0 && used + weight > maxBytes) {
|
|
@@ -1221,39 +2655,79 @@ export class NodeRuntime {
|
|
|
1221
2655
|
keptOut.push(entry.seq);
|
|
1222
2656
|
continue;
|
|
1223
2657
|
}
|
|
1224
|
-
if (
|
|
2658
|
+
if (gone) {
|
|
2659
|
+
withheld.push(entry.seq);
|
|
2660
|
+
continue;
|
|
2661
|
+
}
|
|
2662
|
+
if (entry.sha256 !== null) remember(channel, entry.sha256, entry.from_key);
|
|
2663
|
+
if (share !== null) await this.scopeShareTake(entry, share, said);
|
|
1225
2664
|
for (const planned of bindings) {
|
|
1226
2665
|
if (planned.conflict === null) {
|
|
1227
2666
|
this.state.peers[planned.address] = entry.from_key;
|
|
1228
2667
|
} else {
|
|
1229
|
-
|
|
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.`]);
|
|
2669
|
+
}
|
|
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
|
+
}
|
|
1230
2694
|
}
|
|
1231
2695
|
}
|
|
1232
2696
|
entries.push(entry);
|
|
1233
2697
|
}
|
|
2698
|
+
// Messages whose bytes this channel handed over before, which an earlier
|
|
2699
|
+
// version recorded without who sent them: whether each is a copy cannot be
|
|
2700
|
+
// told, and it is said rather than guessed.
|
|
2701
|
+
const unknown = entries.filter((entry) => entry.replay_unknown).map((entry) => entry.seq);
|
|
2702
|
+
if (unknown.length) {
|
|
2703
|
+
said.push(["replay_unknown", `message(s) ${unknown.join(", ")} carry the bytes of a message this channel handed over before, which an earlier version recorded without who sent it, so whether each is a copy cannot be told: each comes with replay false and replay_unknown true.`]);
|
|
2704
|
+
}
|
|
1234
2705
|
if (keptOut.length) {
|
|
1235
2706
|
const openedFor = (channel.allow || []).includes("*") ? "any key, signed only" : `${channel.allow.length} named key(s)`;
|
|
1236
|
-
|
|
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.`]);
|
|
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.`]);
|
|
1237
2711
|
}
|
|
1238
2712
|
if (oversized) {
|
|
1239
|
-
|
|
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.`]);
|
|
1240
2714
|
}
|
|
1241
2715
|
// Rows the service sent that this read left because its own limit was
|
|
1242
2716
|
// reached: a service that does not take X-Limit sends them all, and the read
|
|
1243
2717
|
// that stopped on its own said nothing (a review of 10 November 2026).
|
|
1244
|
-
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;
|
|
1245
2719
|
if (stoppedAtBudget) {
|
|
1246
|
-
|
|
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.`]);
|
|
1247
2721
|
} else if (leftHere || (data.more === true && !isObject(data.too_large))) {
|
|
1248
2722
|
// The service says more both when the count this read asked for was
|
|
1249
2723
|
// reached and when the byte budget was. A note that blamed a budget
|
|
1250
2724
|
// nobody set would send the reader looking for the wrong cause.
|
|
1251
2725
|
const atLimit = leftHere || (limit !== null && data.messages.length >= limit);
|
|
1252
|
-
|
|
2726
|
+
said.push(["more", atLimit
|
|
1253
2727
|
? "this read handed over as many messages as its limit allows, and the service has more waiting on this channel. Nothing was passed over: the cursor stands at the last message handed over, so read again for the rest."
|
|
1254
2728
|
: maxBytes !== null
|
|
1255
2729
|
? "the service had more waiting on this channel than the byte budget this read asked for, so it sent what fits and kept the rest. Read again for it: the cursor stands at the last message handed over."
|
|
1256
|
-
: "the service says it has more waiting on this channel than it sent. Nothing was passed over: the cursor stands at the last message handed over, so read again for the rest."
|
|
2730
|
+
: "the service says it has more waiting on this channel than it sent. Nothing was passed over: the cursor stands at the last message handed over, so read again for the rest."]);
|
|
1257
2731
|
}
|
|
1258
2732
|
// A read that stopped early moves the cursor to the last row it took and
|
|
1259
2733
|
// no further: next would skip the rest of the page.
|
|
@@ -1262,7 +2736,28 @@ export class NodeRuntime {
|
|
|
1262
2736
|
if (lastSeq !== null) channel.after = lastSeq;
|
|
1263
2737
|
} else if (Number.isInteger(data.next)) channel.after = data.next;
|
|
1264
2738
|
else if (lastSeq !== null) channel.after = Math.max(channel.after, lastSeq);
|
|
1265
|
-
|
|
2739
|
+
try {
|
|
2740
|
+
await this.saveRead();
|
|
2741
|
+
} catch (error) {
|
|
2742
|
+
for (const delivery of written) this.writtenDown.add(delivery);
|
|
2743
|
+
throw error;
|
|
2744
|
+
}
|
|
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);
|
|
1266
2761
|
return entries;
|
|
1267
2762
|
}
|
|
1268
2763
|
|
|
@@ -1362,6 +2857,10 @@ export class NodeRuntime {
|
|
|
1362
2857
|
result.anchor = { error: error.status ?? 0, detail: typeof said === "string" ? said : error.body ?? error.message };
|
|
1363
2858
|
}
|
|
1364
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;
|
|
1365
2864
|
return result;
|
|
1366
2865
|
}
|
|
1367
2866
|
|
|
@@ -1370,6 +2869,7 @@ export class NodeRuntime {
|
|
|
1370
2869
|
const noted = this.attention.length;
|
|
1371
2870
|
await this.ensureInbox();
|
|
1372
2871
|
await this.publishPresence();
|
|
2872
|
+
this.dropExpired();
|
|
1373
2873
|
const now = this.now();
|
|
1374
2874
|
// What was read of a thread is kept for its receipt while the receipt can
|
|
1375
2875
|
// still be taken, and no longer: a server that ran for days held the rows
|
|
@@ -1378,7 +2878,8 @@ export class NodeRuntime {
|
|
|
1378
2878
|
const known = this.state.channels.find((c) => isObject(c) && c.w === w);
|
|
1379
2879
|
if (!known || Number(known.expire_at) + RECEIPT_KEEP < now) this.observed.delete(w);
|
|
1380
2880
|
}
|
|
1381
|
-
|
|
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)
|
|
1382
2883
|
.sort((a, b) => (a.label === "inbox" ? -1 : b.label === "inbox" ? 1 : 0));
|
|
1383
2884
|
const messages = [];
|
|
1384
2885
|
// A channel this read had no room left to ask is named, as aamio-python
|