aamio 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,8 +31,13 @@ SHA-256 hash and its eight-character hash prefix, the inbox and when it
31
31
  expires, the service and the tags presence publishes, as aamio-python and
32
32
  aamio-php do; `init` opens no inbox, so it needs no service, and the first
33
33
  read opens one. The service is `--host URL`, else `AAMIO_HOST`, else
34
- https://aamio.at. The tags are `init --tags`, else the home's own, else
35
- `AAMIO_TAGS`. Save the home if you want to keep the identity.
34
+ https://aamio.at. `--host` moves the service and nothing else: the board is
35
+ `AAMIO_BOARD`, else https://board.aamio.at, and a receipt is anchored at
36
+ `AAMIO_VERIFYUM`, else Verifyum's MCP server. For a copy of both on your own
37
+ machine, name both before a post, as in
38
+ `AAMIO_BOARD=http://127.0.0.1:8081 aamio --host http://127.0.0.1:8080 board find`.
39
+ The tags are `init --tags`, else the home's own, else `AAMIO_TAGS`. Save the
40
+ home if you want to keep the identity.
36
41
 
37
42
  `doctor` answers three questions as the other two runtimes answer them:
38
43
  whether this client fits the service, read from `/health` and from what the
@@ -66,7 +71,7 @@ else: the message is handed over and the send is delivered, with
66
71
  `archive_failed` in `attention` or `archive_error` on the answer.
67
72
 
68
73
  To connect an MCP client, set its command to `npx` and its arguments to
69
- `["-y", "aamio@0.7.0", "--home", "/absolute/path/to/home", "serve"]`, or from a
74
+ `["-y", "aamio@0.7.2", "--home", "/absolute/path/to/home", "serve"]`, or from a
70
75
  checkout the command `node` and the arguments
71
76
  `["/absolute/path/to/aamio-js/bin/aamio.js", "--home", "/absolute/path/to/home", "serve"]`.
72
77
  The server speaks JSON-RPC over stdio and creates a new identity on first
@@ -176,9 +181,9 @@ instructions. Answer a post with `board answer POST_ID` rather than by sending
176
181
  to the address on it: the answer names the post and is sealed to the key that
177
182
  signed it, from any process. The server has the same as `aamio_board_post`,
178
183
  `aamio_board_find`, `aamio_board_answer`, `aamio_board_withdraw` and
179
- `aamio_board_tags`. Add-on commands are planned for a later milestone. The
180
- files are those aamio-python and aamio-php keep, so a home opens in any of the
181
- three.
184
+ `aamio_board_tags`. An add-on brings its own commands, under `aamio addon`,
185
+ as the section on add-ons below says. The files are those aamio-python and
186
+ aamio-php keep, so a home opens in any of the three.
182
187
 
183
188
  `board replies` reads the board inboxes, and only those, and lists the answers
184
189
  to your posts: what this run read, and what the archive holds from the runs
package/bin/aamio.js CHANGED
@@ -9,7 +9,7 @@ import { AddonError, describe, disable, enable, enabledButMissing, installWords,
9
9
  import { parseCommand, probe, runCommand, usageOf } from "../src/node-addon-process.js";
10
10
  import { archiveStatus, parsePolicy } from "../src/node-archive.js";
11
11
  import { doctor, homeCheck } from "../src/node-doctor.js";
12
- import { CHANNEL_TTL, NodeRuntime, SEND_STOPS, checkChannelTtl, checkGate, checkLabel, checkPartner, checkRe, stopFields, tagsFrom } from "../src/node-runtime.js";
12
+ import { BODY_MAX_DEPTH, CHANNEL_TTL, NodeRuntime, SEND_STOPS, checkChannelTtl, checkGate, checkLabel, checkPartner, checkRe, stopFields, tagsFrom, unfitJson } from "../src/node-runtime.js";
13
13
  import { checkAccess, checkScopeAdd, checkScopeName } from "../src/node-scopes.js";
14
14
 
15
15
  const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
@@ -70,6 +70,10 @@ An add-on is an npm package of its own, installed in the home's folder for
70
70
  add-ons, addon-packages/npm, and run in a process of its own for each call.
71
71
  The default home is AAMIO_HOME or ~/.aamio. A new home needs an existing
72
72
  parent directory. The service is --host, else AAMIO_HOST, else https://aamio.at.
73
+ --host moves the service and nothing else: the board is AAMIO_BOARD, else
74
+ https://board.aamio.at, and a receipt is anchored at AAMIO_VERIFYUM, else
75
+ Verifyum's MCP server. For a copy of both on this machine, name both, as in
76
+ AAMIO_BOARD=http://127.0.0.1:8081 aamio --host http://127.0.0.1:8080 board find
73
77
  The tags presence publishes are init --tags, else the home's own, else
74
78
  AAMIO_TAGS. With --no-archive nothing decrypted is written by this command,
75
79
  whatever the home's choice is. Run with --version to print the version.
@@ -218,11 +222,34 @@ function scopeName(value) {
218
222
 
219
223
  function jsonData(value) {
220
224
  if (value === undefined) return null;
225
+ let parsed;
221
226
  try {
222
- return JSON.parse(value);
227
+ parsed = JSON.parse(value);
223
228
  } catch {
224
229
  throw Object.assign(new Error("--data is not JSON. Give a JSON value, quoted for the shell."), { code: "usage" });
225
230
  }
231
+ return holdable(parsed, "--data", BODY_MAX_DEPTH - 1);
232
+ }
233
+
234
+ /**
235
+ * A JSON option every reader can hold, or the usage error that says why not:
236
+ * a number that is not finite, as 1e999 is, which JSON.parse makes Infinity
237
+ * and JSON.stringify sends as null, and --data nested so deep that the
238
+ * message around it passes BODY_MAX_DEPTH, which a reader hands over as text.
239
+ * The three runtimes refuse both alike (a security review of 13 November 2026).
240
+ */
241
+ function holdable(parsed, option, limit) {
242
+ const unreadable = unfitJson(parsed, limit);
243
+ if (unreadable && unreadable[1] === "half a pair") {
244
+ throw Object.assign(new Error(`${option} holds half of a UTF-16 surrogate pair, as "\\ud800" is: JSON can write it, and UTF-8 cannot carry it.`), { code: "usage" });
245
+ }
246
+ if (unreadable && unreadable[1] === "not finite") {
247
+ throw Object.assign(new Error(`${option} holds a number that is not finite, as 1e999 is: JSON can write it, and nothing that reads it can hold it.`), { code: "usage" });
248
+ }
249
+ if (unreadable) {
250
+ throw Object.assign(new Error(`${option} is nested too deep: with the message around it, it is deeper than ${BODY_MAX_DEPTH}, and a reader hands such a message over as text.`), { code: "usage" });
251
+ }
252
+ return parsed;
226
253
  }
227
254
 
228
255
  /**
@@ -345,6 +372,7 @@ function plan(command, positional, options) {
345
372
  } catch {
346
373
  throw wrong("--gate is not JSON. Quote it as one argument, as in --gate '{\"require\": {\"pow\": {\"bits\": 20}}}'.");
347
374
  }
375
+ holdable(gate, "--gate", 1000000);
348
376
  usage(() => checkGate(gate));
349
377
  }
350
378
  return (runtime) => runtime.channelOpen(first, { ttl, allow, gate });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aamio",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "mcpName": "at.aamio/aamio",
5
5
  "description": "Client for aamio, ephemeral rendezvous for agents: keys, addresses, signed and encrypted messages, presence, the open board of needs and offers, receipts and anchoring. Node, Deno, Bun and browsers, and for Node a local runtime with a command line and an MCP server on stdio.",
6
6
  "type": "module",
@@ -132,14 +132,17 @@ export function parsePolicy(text, maxMb = null) {
132
132
 
133
133
  /** One sentence a person or a model can act on. The words are aamio-python's. */
134
134
  export function describePolicy(policy) {
135
- const size = policy.max_mb ? ` and never more than ${policy.max_mb} MB, oldest first out` : "";
135
+ // The ceiling holds each time the archive is tidied, not at every write:
136
+ // under one of 128 bytes a row of 415 stayed until the next tidying, where
137
+ // this said never more than (the fifth release check of 14 November 2026).
138
+ const size = policy.max_mb ? ` Each time it is tidied, when a runtime starts and at most once an hour after, the oldest go first until it holds no more than ${policy.max_mb} MB, so in between it can hold more.` : "";
136
139
  if (policy.mode === "off") {
137
140
  return "Nothing decrypted is written to this machine. The key, the read keys of open threads and the outbox with sealed bytes still are, since the runtime cannot work without them.";
138
141
  }
139
142
  if (policy.mode === "days") {
140
- return `Decrypted messages and receipts are kept in archive/ for ${policy.days} days${size}, then removed. The service itself keeps nothing past a thread's expiry: this archive is yours, on this machine.`;
143
+ return `Decrypted messages and receipts are kept in archive/ for ${policy.days} days, then removed.${size} The service itself keeps nothing past a thread's expiry: this archive is yours, on this machine.`;
141
144
  }
142
- return `Decrypted messages and receipts are kept in archive/ until you remove them${size}. The service itself keeps nothing past a thread's expiry: this archive is yours, on this machine. \`aamio archive days:30\` gives it a lifetime, \`aamio archive off\` stops it.`;
145
+ return `Decrypted messages and receipts are kept in archive/ until you remove them.${size} The service itself keeps nothing past a thread's expiry: this archive is yours, on this machine. \`aamio archive days:30\` gives it a lifetime, \`aamio archive off\` stops it.`;
143
146
  }
144
147
 
145
148
  /** After a rename the new name is on disk only when the folder is. Windows has no such call for a folder, and journals the rename. */
@@ -265,15 +265,37 @@ async function ask(runtime, path) {
265
265
  }
266
266
  }
267
267
 
268
+ /**
269
+ * What /health said when it is not aamio's health answer, an object with
270
+ * "service": "aamio" and "status": "ok" as the service gives it, and null when
271
+ * it is. A 200 from a proxy or a login page in front of the host was said as
272
+ * healthy, beside "answered 200 and not 200" (a check of 13 November 2026).
273
+ */
274
+ function notHealth(body) {
275
+ if (body === null || body === "") return "an empty body";
276
+ if (typeof body === "string") return "a body that is not JSON";
277
+ if (!isObject(body) || body.service !== "aamio" || body.status !== "ok") return "JSON that is not aamio's health answer";
278
+ return null;
279
+ }
280
+
268
281
  /** One answer to three questions: does this client fit the service, who can read the home, what is kept. */
269
282
  export async function doctor(runtime, version) {
270
283
  const health = await ask(runtime, "/health");
271
- const service = { host: runtime.host, reachable: health.status === 200 };
272
- if (health.status === 200 && isObject(health.body)) {
284
+ // Whether the service answered and whether it is well are two things, and
285
+ // both apart from whether this client fits it. A 503 from /health was said
286
+ // as no answer, beside a fit read from the descriptor, which had answered
287
+ // (a check of 13 November 2026). As aamio-python and aamio-php say it.
288
+ const unlike = health.status === 200 ? notHealth(health.body) : null;
289
+ const service = { host: runtime.host, reachable: health.status > 0, healthy: health.status === 200 && unlike === null, status: health.status };
290
+ if (service.healthy) {
273
291
  service.version = health.body.version ?? null;
274
292
  service.revision = health.body.revision ?? null;
293
+ } else if (health.status === 0) {
294
+ service.problem = "no answer from /health, so the service's version and health were not read";
295
+ } else if (health.status !== 200) {
296
+ service.problem = `/health answered ${health.status} and not 200: the service is reachable and not healthy, and its version was not read`;
275
297
  } else {
276
- service.problem = `no answer from /health (${health.status}), so nothing below about the service was checked`;
298
+ service.problem = `/health answered 200 with ${unlike}, so the service's version and health were not read: check that --host names the aamio service and not a page in front of it`;
277
299
  }
278
300
  const descriptor = await ask(runtime, "/.well-known/aamio.json");
279
301
  const fits = compatCheck(descriptor.status === 200 ? descriptor.body : null);
package/src/node-mcp.js CHANGED
@@ -2,7 +2,7 @@ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { GateStop, isKey } from "./aamio.js";
4
4
  import { readIdentity } from "./node-identity.js";
5
- import { OUTBOX_NOTES, SEND_STOPS, checkGate, outboxOutcome, stopFields } from "./node-runtime.js";
5
+ import { OUTBOX_NOTES, SEND_STOPS, checkGate, outboxOutcome, stopFields, unfitJson, unfitText } from "./node-runtime.js";
6
6
  import { toolsOf } from "./node-addons.js";
7
7
  import { HOST_QUESTION, callTool } from "./node-addon-process.js";
8
8
 
@@ -510,6 +510,8 @@ async function addPartner(runtime, args, ask) {
510
510
 
511
511
  /** What a call whose arguments do not fit is told to do, in the three runtimes alike. */
512
512
  const ARGUMENTS_FIX = "Check each argument against the tool's inputSchema and call again.";
513
+ /** And one whose gate does not fit: there is no changing a gate once the channel is open. */
514
+ const GATE_FIX = "Send a gate with require, advise or both, as in {\"require\": {\"pow\": {\"bits\": 20}, \"per_key\": 5}}, or leave gate out to take writes from anyone on the allowlist.";
513
515
 
514
516
  /**
515
517
  * Arguments as the tool's schema says: an object of known names, each of its
@@ -519,22 +521,53 @@ const ARGUMENTS_FIX = "Check each argument against the tool's inputSchema and ca
519
521
  */
520
522
  function wrongArguments(tool, args) {
521
523
  if (args === undefined) args = {};
522
- if (!object(args)) return "arguments must be an object";
523
524
  const properties = tool.inputSchema.properties;
524
525
  for (const [name, value] of Object.entries(args)) {
525
- const want = properties[name];
526
- if (!want) return `${tool.name} takes no argument called ${name}`;
527
- const ok = want.type === "string" ? typeof value === "string" && (!want.pattern || new RegExp(want.pattern).test(value)) && [...value].length >= (want.minLength ?? 0) && [...value].length <= (want.maxLength ?? Infinity) && (!want.enum || want.enum.includes(value))
528
- : want.type === "integer" ? Number.isInteger(value) && value >= (want.minimum ?? -Infinity) && value <= (want.maximum ?? Infinity)
529
- : want.type === "boolean" ? typeof value === "boolean"
530
- : want.type === "object" ? object(value)
531
- : want.type === "array" ? Array.isArray(value) && value.every((item) => typeof item === "string") && value.length <= (want.maxItems ?? Infinity) : false;
532
- if (!ok) return `${name} is not what ${tool.name} takes: ${JSON.stringify(want)}`;
526
+ // Own names only: constructor or __proto__ is no argument of any tool.
527
+ if (!Object.hasOwn(properties, name)) return { path: name, message: `${tool.name} takes no argument called ${name}` };
528
+ const wrong = wrongValue(tool, name, properties[name], value);
529
+ if (wrong) return wrong;
533
530
  }
534
- for (const name of tool.inputSchema.required ?? []) if (!(name in args)) return `${tool.name} needs ${name}`;
531
+ for (const name of tool.inputSchema.required ?? []) if (!Object.hasOwn(args, name)) return { path: name, message: `${tool.name} needs ${name}` };
535
532
  return null;
536
533
  }
537
534
 
535
+ /**
536
+ * What is wrong with one value against its schema, named by its path, as the
537
+ * three runtimes say it; or null. An object whose fields the schema declares,
538
+ * as a gate's, is checked field by field: a pow of "5" bits went on to the
539
+ * service, and a field no gate has was passed over (a check of 13 November
540
+ * 2026). One that declares none, as data, takes any field.
541
+ */
542
+ function wrongValue(tool, path, want, value) {
543
+ const ok = want.type === "string" ? typeof value === "string" && (!want.pattern || new RegExp(want.pattern).test(value)) && [...value].length >= (want.minLength ?? 0) && [...value].length <= (want.maxLength ?? Infinity) && (!want.enum || want.enum.includes(value))
544
+ : want.type === "integer" ? Number.isInteger(value) && value >= (want.minimum ?? -Infinity) && value <= (want.maximum ?? Infinity)
545
+ : want.type === "boolean" ? typeof value === "boolean"
546
+ : want.type === "object" ? object(value)
547
+ : want.type === "array" ? Array.isArray(value) && value.every((item) => typeof item === "string") && value.length <= (want.maxItems ?? Infinity) : false;
548
+ if (!ok) return { path, message: `${path} is not what ${tool.name} takes: ${JSON.stringify(want)}` };
549
+ if (want.type === "object" && object(want.properties)) {
550
+ for (const [key, inner] of Object.entries(value)) {
551
+ if (!Object.hasOwn(want.properties, key)) {
552
+ if (want.additionalProperties === false) return { path: `${path}.${key}`, message: `${path} has no field called ${key}` };
553
+ continue;
554
+ }
555
+ const wrong = wrongValue(tool, `${path}.${key}`, want.properties[key], inner);
556
+ if (wrong) return wrong;
557
+ }
558
+ for (const key of want.required ?? []) if (!Object.hasOwn(value, key)) return { path: `${path}.${key}`, message: `${path} needs ${key}` };
559
+ }
560
+ return null;
561
+ }
562
+
563
+ /** What does not fit, as the call's answer: inside a gate it is the gate's refusal, with its fix; anything else is invalid_arguments. */
564
+ function unfit(tool, wrong, args) {
565
+ if (tool.name === "aamio_open_channel" && (wrong.path === "gate" || wrong.path.startsWith("gate."))) {
566
+ return { error: wrong.message, error_code: "invalid_gate", fix: GATE_FIX, given: args.gate };
567
+ }
568
+ return { error: wrong.message, error_code: "invalid_arguments", fix: ARGUMENTS_FIX };
569
+ }
570
+
538
571
  /**
539
572
  * One tool of an add-on enabled in this home. The core settles what is the
540
573
  * core's, in turn with everything else that uses the home: the key behind a
@@ -562,6 +595,15 @@ async function callAddonTool(id, params, session, version, reply, complete, mani
562
595
  /** One tool of the runtime. A call and the presence timer take turns with the home. */
563
596
  async function callRuntimeTool(id, params, session, version, reply, complete) {
564
597
  const runtime = session.runtime;
598
+ // A number that is not finite, as 1e999 is, or nesting deeper than a reader
599
+ // reads as JSON, is refused before any tool runs, an add-on's included, as in
600
+ // the other two runtimes. JSON.parse made 1e999 Infinity here and it went on
601
+ // as null; in aamio-php the arguments vanished and an add-on ran with its
602
+ // defaults (a security review of 13 November 2026).
603
+ if (object(params.arguments)) {
604
+ const unreadable = unfitJson(params.arguments);
605
+ if (unreadable) return complete(toolResult({ error: unfitText(unreadable), error_code: "invalid_arguments", fix: ARGUMENTS_FIX }, true));
606
+ }
565
607
  const tool = RUNTIME_TOOLS.find((t) => t.name === params.name);
566
608
  if (!tool) {
567
609
  const manifest = session.addons.find((held) => held.tools.some((offered) => offered.name === params.name));
@@ -587,7 +629,7 @@ async function callRuntimeTool(id, params, session, version, reply, complete) {
587
629
  // of 13 November 2026).
588
630
  if (params.arguments !== undefined && !object(params.arguments)) return rpcError(id, -32602, "arguments must be an object");
589
631
  const wrong = wrongArguments(tool, params.arguments);
590
- if (wrong) return complete(toolResult({ error: wrong, error_code: "invalid_arguments", fix: ARGUMENTS_FIX }, true));
632
+ if (wrong) return complete(toolResult(unfit(tool, wrong, params.arguments ?? {}), true));
591
633
  const args = params.arguments ?? {};
592
634
  try {
593
635
  const data = await runtime.serial(async () => {
@@ -611,7 +653,7 @@ async function callRuntimeTool(id, params, session, version, reply, complete) {
611
653
  try {
612
654
  checkGate(args.gate);
613
655
  } catch (wrong) {
614
- throw new ToolRefusal({ error: wrong.message, error_code: "invalid_gate", fix: "Send a gate with require, advise or both, as in {\"require\": {\"pow\": {\"bits\": 20}, \"per_key\": 5}}, or leave gate out to take writes from anyone on the allowlist.", given: args.gate });
656
+ throw new ToolRefusal({ error: wrong.message, error_code: "invalid_gate", fix: GATE_FIX, given: args.gate });
615
657
  }
616
658
  }
617
659
  return runtime.channelOpen(args.label, { ttl: args.ttl, allow: args.allow ?? null, gate: args.gate ?? null, to: args.to ?? null, note: args.note ?? null });
@@ -1,4 +1,5 @@
1
- import { open, readFile, rename, unlink } from "node:fs/promises";
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { open, readFile, rename, stat as fsStat, unlink } from "node:fs/promises";
2
3
  import { constants } from "node:fs";
3
4
  import { randomUUID } from "node:crypto";
4
5
  import { join } from "node:path";
@@ -249,6 +250,80 @@ export function checkGate(gate) {
249
250
  return gate;
250
251
  }
251
252
 
253
+
254
+ /**
255
+ * What a body may be and still be read as JSON, in all three runtimes: every
256
+ * number finite and nested at most this deep, the outermost array or object
257
+ * counted as one. Anything else is handed over as text. A body nested 5 000
258
+ * deep made every read of its thread throw here, and the messages other
259
+ * channels had handed over in the same read, their cursors saved, were lost.
260
+ * 1e999 became Infinity, and went on as null (a security review of 13 November
261
+ * 2026).
262
+ */
263
+ export const BODY_MAX_DEPTH = 64;
264
+ // How far back from the end of a channel's archive a read looks for a delivery
265
+ // an earlier process wrote down and did not get past with its cursor.
266
+ const ARCHIVE_TAIL_BYTES = 16 * 1024 * 1024;
267
+
268
+ /** One step of a path: a member name with half a pair in it is written escaped, so the path can be printed. */
269
+ function segment(key) {
270
+ return key.isWellFormed() ? key : JSON.stringify(key).slice(1, -1);
271
+ }
272
+
273
+ /**
274
+ * [path, what] for the first part of value no reader can hold, in document
275
+ * order, or null: "not finite" for a number such as 1e999, "too deep" for
276
+ * arrays and objects nested deeper than limit, and "half a pair" for a string
277
+ * or member name with half of a UTF-16 surrogate pair in it, as "\\ud800"
278
+ * decodes to: JSON can write it and UTF-8 cannot carry it, so it went out as
279
+ * U+FFFD in its place, and aamio-php reads no such body as JSON (the release
280
+ * check of 14 November 2026). The path names keys and indices with dots, and
281
+ * is "" for value itself.
282
+ */
283
+ export function unfitJson(value, limit = BODY_MAX_DEPTH) {
284
+ const stack = [[value, "", 0]];
285
+ while (stack.length) {
286
+ const [node, path, level] = stack.pop();
287
+ if (typeof node === "number") {
288
+ if (!Number.isFinite(node)) return [path, "not finite"];
289
+ continue;
290
+ }
291
+ if (typeof node === "string") {
292
+ if (!node.isWellFormed()) return [path, "half a pair"];
293
+ continue;
294
+ }
295
+ if (node === null || typeof node !== "object") continue;
296
+ if (level + 1 > limit) return [path, "too deep"];
297
+ const items = Array.isArray(node) ? node.map((child, index) => [String(index), child]) : Object.entries(node);
298
+ for (const [key, child] of items.reverse()) {
299
+ const where = path ? `${path}.${segment(key)}` : segment(key);
300
+ stack.push([child, where, level + 1]);
301
+ // The member name, where it stands: before its value.
302
+ if (!Array.isArray(node)) stack.push([key, where, level + 1]);
303
+ }
304
+ }
305
+ return null;
306
+ }
307
+
308
+ /** What is wrong with an argument unfitJson found, as all three runtimes say it. */
309
+ export function unfitText([path, what]) {
310
+ const where = path || "arguments";
311
+ if (what === "not finite") return `${where} is a number that is not finite, as 1e999 is: JSON can write it, and nothing that reads it can hold it`;
312
+ if (what === "half a pair") return `${where} holds half of a UTF-16 surrogate pair, as "\\ud800" is: JSON can write it, and UTF-8 cannot carry it`;
313
+ return `${where} is nested deeper than ${BODY_MAX_DEPTH}: a reader hands a message nested that deep over as text`;
314
+ }
315
+
316
+ /** A body's JSON as { value }, or null when it is to be read as text: not JSON, a number that is not finite, or too deep. */
317
+ function bodyJson(text) {
318
+ let value;
319
+ try {
320
+ value = JSON.parse(text);
321
+ } catch {
322
+ return null;
323
+ }
324
+ return unfitJson(value) === null ? { value } : null;
325
+ }
326
+
252
327
  /**
253
328
  * A label for a channel of the caller's own. It names the channel in every
254
329
  * answer and its file in the archive, so it is made of what a file's name may
@@ -373,6 +448,12 @@ export class NodeRuntime {
373
448
  this.archivePolicy = { ...DEFAULT_POLICY };
374
449
  this.archiveEnabled = false;
375
450
  this.prunedAt = 0;
451
+ // The read an async call belongs to, how many reads are saying what the
452
+ // archive holds, and a tidying under way: tidying waits for those reads
453
+ // (the fifth release check of 14 November 2026).
454
+ this.readScope = new AsyncLocalStorage();
455
+ this.receiptWindows = 0;
456
+ this.tidying = null;
376
457
  // What this process read on each channel, by write address, as it was
377
458
  // handed over, with the thread's instance and the archive's mark: what
378
459
  // board replies lists beside the archive. In memory only, and gone with
@@ -380,7 +461,16 @@ export class NodeRuntime {
380
461
  this.received = new Map();
381
462
  // Messages the archive holds from a read whose cursor could not be
382
463
  // written: read again, they are not written down a second time.
383
- this.writtenDown = new Set();
464
+ // By delivery, the name of the archive file each went to.
465
+ this.writtenDown = new Map();
466
+ // What earlier processes wrote to each channel's archive, from the end of
467
+ // the file, by file name: read once a process.
468
+ this.archiveBefore = new Map();
469
+ // How often a delivery was handed over here with a row an earlier process
470
+ // had written: copies held here that wrote none, by board replies' key.
471
+ this.rowsReused = new Map();
472
+ // How far back from the end of a channel's archive that is read.
473
+ this.archiveTailBytes = ARCHIVE_TAIL_BYTES;
384
474
  this.host = host.replace(/\/+$/, "");
385
475
  // Where a receipt's root is anchored, as aamio-python reads it: AAMIO_VERIFYUM.
386
476
  this.verifyum = String(verifyum).replace(/\/+$/, "");
@@ -498,7 +588,7 @@ export class NodeRuntime {
498
588
  for (const channel of this.state.channels.filter(gone)) {
499
589
  this.received.delete(channel.w);
500
590
  // 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);
591
+ for (const delivery of [...this.writtenDown.keys()]) if (delivery.startsWith(channel.w + "\u0000")) this.writtenDown.delete(delivery);
502
592
  }
503
593
  this.state.channels = this.state.channels.filter((channel) => !gone(channel));
504
594
  }
@@ -511,6 +601,50 @@ export class NodeRuntime {
511
601
  // fails costs the record, and is said, and the message, the send or the
512
602
  // post it is about stands.
513
603
 
604
+ /**
605
+ * Tidies the archive when an hour has passed since it was tidied last, and
606
+ * no read is saying what the archive holds. Never within a read, and never
607
+ * while another read is between its first word on the archive and its
608
+ * return: one read() over two channels tidied between them, took the first
609
+ * channel's row and still said archived of it (the fifth release check of
610
+ * 14 November 2026). A tidying that has to wait waits for the next chance.
611
+ */
612
+ async pruneWhenDue() {
613
+ if (this.now() - this.prunedAt <= 3600) return;
614
+ if (this.readScope?.getStore() !== undefined) return;
615
+ if (this.receiptWindows > 0 || this.tidying) return;
616
+ this.tidying = this.prune().finally(() => {
617
+ this.tidying = null;
618
+ });
619
+ await this.tidying;
620
+ }
621
+
622
+ /**
623
+ * A read as its caller asked for it: it tidies first, when that is due, and
624
+ * nothing within it tidies. The channels it reads one by one, the reads
625
+ * within it and the rows it writes are one read for the archive, so what it
626
+ * says of each message is so when it returns.
627
+ */
628
+ async outerRead(read) {
629
+ if (!this.readScope || this.readScope.getStore() !== undefined) return read();
630
+ if (this.archiveEnabled && !this.closed) await this.pruneWhenDue();
631
+ const scope = { receipts: false };
632
+ try {
633
+ return await this.readScope.run(scope, read);
634
+ } finally {
635
+ if (scope.receipts) this.receiptWindows--;
636
+ }
637
+ }
638
+
639
+ /** From here until the outer read returns, what it says of the archive is to stay so: tidying waits. */
640
+ async givingReceipts() {
641
+ const scope = this.readScope?.getStore();
642
+ if (!scope || scope.receipts) return;
643
+ while (this.tidying) await this.tidying;
644
+ this.receiptWindows++;
645
+ scope.receipts = true;
646
+ }
647
+
514
648
  /** Removes what the policy no longer keeps. A failure is said, since the archive then keeps growing in plain text. */
515
649
  async prune() {
516
650
  const policy = this.archivePolicy;
@@ -518,6 +652,11 @@ export class NodeRuntime {
518
652
  this.prunedAt = this.now();
519
653
  try {
520
654
  await pruneArchive(this.home, policy, { now: this.now() });
655
+ // What the end of each file held may be gone with it, and with it a row
656
+ // this process wrote at a read whose cursor was not written: the next
657
+ // read looks in the file again.
658
+ this.archiveBefore.clear();
659
+ this.writtenDown.clear();
521
660
  } catch (error) {
522
661
  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
662
  }
@@ -530,11 +669,100 @@ export class NodeRuntime {
530
669
  */
531
670
  async archive(label, record) {
532
671
  if (!this.archiveEnabled || this.closed) return false;
672
+ // Before the row, never after it: a ceiling under one row took the row just
673
+ // written, and the read said archived (the fourth release check of 14
674
+ // November 2026). Within a read, never: the outer read tidies before it
675
+ // begins.
676
+ await this.pruneWhenDue();
533
677
  await appendRecord(this.home, archiveName(label), record);
534
- if (this.now() - this.prunedAt > 3600) await this.prune();
535
678
  return true;
536
679
  }
537
680
 
681
+ /**
682
+ * What earlier processes wrote to this channel's archive, from the end of
683
+ * the file, as deliveries. The end is enough for what an earlier process
684
+ * wrote and did not get past with its cursor, which was written last: a
685
+ * read takes at most a thread's megabyte of messages. A row further back is
686
+ * not found, and that delivery is written down again. The rows are the ones
687
+ * the archive's own reader counts, by its rules: past a byte order mark at
688
+ * the start of the file, UTF-8, JSON. The first line of the end counts when
689
+ * it starts where a line starts, and is passed by when it is the tail of a
690
+ * line cut by where the end begins.
691
+ *
692
+ * Read again when the file is not the one that was read: replaced, as a
693
+ * prune replaces it, or shorter. A row a prune had removed was still taken
694
+ * as there, and the message said archived: true with no row behind it (the
695
+ * release check of 14 November 2026).
696
+ */
697
+ async archivedBefore(label) {
698
+ const name = archiveName(label);
699
+ const path = join(this.home, "archive", name + ".jsonl");
700
+ let found = null;
701
+ try {
702
+ found = await fsStat(path, { bigint: true });
703
+ } catch {
704
+ found = null;
705
+ }
706
+ const version = found === null ? null : `${found.dev}:${found.ino}`;
707
+ const size = found === null ? 0 : Number(found.size);
708
+ const held = this.archiveBefore.get(name);
709
+ if (held && held.version === version && size >= held.size) return held.rows;
710
+ // Another file now, or a shorter one: what this process wrote to it at a
711
+ // read that did not get past its cursor is looked for in it again.
712
+ if (held) for (const [delivery, at] of [...this.writtenDown]) if (at === name) this.writtenDown.delete(delivery);
713
+ const rows = new Set();
714
+ if (found !== null) {
715
+ let handle = null;
716
+ try {
717
+ handle = await open(path, "r");
718
+ const start = Math.max(0, size - this.archiveTailBytes);
719
+ // The byte before the end says whether its first line is whole.
720
+ const from = Math.max(0, start - 1);
721
+ const buffer = Buffer.alloc(size - from);
722
+ let filled = 0;
723
+ while (filled < buffer.length) {
724
+ const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, from + filled);
725
+ if (bytesRead === 0) break;
726
+ filled += bytesRead;
727
+ }
728
+ let bytes = buffer.subarray(0, filled);
729
+ let whole = true;
730
+ if (start > 0) {
731
+ whole = bytes[0] === 0x0a;
732
+ bytes = bytes.subarray(1);
733
+ } else if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
734
+ bytes = bytes.subarray(3);
735
+ }
736
+ const lines = [];
737
+ for (let at = 0; at < bytes.length; ) {
738
+ let end = bytes.indexOf(0x0a, at);
739
+ if (end === -1) end = bytes.length;
740
+ lines.push(bytes.subarray(at, end));
741
+ at = end + 1;
742
+ }
743
+ if (!whole) lines.shift();
744
+ for (const raw of lines) {
745
+ let row;
746
+ try {
747
+ row = JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(raw));
748
+ } catch {
749
+ continue;
750
+ }
751
+ if (isObject(row) && row.kind === "received" && typeof row.w === "string" && typeof row.instance === "string" && Number.isInteger(row.seq) && typeof row.sha256 === "string") {
752
+ rows.add([row.w, row.instance, row.seq, row.sha256].join("\u0000"));
753
+ }
754
+ }
755
+ } catch {
756
+ // One that cannot be read: there is no row to use again.
757
+ rows.clear();
758
+ } finally {
759
+ await handle?.close();
760
+ }
761
+ }
762
+ this.archiveBefore.set(name, { rows, version, size });
763
+ return rows;
764
+ }
765
+
538
766
  /** The same for a record of something that is done already: what went wrong is given back, said in attention, and never thrown. */
539
767
  async archiveSafely(label, record, what) {
540
768
  try {
@@ -568,6 +796,8 @@ export class NodeRuntime {
568
796
  /** Removes what the policy no longer keeps, or with everything the whole archive, and says how much went. */
569
797
  async archivePrune({ everything = false } = {}) {
570
798
  const gone = await pruneArchive(this.home, this.archivePolicy, { now: this.now(), everything });
799
+ this.archiveBefore.clear();
800
+ this.writtenDown.clear();
571
801
  return { ...(await this.archiveStatus()), removed: gone.removed };
572
802
  }
573
803
 
@@ -2210,6 +2440,11 @@ export class NodeRuntime {
2210
2440
  * board inbox is waited on, and the others are asked once.
2211
2441
  */
2212
2442
  async boardPoll(wait = 0) {
2443
+ return this.outerRead(() => this.boardPollHeld(wait));
2444
+ }
2445
+
2446
+ /** boardPoll, as one read for the archive. */
2447
+ async boardPollHeld(wait) {
2213
2448
  this.dropExpired();
2214
2449
  const now = this.now();
2215
2450
  const boards = this.state.channels.filter((channel) => isObject(channel) && (channel.label === "board" || String(channel.label).startsWith("board-")) && !channel.muted && Number(channel.expire_at) > now)
@@ -2280,6 +2515,9 @@ export class NodeRuntime {
2280
2515
  // and the copies held here are later readings of it.
2281
2516
  const stored = count(rows.map(one));
2282
2517
  const written = count(held.filter((entry) => entry.archived !== false).map(one));
2518
+ // A copy held here whose row an earlier process wrote wrote none: it is a
2519
+ // later reading of that row (the release check of 14 November 2026).
2520
+ for (const [delivery, times] of this.rowsReused) if (written.has(delivery)) written.set(delivery, Math.max(0, written.get(delivery) - times));
2283
2521
  const earlier = new Set([...stored].filter(([delivery, rowsOfIt]) => rowsOfIt > (written.get(delivery) ?? 0)).map(([delivery]) => delivery));
2284
2522
  const out = [];
2285
2523
  const skipped = [];
@@ -2365,12 +2603,9 @@ export class NodeRuntime {
2365
2603
  const raw = message.body;
2366
2604
  if (!message.verified || !message.from) return [{ text: raw }, { signed: false, encrypted: false, format: "unsigned" }];
2367
2605
  if (!isEnvelope(raw)) {
2368
- let parsed;
2369
- try {
2370
- parsed = JSON.parse(raw);
2371
- } catch {
2372
- return [{ text: raw }, { signed: true, encrypted: false, format: "text" }];
2373
- }
2606
+ const read = bodyJson(raw);
2607
+ if (read === null) return [{ text: raw }, { signed: true, encrypted: false, format: "text" }];
2608
+ const parsed = read.value;
2374
2609
  return isObject(parsed) ? [parsed, { signed: true, encrypted: false, format: "json" }] : [{ text: raw }, { signed: true, encrypted: false, format: "json" }];
2375
2610
  }
2376
2611
  let text;
@@ -2382,12 +2617,9 @@ export class NodeRuntime {
2382
2617
  } catch (error) {
2383
2618
  return [{ text: null }, { signed: true, encrypted: true, format: "unreadable", error: error.name || "Error" }];
2384
2619
  }
2385
- let parsed;
2386
- try {
2387
- parsed = JSON.parse(text);
2388
- } catch {
2389
- return [{ text }, { signed: true, encrypted: true, format: "text" }];
2390
- }
2620
+ const read = bodyJson(text);
2621
+ if (read === null) return [{ text }, { signed: true, encrypted: true, format: "text" }];
2622
+ const parsed = read.value;
2391
2623
  return isObject(parsed) ? [parsed, { signed: true, encrypted: true, format: "json" }] : [{ text: parsed }, { signed: true, encrypted: true, format: "json" }];
2392
2624
  }
2393
2625
 
@@ -2445,21 +2677,46 @@ export class NodeRuntime {
2445
2677
  * one before it redelivers what the stored hashes then mark as replays.
2446
2678
  */
2447
2679
  async poll(channel, wait = 0, limit = null, maxBytes = null) {
2680
+ return this.outerRead(() => this.pollHeld(channel, wait, limit, maxBytes));
2681
+ }
2682
+
2683
+ /** One poll, inside the read it belongs to. */
2684
+ async pollHeld(channel, wait, limit, maxBytes) {
2448
2685
  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 } };
2449
2686
  try {
2450
2687
  return await this.pollOnce(channel, wait, limit, true, maxBytes);
2451
2688
  } catch (error) {
2452
- if (!error?.notSaved) throw error;
2453
2689
  // What this read changed is undone in memory too: the cursor, the hashes
2454
2690
  // and the bindings stay as the file has them, and the messages wait at
2455
- // the service for the next read, which hands them over.
2691
+ // the service for the next read, which hands them over. Whatever stopped
2692
+ // the read: one that threw halfway through its messages kept the hashes of
2693
+ // the ones it had taken, the next channel's save wrote them down, and the
2694
+ // next read handed those messages over marked replay, never having handed
2695
+ // them over at all (the release check of 13 November 2026).
2456
2696
  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 });
2457
2697
  this.state.peers = before.peers;
2698
+ if (!error?.notSaved) throw error;
2458
2699
  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);
2459
2700
  return [];
2460
2701
  }
2461
2702
  }
2462
2703
 
2704
+ /**
2705
+ * One channel of a read across channels, and what went wrong there said in
2706
+ * attention rather than thrown, as aamio-python and aamio-php do. One channel
2707
+ * that threw rejected the whole read, and what the channels before it had
2708
+ * handed over, their cursors already saved, was lost (a security review of
2709
+ * 13 November 2026).
2710
+ */
2711
+ async pollSafely(channel, wait, limit, maxBytes) {
2712
+ try {
2713
+ return await this.poll(channel, wait, limit, maxBytes);
2714
+ } catch (error) {
2715
+ this.note(channel.label, "unread", `this channel could not be read: ${error?.name || "Error"}. Messages from the other channels are still returned.`);
2716
+ return [];
2717
+ }
2718
+ }
2719
+
2463
2720
  async saveRead() {
2464
2721
  try {
2465
2722
  await this.saveState();
@@ -2606,6 +2863,13 @@ export class NodeRuntime {
2606
2863
  // cursor cannot be written, so the same rows are not written down again
2607
2864
  // when this process reads them again.
2608
2865
  const written = [];
2866
+ // The outer read tidied before it began, and from the first message weighed
2867
+ // for the archive nothing tidies until it returns, in this read or another:
2868
+ // what it says of the archive is so as it returns. Tidied here, channel by
2869
+ // channel, a ceiling under one row took the first channel's row when the
2870
+ // hour passed before the second (the fifth release check of 14 November
2871
+ // 2026).
2872
+ if (Array.isArray(data.messages) && data.messages.length > 0 && this.archiveEnabled && !this.closed) await this.givingReceipts();
2609
2873
  for (const raw of data.messages) {
2610
2874
  if (limit !== null && entries.length >= limit) break;
2611
2875
  const entry = this.readEntry(channel, raw);
@@ -2680,13 +2944,33 @@ export class NodeRuntime {
2680
2944
  if (typeof channel.instance === "string") row.instance = channel.instance;
2681
2945
  const delivery = this.deliveryOf(channel, entry);
2682
2946
  if (this.archiveEnabled) {
2947
+ // The file first: one that is another now, or shorter, makes this
2948
+ // process forget what it wrote to it, and that is looked for in it
2949
+ // again (the third release check of 14 November 2026).
2950
+ const before = delivery !== null && typeof channel.instance === "string" ? await this.archivedBefore(channel.label) : new Set();
2951
+ if (delivery !== null && !this.writtenDown.has(delivery) && before.has(delivery)) {
2952
+ // Written down by an earlier process that stopped before its cursor:
2953
+ // the row holds the same message and is used again, where it was
2954
+ // written a second time (the release check of 14 November 2026). A
2955
+ // row without the thread's instance, from a version before it was
2956
+ // kept, could be another delivery, so both are kept.
2957
+ this.writtenDown.set(delivery, archiveName(channel.label));
2958
+ const shown = [channel.w, channel.instance, entry.seq].join("\u0000");
2959
+ this.rowsReused.set(shown, (this.rowsReused.get(shown) ?? 0) + 1);
2960
+ }
2683
2961
  if (delivery !== null && this.writtenDown.has(delivery)) {
2684
- // Written down at a read whose cursor could not be saved, by this process.
2962
+ // Written down at a read that did not get as far as its cursor, by this process or an earlier one.
2685
2963
  entry.archived = true;
2686
2964
  } else {
2687
2965
  try {
2688
2966
  entry.archived = (await this.archive(channel.label, row)) !== false;
2689
- if (entry.archived && delivery !== null) written.push(delivery);
2967
+ if (entry.archived && delivery !== null) {
2968
+ written.push(delivery);
2969
+ // Known at once: a read that throws before its cursor is written
2970
+ // reads it again, and wrote it down a second time (the release
2971
+ // check of 14 November 2026).
2972
+ this.writtenDown.set(delivery, archiveName(channel.label));
2973
+ }
2690
2974
  } catch (error) {
2691
2975
  entry.archived = false;
2692
2976
  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.`]);
@@ -2739,7 +3023,7 @@ export class NodeRuntime {
2739
3023
  try {
2740
3024
  await this.saveRead();
2741
3025
  } catch (error) {
2742
- for (const delivery of written) this.writtenDown.add(delivery);
3026
+ for (const delivery of written) this.writtenDown.set(delivery, archiveName(channel.label));
2743
3027
  throw error;
2744
3028
  }
2745
3029
  for (const [state, what] of said) this.note(channel.label, state, what, channel.w);
@@ -2866,6 +3150,13 @@ export class NodeRuntime {
2866
3150
 
2867
3151
  /** Messages on every channel still read, the inbox first. The inbox is opened and presence published as the read starts. */
2868
3152
  async read({ wait = 0, limit = 50, maxBytes = null } = {}) {
3153
+ // The channels it reads one by one are one read for the archive: it is
3154
+ // tidied before the first, and not again until this returns.
3155
+ return this.outerRead(() => this.readHeld({ wait, limit, maxBytes }));
3156
+ }
3157
+
3158
+ /** read, as one read for the archive. */
3159
+ async readHeld({ wait, limit, maxBytes }) {
2869
3160
  const noted = this.attention.length;
2870
3161
  await this.ensureInbox();
2871
3162
  await this.publishPresence();
@@ -2896,11 +3187,11 @@ export class NodeRuntime {
2896
3187
  notAsked.push(channel);
2897
3188
  continue;
2898
3189
  }
2899
- messages.push(...(await this.poll(channel, 0, limit - messages.length, maxBytes)));
3190
+ messages.push(...(await this.pollSafely(channel, 0, limit - messages.length, maxBytes)));
2900
3191
  }
2901
3192
  const inbox = this.channel("inbox");
2902
3193
  if (!messages.length && wait > 0 && inbox && !inbox.muted) {
2903
- messages.push(...(await this.poll(inbox, wait, limit, maxBytes)));
3194
+ messages.push(...(await this.pollSafely(inbox, wait, limit, maxBytes)));
2904
3195
  // What reached the other channels during the wait comes with this
2905
3196
  // answer, as in aamio-python: only the inbox can end the wait early. It
2906
3197
  // used to wait for the next read (a review of 9 November 2026).
@@ -2910,7 +3201,7 @@ export class NodeRuntime {
2910
3201
  notAskedAfter.push(channel);
2911
3202
  continue;
2912
3203
  }
2913
- messages.push(...(await this.poll(channel, 0, limit - messages.length, maxBytes)));
3204
+ messages.push(...(await this.pollSafely(channel, 0, limit - messages.length, maxBytes)));
2914
3205
  }
2915
3206
  }
2916
3207
  for (const channel of notAsked) {