aamio 0.6.1 → 0.6.3
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 +49 -3
- package/bin/aamio.js +27 -6
- package/package.json +1 -1
- package/src/aamio.d.ts +9 -0
- package/src/aamio.js +97 -31
- package/src/node-mcp.js +18 -3
- package/src/node-runtime.js +441 -44
package/README.md
CHANGED
|
@@ -58,8 +58,17 @@ for the partners, and the open one is still read until it expires, within the
|
|
|
58
58
|
hour, as partner add says in its answer. The board inbox takes any signed key, since whoever answers
|
|
59
59
|
a post has not been met. A read publishes where the inbox is for two
|
|
60
60
|
minutes, and every message it hands over is checked on this machine: the hash,
|
|
61
|
-
the signature over this address, and the seal. A message read before
|
|
62
|
-
back marked `replay`, also after a restart
|
|
61
|
+
the signature over this address, and the seal. A message read before, the same
|
|
62
|
+
bytes from the same signer, comes back marked `replay`, also after a restart;
|
|
63
|
+
another signer's message with the same text does not. Bytes an earlier version
|
|
64
|
+
kept without who sent them come with `replay_unknown: true`, said in
|
|
65
|
+
`attention`, until their signer is recorded. Where `replay` decides what is
|
|
66
|
+
done, upgrade every client that reads one home together: an earlier version
|
|
67
|
+
that reads in between records no signer for what it hands over, and a copy from
|
|
68
|
+
that signer can then come with `replay: false`. A cursor kept without the thread's
|
|
69
|
+
instance, by aamio 0.6.1 among others, has the thread read again from the start
|
|
70
|
+
once, said in `attention` as `reread`, and what came before comes marked
|
|
71
|
+
`replay`. The server offers the same as
|
|
63
72
|
tools: `aamio_whoami`, `aamio_partners`, `aamio_partner_add`,
|
|
64
73
|
`aamio_presence_lookup`, `aamio_send`, `aamio_read` and `aamio_pending`.
|
|
65
74
|
`aamio_partner_add` takes a name from the agent and nothing else: the key comes
|
|
@@ -89,6 +98,37 @@ signed it, from any process. The server has the same as `aamio_board_post`,
|
|
|
89
98
|
later milestones. The files are those aamio-python and aamio-php keep, so a
|
|
90
99
|
home opens in any of the three.
|
|
91
100
|
|
|
101
|
+
A receipt says what a thread held: the hash, time and signer key of every
|
|
102
|
+
message, and one root. `receipt` takes it for a channel of the home, the inbox
|
|
103
|
+
unless `--channel` names another, and compares it with what this process read:
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
npx aamio --home ./alice receipt # the inbox
|
|
107
|
+
npx aamio --home ./alice receipt --channel board --anchor
|
|
108
|
+
npx aamio --home ./alice read --max-bytes 65536 # whole messages, this many bytes of them
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`root_adds_up` says the receipt's own lines hash to its root.
|
|
112
|
+
`local_root_matches` compares it with the messages this process read on that
|
|
113
|
+
thread, and is `null`, which is not a failure, when the process holds fewer than
|
|
114
|
+
the receipt counts, as a new process that has read nothing does. A thread opened
|
|
115
|
+
again at the same address, after the service restarted, is another thread, and
|
|
116
|
+
nothing read on the old one counts for it. Take the receipt in the process that
|
|
117
|
+
read, the MCP server, for the comparison. An answer that is not a receipt for
|
|
118
|
+
the channel, for another address or with a count that is not its lines, is
|
|
119
|
+
refused before anything is signed or anchored. A signer is named only
|
|
120
|
+
when this process checked the signature; a key the service alone vouches for is
|
|
121
|
+
listed in `keys_service_claim_only`. The receipt is signed with the home's key as
|
|
122
|
+
it was fetched, which records what came back, not that it is true. `--anchor`
|
|
123
|
+
publishes the root through Verifyum to Solana, which leaves the machine and
|
|
124
|
+
cannot be undone. Its idempotency key is the one aamio-python and aamio-php use,
|
|
125
|
+
so one root anchored from any of the three is one proof. The server has the
|
|
126
|
+
receipt as `aamio_receipt`. A read takes `--max-bytes`, 512 or more, and `aamio_read`
|
|
127
|
+
reads 65536 bytes per channel unless told otherwise, measured as the messages
|
|
128
|
+
are handed over: whole messages only, and what does not fit stays at the
|
|
129
|
+
service and is said in `attention`. A message larger than the budget is named
|
|
130
|
+
with its size, or, when it has already come, handed over on its own and said.
|
|
131
|
+
|
|
92
132
|
Run one server per home. On Windows and Linux, a running Node, Python or
|
|
93
133
|
Composer client that owns the home blocks a second server. Stop the owner or
|
|
94
134
|
choose a separate `--home DIR`. The operating system releases the lock if
|
|
@@ -179,7 +219,13 @@ that local policy. `keptOut` retains the reason a signature or hash failed.
|
|
|
179
219
|
Without `onKeptOut`, `listen()` appends these entries to `thread.keptOut` and
|
|
180
220
|
warns once per batch. With a callback, the caller owns that reporting and
|
|
181
221
|
retention. `onGone` and `onReset` expose lost threads and cursor resets, with
|
|
182
|
-
warnings by default. `
|
|
222
|
+
warnings by default. `open()` keeps the `instance` the service names the thread
|
|
223
|
+
by, and `listen()` reads a thread opened again at its address from the start
|
|
224
|
+
and says it once through `onReset`, in each listener of the thread. A
|
|
225
|
+
listener knows the thread by the instance it had when `listen()` was called. A cursor
|
|
226
|
+
given with a thread that has no `instance`, as one stored by an earlier
|
|
227
|
+
version, is read again from the start once in the same way, and what came
|
|
228
|
+
before it may come again. `decode(message)` without an address is legacy unchecked
|
|
183
229
|
decoding and returns `checked: false`. Use `read()` or provide the address to
|
|
184
230
|
check authorship and destination binding.
|
|
185
231
|
|
package/bin/aamio.js
CHANGED
|
@@ -17,8 +17,10 @@ const help = `Usage: aamio [--home DIR] <command>
|
|
|
17
17
|
lookup [NAME ...] Which partners are reachable now, and where
|
|
18
18
|
send TO TEXT [--data JSON] [--re SHA256]
|
|
19
19
|
Send a partner a message, signed and sealed to its key
|
|
20
|
-
read [--wait SECONDS] [--limit N]
|
|
20
|
+
read [--wait SECONDS] [--limit N] [--max-bytes N]
|
|
21
21
|
Read the inbox: every message checked here first
|
|
22
|
+
receipt [--channel LABEL] [--anchor]
|
|
23
|
+
The receipt for a channel, compared with what this process read
|
|
22
24
|
outbox pending Sends with no settled outcome on this machine
|
|
23
25
|
board post KIND TITLE TEXT [--tags a,b] [--ttl SECONDS] [--lang TAG] [--deadline ISO]
|
|
24
26
|
Put a need or an offer on the open board
|
|
@@ -43,7 +45,8 @@ const SPEC = {
|
|
|
43
45
|
"partner list": { positional: [] },
|
|
44
46
|
lookup: { positional: [], rest: true, options: ["--wait"] },
|
|
45
47
|
send: { positional: ["to", "text"], options: ["--data", "--re"] },
|
|
46
|
-
read: { positional: [], options: ["--wait", "--limit"] },
|
|
48
|
+
read: { positional: [], options: ["--wait", "--limit", "--max-bytes"] },
|
|
49
|
+
receipt: { positional: [], options: ["--channel"], switches: ["--anchor"] },
|
|
47
50
|
"outbox pending": { positional: [] },
|
|
48
51
|
"board post": { positional: ["kind", "title", "text"], options: ["--tags", "--ttl", "--lang", "--deadline"] },
|
|
49
52
|
"board find": { positional: [], options: ["--kind", "--tags", "--lang", "--after", "--wait", "--min-work-bits"] },
|
|
@@ -84,6 +87,12 @@ function parse(argv) {
|
|
|
84
87
|
ended = true;
|
|
85
88
|
} else if (!ended && arg.startsWith("--")) {
|
|
86
89
|
const [name, inline] = arg.includes("=") ? [arg.slice(0, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1)] : [arg, undefined];
|
|
90
|
+
// A switch is there or not, and takes no value: receipt --anchor.
|
|
91
|
+
if ((spec.switches ?? []).includes(name)) {
|
|
92
|
+
if (inline !== undefined) throw new Error(`${name} takes no value.`);
|
|
93
|
+
options[name.slice(2)] = true;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
87
96
|
if (!(spec.options ?? []).includes(name)) throw new Error(`${command} does not take ${name}. Run aamio --help for usage.`);
|
|
88
97
|
const value = inline ?? args[++i];
|
|
89
98
|
if (value === undefined) throw new Error(`${name} needs a value.`);
|
|
@@ -98,9 +107,14 @@ function parse(argv) {
|
|
|
98
107
|
return { command, home: resolve(home ?? (process.env.AAMIO_HOME || join(homedir(), ".aamio"))), positional, options };
|
|
99
108
|
}
|
|
100
109
|
|
|
101
|
-
function whole(value, name, low, high) {
|
|
110
|
+
function whole(value, name, low, high = null) {
|
|
102
111
|
if (value === undefined) return undefined;
|
|
103
|
-
|
|
112
|
+
// Leading zeros are the number's own business: --limit 0001 was taken before
|
|
113
|
+
// 10 November 2026 and is again. The digits are capped only to keep the
|
|
114
|
+
// number exact; the bounds are checked on its value.
|
|
115
|
+
if (!/^[0-9]{1,15}$/.test(value) || Number(value) < low || (high !== null && Number(value) > high)) {
|
|
116
|
+
throw Object.assign(new Error(high === null ? `${name} is a whole number, ${low} or more.` : `${name} is a whole number from ${low} to ${high}.`), { code: "usage" });
|
|
117
|
+
}
|
|
104
118
|
return Number(value);
|
|
105
119
|
}
|
|
106
120
|
|
|
@@ -143,12 +157,19 @@ function plan(command, positional, options) {
|
|
|
143
157
|
return (runtime) => runtime.send(first, { text: second, data, re: options.re ?? null });
|
|
144
158
|
}
|
|
145
159
|
case "read": {
|
|
146
|
-
|
|
160
|
+
// Without --max-bytes the command line asks for no byte budget, as aamio-python's does. With it,
|
|
161
|
+
// 512 or more, as over MCP: a budget above what the service takes is sent as the largest it takes.
|
|
162
|
+
const asked = { wait: whole(options.wait, "--wait", 0, 25) ?? 0, limit: whole(options.limit, "--limit", 1, 200) ?? 50, maxBytes: whole(options["max-bytes"], "--max-bytes", 512) ?? null };
|
|
147
163
|
return async (runtime) => {
|
|
148
164
|
const messages = await runtime.read(asked);
|
|
149
165
|
return { messages, count: messages.length, attention: await runtime.attentionTaken() };
|
|
150
166
|
};
|
|
151
167
|
}
|
|
168
|
+
case "receipt": {
|
|
169
|
+
const label = options.channel ?? "inbox";
|
|
170
|
+
if (!label) throw Object.assign(new Error("--channel names a channel, as a message's channel field does: inbox, board or an older generation."), { code: "usage" });
|
|
171
|
+
return (runtime) => runtime.receipt(label, { anchor: options.anchor === true });
|
|
172
|
+
}
|
|
152
173
|
case "outbox pending":
|
|
153
174
|
return (runtime) => ({ pending: runtime.outboxPending() });
|
|
154
175
|
case "board post": {
|
|
@@ -240,7 +261,7 @@ try {
|
|
|
240
261
|
} else {
|
|
241
262
|
const known = ["home_not_initialized", "key_unreadable", "home_init_failed", "home_in_use", "lock_unavailable", "state_unreadable", "invalid_name",
|
|
242
263
|
"invalid_key", "invalid_re", "unknown_partner", "ambiguous_partner", "unknown_address", "partner_offline", "too_large", "inbox_unavailable", "runtime_closed",
|
|
243
|
-
"board_failed", "board_unreachable", "board_inbox_unavailable", "unknown_post", "not_saved"];
|
|
264
|
+
"board_failed", "board_unreachable", "board_inbox_unavailable", "unknown_post", "not_saved", "unknown_channel", "receipt_failed"];
|
|
244
265
|
process.stderr.write((known.includes(error?.code) ? error.message + (error.fix ? ` ${error.fix}` : "") : "The local Node runtime could not complete this command.") + "\n");
|
|
245
266
|
process.exitCode = 1;
|
|
246
267
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aamio",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
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",
|
package/src/aamio.d.ts
CHANGED
|
@@ -97,6 +97,13 @@ export interface Thread {
|
|
|
97
97
|
id: string;
|
|
98
98
|
w: string;
|
|
99
99
|
expireAt?: number;
|
|
100
|
+
/**
|
|
101
|
+
* The instance the service named the thread by, kept by open() and by listen(). A thread opened again at
|
|
102
|
+
* this address has another one, and listen() reads it from the start and says so once, as a reset; so does
|
|
103
|
+
* a listen() given a cursor and a thread without one, as one stored by an earlier version, since that
|
|
104
|
+
* cursor cannot be checked. Absent from a service that names none.
|
|
105
|
+
*/
|
|
106
|
+
instance?: string;
|
|
100
107
|
allow?: string[];
|
|
101
108
|
/** The service's echo when it differs from the requested, locally kept list. null when absent. */
|
|
102
109
|
allowAnswered?: unknown;
|
|
@@ -146,6 +153,8 @@ export interface ReadResult {
|
|
|
146
153
|
exists: boolean;
|
|
147
154
|
created_at?: number;
|
|
148
155
|
expire_at?: number;
|
|
156
|
+
/** The thread's instance, from a service that names one: another one than before is another thread at this address. */
|
|
157
|
+
instance?: string;
|
|
149
158
|
count: number;
|
|
150
159
|
allow: string[];
|
|
151
160
|
/** Checked here: verified and from are this client's result, not the service's word. */
|
package/src/aamio.js
CHANGED
|
@@ -1191,6 +1191,9 @@ export class Aamio {
|
|
|
1191
1191
|
const body = gate ? JSON.stringify({ gate }) : undefined;
|
|
1192
1192
|
const data = await this.request("PUT", "/" + w, { headers, body });
|
|
1193
1193
|
const thread = { id, w, expireAt: data.expire_at, allow };
|
|
1194
|
+
// The service names each thread by an instance of its own, and listen()
|
|
1195
|
+
// knows the thread by it.
|
|
1196
|
+
if (typeof data.instance === "string") thread.instance = data.instance;
|
|
1194
1197
|
if (JSON.stringify(data.allow) !== JSON.stringify(allow)) thread.allowAnswered = data.allow ?? null;
|
|
1195
1198
|
if (data.gate) thread.gate = data.gate;
|
|
1196
1199
|
return thread;
|
|
@@ -1424,37 +1427,82 @@ export class Aamio {
|
|
|
1424
1427
|
* Yield messages as they arrive, long polling with wait seconds per call,
|
|
1425
1428
|
* until signal aborts or the thread answers 410 (then AamioError is thrown).
|
|
1426
1429
|
*/
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1430
|
+
listen(thread, { after = 0, wait = 25, signal, onKeptOut, onGone, onReset } = {}) {
|
|
1431
|
+
// The instance this listener last saw the thread by, its own. Two listeners
|
|
1432
|
+
// on one thread object shared the field on it: the first to see a new
|
|
1433
|
+
// thread wrote it, and the second went on past its first message (a review
|
|
1434
|
+
// of 10 November 2026). The field on the thread is kept as well, for a
|
|
1435
|
+
// caller that stores the thread. It is taken when listen() is called, not
|
|
1436
|
+
// when the first message is asked for: a generator starts at its first
|
|
1437
|
+
// next(), and a listener made before the thread changed, asked first after
|
|
1438
|
+
// another listener had written the new instance on the thread, took that
|
|
1439
|
+
// one as its own and went on past the new thread's first message (a review
|
|
1440
|
+
// of 11 November 2026).
|
|
1441
|
+
let known = typeof thread.instance === "string" ? thread.instance : null;
|
|
1442
|
+
const client = this;
|
|
1443
|
+
return (async function* () {
|
|
1444
|
+
let seq = after;
|
|
1445
|
+
while (!(signal && signal.aborted)) {
|
|
1446
|
+
const data = await client.read(thread, { after: seq, wait });
|
|
1447
|
+
// A thread is known by the instance the service names it by. One opened
|
|
1448
|
+
// again at this address, by its owner or by a write after a restart,
|
|
1449
|
+
// that already holds as many messages as the cursor is not reset by the
|
|
1450
|
+
// service, and listen went on from the old cursor past its first messages
|
|
1451
|
+
// without a word (a review of 10 November 2026). So it is read from the
|
|
1452
|
+
// start, and that is said once, as a reset. A cursor that came without
|
|
1453
|
+
// the instance, as with a thread stored by an earlier version, cannot be
|
|
1454
|
+
// checked at all, and is read again from the start once in the same way:
|
|
1455
|
+
// what came before it may come again (the same review).
|
|
1456
|
+
if (data.exists === true && typeof data.instance === "string") {
|
|
1457
|
+
if (data.instance !== known && !data.reset && seq > 0) {
|
|
1458
|
+
const what = known !== null
|
|
1459
|
+
? `the thread at this address is a new one, instance ${data.instance} where this listener knew instance ${known}, so it is read again from the start`
|
|
1460
|
+
: `this listener's cursor, ${seq}, came without the thread's instance, so it cannot be told whether the thread here is the one it was kept on. It is read again from the start this once, and messages before the cursor may come again.`;
|
|
1461
|
+
const reset = { after: seq, newest: Number.isInteger(data.count) ? data.count : 0, what };
|
|
1462
|
+
known = data.instance;
|
|
1463
|
+
thread.instance = data.instance;
|
|
1464
|
+
if (Number.isInteger(data.expire_at)) thread.expireAt = data.expire_at;
|
|
1465
|
+
thread.reset = reset;
|
|
1466
|
+
client.forgetGate(thread.w);
|
|
1467
|
+
if (typeof onReset === "function") onReset(reset);
|
|
1468
|
+
else console.warn("aamio: the thread cursor was reset", reset);
|
|
1469
|
+
seq = 0;
|
|
1470
|
+
continue;
|
|
1471
|
+
}
|
|
1472
|
+
// The thread that is there, with its own expiry: after the service read
|
|
1473
|
+
// it from the start it may expire at another time than the one opened.
|
|
1474
|
+
if (known !== data.instance && Number.isInteger(data.expire_at)) thread.expireAt = data.expire_at;
|
|
1475
|
+
known = data.instance;
|
|
1476
|
+
thread.instance = data.instance;
|
|
1437
1477
|
}
|
|
1478
|
+
// What the thread's own allowlist kept out is not yielded, and it is said.
|
|
1479
|
+
if (data.keptOut?.length) {
|
|
1480
|
+
if (typeof onKeptOut === "function") onKeptOut(data.keptOut);
|
|
1481
|
+
else {
|
|
1482
|
+
(thread.keptOut ??= []).push(...data.keptOut);
|
|
1483
|
+
console.warn("aamio: messages kept out", data.keptOut);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
if (data.exists === false) {
|
|
1487
|
+
if (typeof onGone === "function") onGone(data);
|
|
1488
|
+
else if (!thread.gone) console.warn("aamio: the thread is gone", data.note);
|
|
1489
|
+
thread.gone = true;
|
|
1490
|
+
} else if (data.exists === true) thread.gone = false;
|
|
1491
|
+
if (data.reset) {
|
|
1492
|
+
thread.reset = data.reset;
|
|
1493
|
+
client.forgetGate(thread.w);
|
|
1494
|
+
if (typeof onReset === "function") onReset(data.reset);
|
|
1495
|
+
else console.warn("aamio: the thread cursor was reset", data.reset);
|
|
1496
|
+
}
|
|
1497
|
+
for (const message of data.messages) yield message;
|
|
1498
|
+
// The service's next, not the highest seq seen. When a restart takes a
|
|
1499
|
+
// thread and a write opens a new one at the same address, the service
|
|
1500
|
+
// reads from the start and says reset, and next is lower than before.
|
|
1501
|
+
// Keeping the highest seq asked past the new thread on every call and
|
|
1502
|
+
// handed the same messages over each time.
|
|
1503
|
+
if (Number.isInteger(data.next)) seq = data.next;
|
|
1438
1504
|
}
|
|
1439
|
-
|
|
1440
|
-
if (typeof onGone === "function") onGone(data);
|
|
1441
|
-
else if (!thread.gone) console.warn("aamio: the thread is gone", data.note);
|
|
1442
|
-
thread.gone = true;
|
|
1443
|
-
} else if (data.exists === true) thread.gone = false;
|
|
1444
|
-
if (data.reset) {
|
|
1445
|
-
thread.reset = data.reset;
|
|
1446
|
-
this.forgetGate(thread.w);
|
|
1447
|
-
if (typeof onReset === "function") onReset(data.reset);
|
|
1448
|
-
else console.warn("aamio: the thread cursor was reset", data.reset);
|
|
1449
|
-
}
|
|
1450
|
-
for (const message of data.messages) yield message;
|
|
1451
|
-
// The service's next, not the highest seq seen. When a restart takes a
|
|
1452
|
-
// thread and a write opens a new one at the same address, the service
|
|
1453
|
-
// reads from the start and says reset, and next is lower than before.
|
|
1454
|
-
// Keeping the highest seq asked past the new thread on every call and
|
|
1455
|
-
// handed the same messages over each time.
|
|
1456
|
-
if (Number.isInteger(data.next)) seq = data.next;
|
|
1457
|
-
}
|
|
1505
|
+
})();
|
|
1458
1506
|
}
|
|
1459
1507
|
|
|
1460
1508
|
/** The receipt of a thread you own, with its root recomputed locally. */
|
|
@@ -1497,14 +1545,32 @@ export class Aamio {
|
|
|
1497
1545
|
headers: { "Content-Type": "application/json", Accept: "application/json", "MCP-Protocol-Version": "2025-11-25" },
|
|
1498
1546
|
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "verifyum_anchor_commitment", arguments: { commitment, idempotency_key: idem } } }),
|
|
1499
1547
|
});
|
|
1500
|
-
|
|
1548
|
+
// The status first. A 503 with a proof_id in its body is no anchoring, and a
|
|
1549
|
+
// 503 in plain text is still a 503: the body used to be read as JSON before
|
|
1550
|
+
// the status was looked at (a review of 10 November 2026).
|
|
1551
|
+
// The status came before the body, and it is the answer's even when the body
|
|
1552
|
+
// breaks off: a 503 cut short was reported as 0 (a review of 10 November 2026).
|
|
1553
|
+
let text;
|
|
1554
|
+
try {
|
|
1555
|
+
text = await response.text();
|
|
1556
|
+
} catch (error) {
|
|
1557
|
+
throw new AamioError(response.status, String(error?.message ?? error), `anchor failed: Verifyum answered ${response.status} and the answer broke off`);
|
|
1558
|
+
}
|
|
1559
|
+
let rpc = null;
|
|
1560
|
+
try {
|
|
1561
|
+
rpc = JSON.parse(text);
|
|
1562
|
+
} catch {
|
|
1563
|
+
rpc = null;
|
|
1564
|
+
}
|
|
1565
|
+
if (!response.ok) throw new AamioError(response.status, rpc ?? text, `anchor failed: Verifyum answered ${response.status}`);
|
|
1501
1566
|
let result = {};
|
|
1502
1567
|
try {
|
|
1503
1568
|
result = JSON.parse(rpc?.result?.content?.[0]?.text ?? "{}");
|
|
1504
1569
|
} catch {
|
|
1505
1570
|
result = {};
|
|
1506
1571
|
}
|
|
1507
|
-
|
|
1572
|
+
// A proof_id of blanks names no proof (a review of 10 November 2026).
|
|
1573
|
+
if (rpc === null || typeof rpc !== "object" || rpc.result?.isError || typeof result?.proof_id !== "string" || result.proof_id.trim() === "") throw new AamioError(response.status, rpc ?? text, "anchor failed");
|
|
1508
1574
|
return { proofId: result.proof_id, proofUrl: result.proof_url, status: result.status, commitment };
|
|
1509
1575
|
}
|
|
1510
1576
|
|
package/src/node-mcp.js
CHANGED
|
@@ -52,8 +52,12 @@ const RUNTIME_TOOLS = [
|
|
|
52
52
|
},
|
|
53
53
|
{
|
|
54
54
|
name: "aamio_read",
|
|
55
|
-
description: "New messages on your inbox. With wait, returns as soon as one arrives or after that many seconds (max 25). Each message says who signed it (a name from your address book, or unknown key), whether the signature verified (checked on this machine, not taken on the service's word), whether it was sealed to you, and whether it is a replay of one read before. What could not be read, or was kept out by the inbox's list, is in attention. No messages and no attention means nobody wrote. limit caps how many come back, fifty by default; the rest wait for the next call.",
|
|
56
|
-
inputSchema: schema({
|
|
55
|
+
description: "New messages on your inbox. With wait, returns as soon as one arrives or after that many seconds (max 25). Each message says who signed it (a name from your address book, or unknown key), whether the signature verified (checked on this machine, not taken on the service's word), whether it was sealed to you, and whether it is a replay of one read before. What could not be read, or was kept out by the inbox's list, is in attention. No messages and no attention means nobody wrote. limit caps how many come back, fifty by default; the rest wait for the next call. max_bytes caps how many bytes of them, per channel, measured as they are handed over here, and defaults to 65536: one message's worth, so ordinary traffic never meets it and a backlog arrives a page at a time. Pass a larger number to take more in one go. A message bigger than the budget stays at the service, named in attention with its size, until a read with a larger budget takes it; one that has already come is handed over on its own, and attention says so.",
|
|
56
|
+
inputSchema: schema({
|
|
57
|
+
wait: { type: "integer", minimum: 0, maximum: 25 },
|
|
58
|
+
limit: { type: "integer", minimum: 1, maximum: 200, description: "at most this many messages in the answer" },
|
|
59
|
+
max_bytes: { type: "integer", minimum: 512, description: "how many bytes of messages to hand over, per channel, 65536 unless you say otherwise: a read across several channels can return that much from each, since a budget split between them would refuse a message that fits. Whole messages only, measured as they are handed over. A message larger than the budget stays at the service and is named in attention, or, when it has already come, is handed over on its own and said in attention." },
|
|
60
|
+
}),
|
|
57
61
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
58
62
|
},
|
|
59
63
|
{
|
|
@@ -100,6 +104,12 @@ const RUNTIME_TOOLS = [
|
|
|
100
104
|
description: "Every tag in use on the board with live counts of needs and offers, dotted children under their branch. Use it to pick where to look before finding or watching.",
|
|
101
105
|
inputSchema: schema(), annotations: { ...READ_ONLY, openWorldHint: true },
|
|
102
106
|
},
|
|
107
|
+
{
|
|
108
|
+
name: "aamio_receipt",
|
|
109
|
+
description: "The receipt for one channel: hashes, times and signer keys of every message in it, and one root. channel is a local channel label, inbox by default, board for answers to your posts, or an older generation such as inbox-1800000000: take it from the channel field of the message you are working with. root_adds_up says the receipt's own lines hash to the root it claims; local_root_matches compares it to what this server read and is null when it holds fewer messages than the receipt counts, which is not a failure. The signer column is the service's record unless compared with locally verified messages. keys_unverified_count and keys_service_claim_only identify unchecked claims; they are never turned into contact names. Observations include kept-out messages. Fewer receipt lines than observed messages is a mismatch; local_differences names changed fields when counts match. Signing or anchoring a fetched root does not endorse its claims. A receipt does not prove that the other side read, understood or acted. With anchor, the root is published to Verifyum and anchored on Solana, which leaves this machine and cannot be undone.",
|
|
110
|
+
inputSchema: schema({ channel: { type: "string", description: "local channel label, as a message's channel field names it; defaults to inbox" }, anchor: { type: "boolean", description: "publish the root externally" } }),
|
|
111
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
112
|
+
},
|
|
103
113
|
{
|
|
104
114
|
name: "aamio_pending",
|
|
105
115
|
description: "Messages this home sent whose outcome is not settled: in flight, or unknown because no answer came back. Unknown does not mean undelivered. Never compose a replacement for a message whose outcome is unsettled.",
|
|
@@ -201,6 +211,8 @@ async function handle(message, session) {
|
|
|
201
211
|
|
|
202
212
|
const PARTNER_QUESTION = "aamio_partner_key";
|
|
203
213
|
const CONFIRM_SECONDS = 900;
|
|
214
|
+
// What aamio_read hands over per channel when the caller names no budget: one message's maximum.
|
|
215
|
+
const DEFAULT_MAX_BYTES = 65536;
|
|
204
216
|
const PARTNER_NAME = /^[\p{L}\p{N}][\p{L}\p{N}_.-]{0,31}$/u;
|
|
205
217
|
const CONFIRMATIONS = { secret: randomBytes(32), taken: new Map() };
|
|
206
218
|
|
|
@@ -414,6 +426,7 @@ function wrongArguments(tool, args) {
|
|
|
414
426
|
if (!want) return `${tool.name} takes no argument called ${name}`;
|
|
415
427
|
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))
|
|
416
428
|
: want.type === "integer" ? Number.isInteger(value) && value >= (want.minimum ?? -Infinity) && value <= (want.maximum ?? Infinity)
|
|
429
|
+
: want.type === "boolean" ? typeof value === "boolean"
|
|
417
430
|
: want.type === "object" ? object(value)
|
|
418
431
|
: want.type === "array" ? Array.isArray(value) && value.every((item) => typeof item === "string") && value.length <= (want.maxItems ?? Infinity) : false;
|
|
419
432
|
if (!ok) return `${name} is not what ${tool.name} takes: ${JSON.stringify(want)}`;
|
|
@@ -451,9 +464,11 @@ async function callRuntimeTool(id, params, session, version, reply, complete) {
|
|
|
451
464
|
case "aamio_presence_lookup": return runtime.lookup(args.names ?? null, args.wait ?? 0);
|
|
452
465
|
case "aamio_send": return runtime.send(args.to, { text: args.text ?? null, data: args.data ?? null, re: args.re ?? null });
|
|
453
466
|
case "aamio_read": {
|
|
454
|
-
|
|
467
|
+
// One message's maximum per channel unless the caller says otherwise, as aamio-python's server reads.
|
|
468
|
+
const messages = await runtime.read({ wait: args.wait ?? 0, limit: args.limit ?? 50, maxBytes: args.max_bytes ?? DEFAULT_MAX_BYTES });
|
|
455
469
|
return { messages, count: messages.length, attention: await runtime.attentionTaken() };
|
|
456
470
|
}
|
|
471
|
+
case "aamio_receipt": return runtime.receipt(args.channel ?? "inbox", { anchor: args.anchor === true });
|
|
457
472
|
case "aamio_pending": return { pending: runtime.outboxPending() };
|
|
458
473
|
case "aamio_board_post": return runtime.boardPost({ kind: args.kind, title: args.title, text: args.text, tags: args.tags ?? [], ttl: args.ttl ?? undefined, lang: args.lang ?? null, deadline: args.deadline ?? null });
|
|
459
474
|
case "aamio_board_find": return runtime.boardFind({ kind: args.kind ?? null, tags: args.tags ?? null, lang: args.lang ?? null, after: args.after ?? 0, wait: args.wait ?? 0, min_work_bits: args.min_work_bits ?? 0 });
|
package/src/node-runtime.js
CHANGED
|
@@ -2,7 +2,7 @@ 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, checkMessage, gatePlan, isEnvelope, isKey, keyHash, sha256hex, threadSigningInput } from "./aamio.js";
|
|
5
|
+
import { Aamio, AamioError, BOARD_TTL, DEFAULT_BASE, DEFAULT_BOARD, GateStop, VERIFYUM_MCP, checkMessage, gatePlan, isEnvelope, isKey, keyHash, sha256hex, threadSigningInput } from "./aamio.js";
|
|
6
6
|
import { readKeys } from "./node-identity.js";
|
|
7
7
|
import { takeHome } from "./node-lock.js";
|
|
8
8
|
|
|
@@ -31,11 +31,68 @@ export const ANSWER_MARGIN = 600;
|
|
|
31
31
|
// the runtime before anything is saved over it: a save is how a broken file
|
|
32
32
|
// becomes a lost one.
|
|
33
33
|
const KEPT_FILES = ["partners.json", "state.json", "outbox.json"];
|
|
34
|
+
// The largest X-Max-Bytes the service takes: one to eight digits.
|
|
35
|
+
const MAX_BYTES_HEADER = 99_999_999;
|
|
36
|
+
// How long after a thread expires what this process read of it is kept for its
|
|
37
|
+
// receipt: the service keeps an expired thread for a best-effort minute of grace
|
|
38
|
+
// for receipts, and the margin is for clocks that differ.
|
|
39
|
+
const RECEIPT_KEEP = 120;
|
|
34
40
|
// Answers that prove a message was not stored: refusals the service will give
|
|
35
41
|
// again, and a rate window, which it decides before it reads the body.
|
|
36
42
|
const SEND_DETERMINISTIC = [400, 403, 410, 413, 415, 422, 428, 501];
|
|
37
43
|
const SEND_NOT_STORED = [...SEND_DETERMINISTIC, 429];
|
|
38
44
|
const HASH = /^[0-9a-f]{64}$/;
|
|
45
|
+
// One message a channel handed over, as the channel remembers it: the hash of
|
|
46
|
+
// its bytes and who signed them, the first 16 hex of sha256 of the key, or -
|
|
47
|
+
// when nobody did. The hash alone is of the text, and two signers who both sent
|
|
48
|
+
// "OK" made the second a replay of the first (a review of 10 November 2026).
|
|
49
|
+
// aamio-python and aamio-php write the same records, so a home reads the same.
|
|
50
|
+
// ? stands beside a hash an earlier version kept without its signer, once that
|
|
51
|
+
// hash is met again: who sent it then is not known, and stays not known.
|
|
52
|
+
const SEEN_FROM = /^[0-9a-f]{64}:(?:[0-9a-f]{16}|-|\?)$/;
|
|
53
|
+
|
|
54
|
+
/** The record of a message handed over: its hash, and the signer it was verified from, or -. */
|
|
55
|
+
export function seenRecord(digest, fromKey) {
|
|
56
|
+
return `${digest}:${typeof fromKey === "string" && fromKey ? sha256hex(fromKey).slice(0, 16) : "-"}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether this channel handed these bytes over before from this signer, or
|
|
61
|
+
* unsigned when they are unsigned: true when it did, and false when it did not,
|
|
62
|
+
* since bytes it handed over from another signer make this another message.
|
|
63
|
+
* null when it cannot be told: an earlier version kept the hash without its
|
|
64
|
+
* signer, and this signer has no record of its own. Taken as a copy, another
|
|
65
|
+
* signer's first answer with the same text was passed over, and taken as new,
|
|
66
|
+
* the same signer's copy was acted on twice (a review of 11 November 2026), so
|
|
67
|
+
* it is said as it is.
|
|
68
|
+
*/
|
|
69
|
+
export function replayState(channel, digest, fromKey) {
|
|
70
|
+
if (digest === null || digest === undefined) return false;
|
|
71
|
+
const records = Array.isArray(channel.seen_from) ? channel.seen_from : [];
|
|
72
|
+
if (records.includes(seenRecord(digest, fromKey))) return true;
|
|
73
|
+
const mine = records.filter((record) => record.startsWith(digest + ":"));
|
|
74
|
+
if (mine.includes(digest + ":?") || (mine.length === 0 && channel.seen.includes(digest))) return null;
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Whether this is known to be a copy: replayState, with not known taken as no. */
|
|
79
|
+
export function isReplay(channel, digest, fromKey) {
|
|
80
|
+
return replayState(channel, digest, fromKey) === true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A message handed over: its hash, and who it came from. A hash an earlier
|
|
85
|
+
* version kept without its signer keeps that, as ?, beside the first signer
|
|
86
|
+
* recorded for it, so the next signer with the same bytes is not taken for a
|
|
87
|
+
* new one with certainty.
|
|
88
|
+
*/
|
|
89
|
+
export function remember(channel, digest, fromKey) {
|
|
90
|
+
if (!Array.isArray(channel.seen_from)) channel.seen_from = [];
|
|
91
|
+
if (channel.seen.includes(digest) && !channel.seen_from.some((record) => record.startsWith(digest + ":"))) channel.seen_from.push(digest + ":?");
|
|
92
|
+
if (!channel.seen.includes(digest)) channel.seen.push(digest);
|
|
93
|
+
const record = seenRecord(digest, fromKey);
|
|
94
|
+
if (!channel.seen_from.includes(record)) channel.seen_from.push(record);
|
|
95
|
+
}
|
|
39
96
|
const ADDRESS = /^[a-z2-7]{20}$/;
|
|
40
97
|
const NAME = /^[^\u0000-\u001f\u007f]{1,64}$/;
|
|
41
98
|
|
|
@@ -54,6 +111,41 @@ function runtimeError(code, message, extra = {}) {
|
|
|
54
111
|
return Object.assign(new Error(message), { code, ...extra });
|
|
55
112
|
}
|
|
56
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Why a service's answer is not a receipt for the thread at w, or null when it
|
|
116
|
+
* is one: the v1 schema, the address that was asked for, its times, one line in
|
|
117
|
+
* order for every message it counts, and a root its commitment names. Checked
|
|
118
|
+
* before anything is compared, signed or anchored: a receipt for another address,
|
|
119
|
+
* and one that counted seven messages and listed one, were signed with
|
|
120
|
+
* root_adds_up true (a review of 10 November 2026).
|
|
121
|
+
*/
|
|
122
|
+
function receiptFault(data, w) {
|
|
123
|
+
if (!isObject(data)) return "it is not an object";
|
|
124
|
+
if (data.schema !== "aamio-receipt-v1") return `its schema is ${JSON.stringify(data.schema ?? null)}, not aamio-receipt-v1`;
|
|
125
|
+
if (data.w !== w) return `it is for ${JSON.stringify(data.w ?? null)}, not for ${w}, the address asked for`;
|
|
126
|
+
for (const field of ["created_at", "expire_at", "issued_at"]) {
|
|
127
|
+
if (!Number.isInteger(data[field]) || data[field] < 0) return `its ${field} is not a time`;
|
|
128
|
+
}
|
|
129
|
+
if (!Array.isArray(data.messages)) return "it has no list of messages";
|
|
130
|
+
if (!Number.isInteger(data.count) || data.count !== data.messages.length) return `it counts ${JSON.stringify(data.count ?? null)} messages and has ${data.messages.length} lines`;
|
|
131
|
+
let previous = 0;
|
|
132
|
+
for (const [index, m] of data.messages.entries()) {
|
|
133
|
+
const fine = isObject(m) && Number.isInteger(m.seq) && m.seq > previous && Number.isInteger(m.at) && m.at >= 0
|
|
134
|
+
&& typeof m.sha256 === "string" && HASH.test(m.sha256) && (m.from === undefined || m.from === null || typeof m.from === "string");
|
|
135
|
+
if (!fine) return `line ${index + 1} is not a message line in order`;
|
|
136
|
+
previous = m.seq;
|
|
137
|
+
}
|
|
138
|
+
if (typeof data.root !== "string" || !HASH.test(data.root)) return "its root is not a sha256 in hex";
|
|
139
|
+
if (data.commitment !== "sha256:" + data.root) return "its commitment is not sha256: and its root";
|
|
140
|
+
// The signers are the lines' own: the service lists each key that signed a
|
|
141
|
+
// line, once. One that left a signer out was signed as if nobody had signed
|
|
142
|
+
// (a review of 10 November 2026).
|
|
143
|
+
if (!Array.isArray(data.keys) || data.keys.some((key) => typeof key !== "string") || new Set(data.keys).size !== data.keys.length) return "its keys are not a list of keys";
|
|
144
|
+
const signers = new Set(data.messages.map((m) => m.from).filter((from) => typeof from === "string"));
|
|
145
|
+
if (data.keys.length !== signers.size || data.keys.some((key) => !signers.has(key))) return "its keys are not the signers of its lines";
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
57
149
|
function isObject(value) {
|
|
58
150
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
59
151
|
}
|
|
@@ -76,10 +168,10 @@ function messageShape(body, envelope) {
|
|
|
76
168
|
|
|
77
169
|
export class NodeRuntime {
|
|
78
170
|
/** Takes the home, reads its key and its files. Close it to let go. */
|
|
79
|
-
static async open({ home, host = process.env.AAMIO_HOST || DEFAULT_BASE, board = process.env.AAMIO_BOARD || DEFAULT_BOARD, fetch = globalThis.fetch, clock = () => Date.now() / 1000 } = {}) {
|
|
171
|
+
static async open({ home, host = process.env.AAMIO_HOST || DEFAULT_BASE, board = process.env.AAMIO_BOARD || DEFAULT_BOARD, verifyum = process.env.AAMIO_VERIFYUM || VERIFYUM_MCP, fetch = globalThis.fetch, clock = () => Date.now() / 1000 } = {}) {
|
|
80
172
|
const owner = await takeHome(home, { host });
|
|
81
173
|
try {
|
|
82
|
-
const runtime = new NodeRuntime({ home, host, board, keys: await readKeys(home), owner, fetch, clock });
|
|
174
|
+
const runtime = new NodeRuntime({ home, host, board, verifyum, keys: await readKeys(home), owner, fetch, clock });
|
|
83
175
|
await runtime.load();
|
|
84
176
|
return runtime;
|
|
85
177
|
} catch (error) {
|
|
@@ -88,9 +180,15 @@ export class NodeRuntime {
|
|
|
88
180
|
}
|
|
89
181
|
}
|
|
90
182
|
|
|
91
|
-
constructor({ home, host, board = DEFAULT_BOARD, keys, owner, fetch, clock }) {
|
|
183
|
+
constructor({ home, host, board = DEFAULT_BOARD, verifyum = VERIFYUM_MCP, keys, owner, fetch, clock }) {
|
|
92
184
|
this.home = home;
|
|
93
185
|
this.host = host.replace(/\/+$/, "");
|
|
186
|
+
// Where a receipt's root is anchored, as aamio-python reads it: AAMIO_VERIFYUM.
|
|
187
|
+
this.verifyum = String(verifyum).replace(/\/+$/, "");
|
|
188
|
+
// What this process read of each thread, by write address and seq: the
|
|
189
|
+
// lines a receipt is compared with. In memory only, as in aamio-python, so
|
|
190
|
+
// a process that did not read a message cannot vouch for it.
|
|
191
|
+
this.observed = new Map();
|
|
94
192
|
this.keys = keys;
|
|
95
193
|
this.owner = owner;
|
|
96
194
|
this.clock = clock;
|
|
@@ -161,6 +259,7 @@ export class NodeRuntime {
|
|
|
161
259
|
for (const channel of this.state.channels) {
|
|
162
260
|
if (!isObject(channel)) continue;
|
|
163
261
|
channel.seen = Array.isArray(channel.seen) ? channel.seen.filter((value) => typeof value === "string" && HASH.test(value)) : [];
|
|
262
|
+
channel.seen_from = Array.isArray(channel.seen_from) ? channel.seen_from.filter((value) => typeof value === "string" && SEEN_FROM.test(value)) : [];
|
|
164
263
|
channel.allow = Array.isArray(channel.allow) ? channel.allow : [];
|
|
165
264
|
if (!Number.isInteger(channel.after)) channel.after = 0;
|
|
166
265
|
}
|
|
@@ -405,7 +504,7 @@ export class NodeRuntime {
|
|
|
405
504
|
const before = this.state.channels.map((channel) => (isObject(channel) ? [channel, channel.label, channel.muted] : null));
|
|
406
505
|
const noted = this.attention.length;
|
|
407
506
|
if (old) old.label = this.freeLabel(`inbox-${old.expire_at}`);
|
|
408
|
-
const inbox = { label: "inbox", read_key: thread.id, w: thread.w, expire_at: thread.expireAt, allow: wanted ?? [], after: 0, created_at: null, gone: false, muted: false, seen: [] };
|
|
507
|
+
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: [] };
|
|
409
508
|
this.state.channels.push(inbox);
|
|
410
509
|
const muted = this.muteGenerations(reason);
|
|
411
510
|
try {
|
|
@@ -759,7 +858,7 @@ export class NodeRuntime {
|
|
|
759
858
|
}
|
|
760
859
|
const label = held?.label;
|
|
761
860
|
if (held) held.label = this.freeLabel(`board-${held.expire_at}`);
|
|
762
|
-
const channel = { label: "board", read_key: thread.id, w: thread.w, expire_at: thread.expireAt, allow: ["*"], after: 0, created_at: null, gone: false, muted: false, seen: [] };
|
|
861
|
+
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: [] };
|
|
763
862
|
this.state.channels.push(channel);
|
|
764
863
|
try {
|
|
765
864
|
await this.saveState();
|
|
@@ -920,10 +1019,12 @@ export class NodeRuntime {
|
|
|
920
1019
|
const [body, meta] = this.openMessage({ ...message, verified: checked.verified, from: sender });
|
|
921
1020
|
const known = this.nameForKey(sender);
|
|
922
1021
|
const digest = checked.sha256 ?? null;
|
|
1022
|
+
const replay = replayState(channel, digest, sender);
|
|
923
1023
|
const entry = {
|
|
924
1024
|
channel: channel.label, w: channel.w, seq, at, verified: checked.verified, from_key: sender, known_contact: known !== null,
|
|
925
|
-
sender: known ?? (sender ? "unknown key" : "unsigned"), sha256: digest, replay:
|
|
1025
|
+
sender: known ?? (sender ? "unknown key" : "unsigned"), sha256: digest, replay: replay === true, body, ...meta,
|
|
926
1026
|
};
|
|
1027
|
+
if (replay === null) entry.replay_unknown = true;
|
|
927
1028
|
if (checked.whyNot) entry.unverified_because = checked.whyNot;
|
|
928
1029
|
return entry;
|
|
929
1030
|
} catch (error) {
|
|
@@ -957,17 +1058,26 @@ export class NodeRuntime {
|
|
|
957
1058
|
};
|
|
958
1059
|
}
|
|
959
1060
|
|
|
960
|
-
/**
|
|
961
|
-
|
|
962
|
-
|
|
1061
|
+
/**
|
|
1062
|
+
* What a verified message that names an address in this field would bind: its
|
|
1063
|
+
* signer's key to the address, once, or a conflict when the address is bound to
|
|
1064
|
+
* another key, and the binding stays. Nothing is changed here: the binding is
|
|
1065
|
+
* made when the message is taken.
|
|
1066
|
+
*/
|
|
1067
|
+
bindingFor(field, address, senderKey) {
|
|
1068
|
+
if (typeof address !== "string" || !ADDRESS.test(address)) return null;
|
|
963
1069
|
const bound = this.state.peers[address];
|
|
964
|
-
if (bound === undefined) {
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1070
|
+
if (bound === undefined) return { field, address, conflict: null };
|
|
1071
|
+
if (bound === senderKey) return null;
|
|
1072
|
+
return { field, address, conflict: { field, address, claimed_by: senderKey, bound_to: bound } };
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/** The cursor and what was read belonged to a thread that is not there now. The hashes are the reader's and stay. */
|
|
1076
|
+
forgetThread(channel) {
|
|
1077
|
+
channel.after = 0;
|
|
1078
|
+
channel.created_at = null;
|
|
1079
|
+
channel.instance = null;
|
|
1080
|
+
this.observed.delete(channel.w);
|
|
971
1081
|
}
|
|
972
1082
|
|
|
973
1083
|
/**
|
|
@@ -975,17 +1085,17 @@ export class NodeRuntime {
|
|
|
975
1085
|
* before the caller sees a message: a crash after that redelivers nothing, and
|
|
976
1086
|
* one before it redelivers what the stored hashes then mark as replays.
|
|
977
1087
|
*/
|
|
978
|
-
async poll(channel, wait = 0, limit = null) {
|
|
1088
|
+
async poll(channel, wait = 0, limit = null, maxBytes = null) {
|
|
979
1089
|
if (channel.muted) return [];
|
|
980
|
-
const before = { after: channel.after, created_at: channel.created_at, gone: channel.gone, seen: [...channel.seen], peers: { ...this.state.peers } };
|
|
1090
|
+
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 } };
|
|
981
1091
|
try {
|
|
982
|
-
return await this.pollOnce(channel, wait, limit, true);
|
|
1092
|
+
return await this.pollOnce(channel, wait, limit, true, maxBytes);
|
|
983
1093
|
} catch (error) {
|
|
984
1094
|
if (!error?.notSaved) throw error;
|
|
985
1095
|
// What this read changed is undone in memory too: the cursor, the hashes
|
|
986
1096
|
// and the bindings stay as the file has them, and the messages wait at
|
|
987
1097
|
// the service for the next read, which hands them over.
|
|
988
|
-
Object.assign(channel, { after: before.after, created_at: before.created_at, gone: before.gone, seen: before.seen });
|
|
1098
|
+
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 });
|
|
989
1099
|
this.state.peers = before.peers;
|
|
990
1100
|
this.note(channel.label, "not_saved", `what this read found here could not be written down (${error.cause?.code || error.cause?.message}), so nothing from this channel was handed over. It is still there for the next read, and nothing was skipped.`, channel.w);
|
|
991
1101
|
return [];
|
|
@@ -1000,12 +1110,39 @@ export class NodeRuntime {
|
|
|
1000
1110
|
}
|
|
1001
1111
|
}
|
|
1002
1112
|
|
|
1003
|
-
|
|
1113
|
+
/**
|
|
1114
|
+
* One message this process read on a channel, kept for the receipt: the
|
|
1115
|
+
* service's line for it as this process saw it, and what was checked here.
|
|
1116
|
+
* The rows belong to the thread they were read on, the one opened at
|
|
1117
|
+
* created_at at this address. A thread opened at the same address after the
|
|
1118
|
+
* service restarted is another thread, and its receipt is never compared with
|
|
1119
|
+
* them (a review of 10 November 2026).
|
|
1120
|
+
*/
|
|
1121
|
+
observe(channel, raw, entry) {
|
|
1122
|
+
if (!Number.isInteger(entry.seq) || entry.seq < 1) return;
|
|
1123
|
+
let held = this.observed.get(channel.w);
|
|
1124
|
+
const created = channel.created_at ?? null;
|
|
1125
|
+
const expires = Number.isInteger(channel.expire_at) ? channel.expire_at : null;
|
|
1126
|
+
const instance = typeof channel.instance === "string" ? channel.instance : null;
|
|
1127
|
+
if (!held || held.created_at !== created || held.expire_at !== expires || held.instance !== instance) {
|
|
1128
|
+
this.observed.set(channel.w, (held = { created_at: created, expire_at: expires, instance, rows: new Map() }));
|
|
1129
|
+
}
|
|
1130
|
+
const message = isObject(raw) ? raw : {};
|
|
1131
|
+
held.rows.set(entry.seq, { seq: entry.seq, at: entry.at, sha256: entry.sha256, from: typeof message.from === "string" ? message.from : null, verified: entry.verified === true, from_key: entry.from_key ?? null });
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
async pollOnce(channel, wait, limit, again, maxBytes = null) {
|
|
1004
1135
|
let path = "/" + channel.w;
|
|
1005
1136
|
if (channel.after > 0 || wait > 0) path += "/after/" + channel.after;
|
|
1006
1137
|
if (wait > 0) path += "/wait/" + Math.min(25, wait);
|
|
1007
1138
|
const headers = { "X-Read": channel.read_key };
|
|
1008
1139
|
if (limit !== null) headers["X-Limit"] = String(limit);
|
|
1140
|
+
// Whole messages only, and this many bytes of them at most: the service
|
|
1141
|
+
// keeps the rest, and says so with more or too_large. It takes the header
|
|
1142
|
+
// as at most eight digits and holds any budget to the thread's maximum, a
|
|
1143
|
+
// megabyte, so a larger one is sent as the largest it takes: nine digits
|
|
1144
|
+
// were answered 400 and the channel went unread.
|
|
1145
|
+
if (maxBytes !== null) headers["X-Max-Bytes"] = String(Math.min(maxBytes, MAX_BYTES_HEADER));
|
|
1009
1146
|
let data;
|
|
1010
1147
|
try {
|
|
1011
1148
|
data = await this.client.request("GET", path, { headers, expect: [200] });
|
|
@@ -1025,79 +1162,339 @@ export class NodeRuntime {
|
|
|
1025
1162
|
if (!channel.gone) this.note(channel.label, "gone", "there is no thread at this address any more: it expired and was swept, or the service restarted and it went with it" + (channel.label === "inbox" ? ", so a new inbox is opened for the partners" : ""), channel.w);
|
|
1026
1163
|
channel.gone = true;
|
|
1027
1164
|
// The cursor belonged to that thread. The hashes are the reader's and stay.
|
|
1028
|
-
channel
|
|
1029
|
-
channel.created_at = null;
|
|
1165
|
+
this.forgetThread(channel);
|
|
1030
1166
|
await this.saveRead();
|
|
1031
1167
|
return [];
|
|
1032
1168
|
}
|
|
1033
1169
|
channel.gone = false;
|
|
1034
1170
|
const created = data.created_at ?? null;
|
|
1035
|
-
|
|
1036
|
-
|
|
1171
|
+
const expires = Number.isInteger(data.expire_at) ? data.expire_at : null;
|
|
1172
|
+
const instance = typeof data.instance === "string" ? data.instance : null;
|
|
1173
|
+
// A thread is known by its instance, which the service gives each thread
|
|
1174
|
+
// when it is opened: a thread opened again at the same address in the same
|
|
1175
|
+
// second, with the same lifetime and list, looked the same in every other
|
|
1176
|
+
// field, and a cursor taken from the old one passed over its first messages
|
|
1177
|
+
// (a review of 10 November 2026). A service that names no instance is read
|
|
1178
|
+
// by when the thread was opened and when it expires, as before.
|
|
1179
|
+
const known = typeof channel.instance === "string" && instance !== null;
|
|
1180
|
+
const another = known
|
|
1181
|
+
? instance !== channel.instance
|
|
1182
|
+
: channel.created_at !== null && ((created !== null && created !== channel.created_at) || (expires !== null && Number.isInteger(channel.expire_at) && expires !== channel.expire_at));
|
|
1183
|
+
if (again && another && !data.reset) {
|
|
1184
|
+
this.note(channel.label, "restarted", known
|
|
1185
|
+
? `the thread at this address is a new one, instance ${instance} where this channel knew instance ${channel.instance}, so it is read again from the start`
|
|
1186
|
+
: `the thread at this address is a new one, opened at ${created} and expiring at ${expires}, where this channel knew one opened at ${channel.created_at} and expiring at ${channel.expire_at}, so it is read again from the start`, channel.w);
|
|
1187
|
+
// What was read of the old thread is no line of the new one's receipt.
|
|
1188
|
+
this.forgetThread(channel);
|
|
1189
|
+
return this.pollOnce(channel, 0, limit, false, maxBytes);
|
|
1190
|
+
}
|
|
1191
|
+
// A cursor kept by a client that did not keep the thread's instance, aamio
|
|
1192
|
+
// 0.6.1 among them, cannot be checked against it: a thread opened again at
|
|
1193
|
+
// this address in the same second, with the same lifetime and list, looks
|
|
1194
|
+
// the same in every other field, and its first messages were passed over
|
|
1195
|
+
// without a word (a review of 10 November 2026). So the thread is read again
|
|
1196
|
+
// from the start, once, and all of it is handed over: what this channel
|
|
1197
|
+
// handed over before comes again marked replay, as after a restart. Nothing
|
|
1198
|
+
// is passed over by its hash, which is the hash of the text alone: another
|
|
1199
|
+
// sender's message with the same text has it too (the same review).
|
|
1200
|
+
if (!another && !data.reset && channel.after > 0 && typeof channel.instance !== "string" && instance !== null) {
|
|
1201
|
+
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);
|
|
1037
1202
|
channel.after = 0;
|
|
1038
|
-
channel
|
|
1039
|
-
|
|
1203
|
+
return this.pollOnce(channel, 0, limit, false, maxBytes);
|
|
1204
|
+
}
|
|
1205
|
+
if (data.reset) {
|
|
1206
|
+
this.note(channel.label, "restarted", (isObject(data.reset) && data.reset.what) || "the service read this thread from the start", channel.w);
|
|
1207
|
+
this.observed.delete(channel.w);
|
|
1040
1208
|
}
|
|
1041
|
-
|
|
1209
|
+
// The channel follows the thread that is there, all of it at once: one
|
|
1210
|
+
// taken up after its cursor was forgotten, or read from the start by the
|
|
1211
|
+
// service after a restart, may expire at another time than the one opened.
|
|
1212
|
+
// A channel that took the new instance and kept the old expiry chose an
|
|
1213
|
+
// inbox that had expired, and one that expired later was given up early
|
|
1214
|
+
// (a review of 10 November 2026). The list this channel was opened with is
|
|
1215
|
+
// its own, and stays.
|
|
1216
|
+
if ((channel.created_at === null || another || data.reset) && expires !== null) channel.expire_at = expires;
|
|
1042
1217
|
channel.created_at = created;
|
|
1218
|
+
if (instance !== null) channel.instance = instance;
|
|
1219
|
+
// What the service kept back. It answers a byte budget with whole messages
|
|
1220
|
+
// only, so a message larger than the budget is named here and stays at the
|
|
1221
|
+
// service, with what came after it waiting behind it.
|
|
1222
|
+
if (isObject(data.too_large)) {
|
|
1223
|
+
this.note(channel.label, "too_large", `message ${data.too_large.seq} on this channel is ${data.too_large.bytes} bytes and does not fit the byte budget this read asked for, so it was not sent. It is still there, what was written after it waits behind it, and every read at this budget will leave it. Read again with a larger budget to take it: max_bytes over MCP, or --max-bytes on the command line.`, channel.w);
|
|
1224
|
+
}
|
|
1043
1225
|
|
|
1044
1226
|
const entries = [];
|
|
1045
1227
|
const keptOut = [];
|
|
1046
1228
|
let lastSeq = null;
|
|
1229
|
+
// The budget is kept here as well, for what is handed over, measured as it
|
|
1230
|
+
// is handed over. The service counts its own form of each message, which is
|
|
1231
|
+
// not the size of the message opened here, and a service without read
|
|
1232
|
+
// limits sends everything: 512 bytes asked for, 628 handed over (a review of
|
|
1233
|
+
// 10 November 2026). A row that does not fit is not taken at all. It is not
|
|
1234
|
+
// observed for the receipt, not remembered as read and binds no address, and
|
|
1235
|
+
// the cursor stays before it, so the next read asks the service for it.
|
|
1236
|
+
let used = 0;
|
|
1237
|
+
let stoppedAtBudget = false;
|
|
1238
|
+
let oversized = null;
|
|
1239
|
+
// What this read says of the messages it takes is said once the read is
|
|
1240
|
+
// written down. One that cannot be saved hands nothing over, and it said
|
|
1241
|
+
// replay_unknown for rows it never handed over: count 0 and no messages,
|
|
1242
|
+
// with a note on two of them (a review of 11 November 2026).
|
|
1243
|
+
const said = [];
|
|
1047
1244
|
for (const raw of data.messages) {
|
|
1048
1245
|
if (limit !== null && entries.length >= limit) break;
|
|
1049
1246
|
const entry = this.readEntry(channel, raw);
|
|
1050
|
-
lastSeq = lastSeq === null ? entry.seq : Math.max(lastSeq, entry.seq);
|
|
1051
1247
|
const allow = channel.allow || [];
|
|
1052
1248
|
const excluded = allow.length > 0 && !(entry.verified && (allow.includes("*") || allow.includes(entry.from_key)));
|
|
1249
|
+
// What the message's addresses would bind is worked out before it is
|
|
1250
|
+
// weighed, so a conflict it carries is counted with it, and bound only when
|
|
1251
|
+
// it is taken: a conflict added after the weighing let 1146 bytes through a
|
|
1252
|
+
// budget of 900 (a review of 10 November 2026).
|
|
1253
|
+
const bindings = [];
|
|
1254
|
+
if (!excluded) {
|
|
1255
|
+
this.takeScopeShare(entry);
|
|
1256
|
+
if (isObject(entry.body) && entry.verified && entry.from_key) {
|
|
1257
|
+
for (const field of ["reply_to", "channel"]) {
|
|
1258
|
+
const planned = this.bindingFor(field, entry.body[field], entry.from_key);
|
|
1259
|
+
if (planned !== null) bindings.push(planned);
|
|
1260
|
+
}
|
|
1261
|
+
const conflicts = bindings.filter((planned) => planned.conflict !== null).map((planned) => planned.conflict);
|
|
1262
|
+
if (conflicts.length) entry.binding_conflicts = conflicts;
|
|
1263
|
+
}
|
|
1264
|
+
if (maxBytes !== null) {
|
|
1265
|
+
const weight = Buffer.byteLength(JSON.stringify(entry), "utf8");
|
|
1266
|
+
if (entries.length > 0 && used + weight > maxBytes) {
|
|
1267
|
+
stoppedAtBudget = true;
|
|
1268
|
+
break;
|
|
1269
|
+
}
|
|
1270
|
+
// Alone and still over the budget: it has come, so it is handed over
|
|
1271
|
+
// on its own rather than kept back for ever, and that is said.
|
|
1272
|
+
if (entries.length === 0 && weight > maxBytes) oversized = { seq: entry.seq, weight };
|
|
1273
|
+
used += weight;
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
// Every message read counts for the receipt, the ones the list keeps out too.
|
|
1277
|
+
this.observe(channel, raw, entry);
|
|
1278
|
+
lastSeq = lastSeq === null ? entry.seq : Math.max(lastSeq, entry.seq);
|
|
1053
1279
|
if (excluded) {
|
|
1054
1280
|
keptOut.push(entry.seq);
|
|
1055
1281
|
continue;
|
|
1056
1282
|
}
|
|
1057
|
-
if (entry.sha256 !== null
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1283
|
+
if (entry.sha256 !== null) remember(channel, entry.sha256, entry.from_key);
|
|
1284
|
+
for (const planned of bindings) {
|
|
1285
|
+
if (planned.conflict === null) {
|
|
1286
|
+
this.state.peers[planned.address] = entry.from_key;
|
|
1287
|
+
} else {
|
|
1288
|
+
said.push(["binding_conflict", `message ${entry.seq} names ${planned.address} as ${planned.field}, signed by ${this.nameForKey(entry.from_key) ?? entry.from_key}, and that address is already bound to ${this.nameForKey(planned.conflict.bound_to) ?? planned.conflict.bound_to}. The binding is kept.`]);
|
|
1289
|
+
}
|
|
1061
1290
|
}
|
|
1062
1291
|
entries.push(entry);
|
|
1063
1292
|
}
|
|
1293
|
+
// Messages whose bytes this channel handed over before, which an earlier
|
|
1294
|
+
// version recorded without who sent them: whether each is a copy cannot be
|
|
1295
|
+
// told, and it is said rather than guessed.
|
|
1296
|
+
const unknown = entries.filter((entry) => entry.replay_unknown).map((entry) => entry.seq);
|
|
1297
|
+
if (unknown.length) {
|
|
1298
|
+
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.`]);
|
|
1299
|
+
}
|
|
1064
1300
|
if (keptOut.length) {
|
|
1065
1301
|
const openedFor = (channel.allow || []).includes("*") ? "any key, signed only" : `${channel.allow.length} named key(s)`;
|
|
1066
|
-
|
|
1302
|
+
said.push(["kept_out", `${keptOut.length} message(s) (seq ${keptOut.join(", ")}): this channel was opened for ${openedFor}, and these did not satisfy that list as checked here. They are not handed over.`]);
|
|
1303
|
+
}
|
|
1304
|
+
if (oversized) {
|
|
1305
|
+
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.`]);
|
|
1067
1306
|
}
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1307
|
+
// Rows the service sent that this read left because its own limit was
|
|
1308
|
+
// reached: a service that does not take X-Limit sends them all, and the read
|
|
1309
|
+
// that stopped on its own said nothing (a review of 10 November 2026).
|
|
1310
|
+
const leftHere = !stoppedAtBudget && limit !== null && entries.length >= limit && data.messages.length > entries.length + keptOut.length;
|
|
1311
|
+
if (stoppedAtBudget) {
|
|
1312
|
+
said.push(["more", `this read stopped at the byte budget it asked for, ${maxBytes} bytes of messages as they are handed over here, and left the rest at the service. Nothing was passed over: the cursor stands at the last message handed over, so read again for the rest.`]);
|
|
1313
|
+
} else if (leftHere || (data.more === true && !isObject(data.too_large))) {
|
|
1314
|
+
// The service says more both when the count this read asked for was
|
|
1315
|
+
// reached and when the byte budget was. A note that blamed a budget
|
|
1316
|
+
// nobody set would send the reader looking for the wrong cause.
|
|
1317
|
+
const atLimit = leftHere || (limit !== null && data.messages.length >= limit);
|
|
1318
|
+
said.push(["more", atLimit
|
|
1319
|
+
? "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."
|
|
1320
|
+
: maxBytes !== null
|
|
1321
|
+
? "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."
|
|
1322
|
+
: "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."]);
|
|
1323
|
+
}
|
|
1324
|
+
// A read that stopped early moves the cursor to the last row it took and
|
|
1325
|
+
// no further: next would skip the rest of the page.
|
|
1326
|
+
const stoppedEarly = stoppedAtBudget || leftHere;
|
|
1327
|
+
if (stoppedEarly) {
|
|
1328
|
+
if (lastSeq !== null) channel.after = lastSeq;
|
|
1329
|
+
} else if (Number.isInteger(data.next)) channel.after = data.next;
|
|
1071
1330
|
else if (lastSeq !== null) channel.after = Math.max(channel.after, lastSeq);
|
|
1072
1331
|
await this.saveRead();
|
|
1332
|
+
for (const [state, what] of said) this.note(channel.label, state, what, channel.w);
|
|
1073
1333
|
return entries;
|
|
1074
1334
|
}
|
|
1075
1335
|
|
|
1336
|
+
/**
|
|
1337
|
+
* The receipt for one channel, and two checks of it that are not one. Whether
|
|
1338
|
+
* its own lines hash to the root it claims can always be checked:
|
|
1339
|
+
* root_adds_up. Whether it agrees with what this process read can be, when
|
|
1340
|
+
* this process holds every message it counts: local_root_matches, null when
|
|
1341
|
+
* it holds fewer, which is not a failure, and false when it holds more. A
|
|
1342
|
+
* signer the service names is shown by name only when this process checked
|
|
1343
|
+
* that signature itself. The receipt is signed with this home's key as it
|
|
1344
|
+
* was fetched, which records what was taken, not that its claims are true.
|
|
1345
|
+
* With anchor, and only then, its root goes to Verifyum, with the same
|
|
1346
|
+
* idempotency key aamio-python and aamio-php use.
|
|
1347
|
+
*/
|
|
1348
|
+
async receipt(label = "inbox", { anchor = false } = {}) {
|
|
1349
|
+
const channel = this.channel(String(label));
|
|
1350
|
+
if (!isObject(channel)) {
|
|
1351
|
+
const labels = this.state.channels.filter(isObject).map((c) => c.label);
|
|
1352
|
+
throw runtimeError("unknown_channel", `no channel called ${label} in this home. It has ${labels.length ? labels.join(", ") : "none yet: a read opens the inbox"}. A message names the channel it came on.`);
|
|
1353
|
+
}
|
|
1354
|
+
let data;
|
|
1355
|
+
try {
|
|
1356
|
+
({ receipt: data } = await this.client.receipt({ id: channel.read_key, w: channel.w }));
|
|
1357
|
+
} catch (error) {
|
|
1358
|
+
throw runtimeError("receipt_failed", `the receipt could not be taken: ${error.status ?? 0} ${JSON.stringify(error.body ?? error.message)}`, { status: error.status ?? 0 });
|
|
1359
|
+
}
|
|
1360
|
+
// Checked as a receipt for this channel before anything is compared,
|
|
1361
|
+
// signed or anchored.
|
|
1362
|
+
const fault = receiptFault(data, channel.w);
|
|
1363
|
+
if (fault !== null) {
|
|
1364
|
+
throw runtimeError("receipt_failed", `the service's answer is not a receipt for this channel: ${fault}. Nothing was compared, signed or anchored.`);
|
|
1365
|
+
}
|
|
1366
|
+
// What this process read counts only for the thread it was read on. After
|
|
1367
|
+
// the service restarted, a new thread at the same address was compared with
|
|
1368
|
+
// what was read of the old one, and its signer named as checked here.
|
|
1369
|
+
const observed = this.observed.get(channel.w);
|
|
1370
|
+
// The same thread by its instance where the service names one, and by when
|
|
1371
|
+
// it was opened and expires where it does not.
|
|
1372
|
+
const sameThread = observed !== undefined && (typeof data.instance === "string" && observed.instance !== null
|
|
1373
|
+
? observed.instance === data.instance
|
|
1374
|
+
: observed.created_at !== null && observed.created_at === data.created_at && (observed.expire_at === null || observed.expire_at === data.expire_at));
|
|
1375
|
+
const earlier = observed !== undefined && !sameThread && observed.rows.size > 0 ? observed : null;
|
|
1376
|
+
const entries = sameThread ? [...observed.rows.values()].sort((a, b) => a.seq - b.seq) : [];
|
|
1377
|
+
const which = (thread) => (typeof thread.instance === "string" ? `instance ${thread.instance}, ` : "the one ") + `opened at ${thread.created_at} and expiring at ${thread.expire_at}`;
|
|
1378
|
+
const held = entries.length;
|
|
1379
|
+
const line = (m) => `${m.seq}\t${m.at}\t${m.sha256}\t${m.from ?? "-"}\n`;
|
|
1380
|
+
const listed = data.messages.map(line).join("");
|
|
1381
|
+
const ours = entries.map(line).join("");
|
|
1382
|
+
const comparable = held === data.count;
|
|
1383
|
+
// A signer is named only for a row this process checked and the receipt
|
|
1384
|
+
// lists as it was read: the same seq, time, hash and signer. A row read on a
|
|
1385
|
+
// thread the fields cannot tell from this one is not taken for it (a review
|
|
1386
|
+
// of 10 November 2026).
|
|
1387
|
+
const listedBySeq = new Map(data.messages.map((m) => [m.seq, m]));
|
|
1388
|
+
const asListed = (e) => {
|
|
1389
|
+
const m = listedBySeq.get(e.seq);
|
|
1390
|
+
return m !== undefined && m.at === e.at && m.sha256 === e.sha256 && (m.from ?? null) === (e.from ?? null);
|
|
1391
|
+
};
|
|
1392
|
+
const verifiedKeys = new Set(entries.filter((e) => e.verified && typeof e.from_key === "string" && asListed(e)).map((e) => e.from_key));
|
|
1393
|
+
const claimed = Array.isArray(data.keys) ? data.keys : [];
|
|
1394
|
+
const unverified = claimed.filter((key) => typeof key !== "string" || !verifiedKeys.has(key));
|
|
1395
|
+
const attestation = `aamio-receipt-v1\n${channel.w}\n${data.root}\n${data.count}\n${data.issued_at ?? 0}`;
|
|
1396
|
+
const result = {
|
|
1397
|
+
label: channel.label, w: channel.w, root: data.root, commitment: data.commitment ?? null, count: data.count,
|
|
1398
|
+
keys: claimed.map((key) => (typeof key === "string" && verifiedKeys.has(key) ? this.nameForKey(key) ?? key : key)),
|
|
1399
|
+
keys_unverified_count: unverified.length, keys_service_claim_only: unverified,
|
|
1400
|
+
root_adds_up: sha256hex(listed) === data.root, held_locally: held,
|
|
1401
|
+
local_root_matches: comparable ? sha256hex(ours) === data.root : held > data.count ? false : null,
|
|
1402
|
+
signed_by: this.keys.public, signature: this.keys.sign(attestation), signed_text: attestation, receipt: data,
|
|
1403
|
+
};
|
|
1404
|
+
if (comparable) {
|
|
1405
|
+
const local = new Map(entries.map((e) => [e.seq, e]));
|
|
1406
|
+
result.local_differences = (data.messages ?? []).map((m) => {
|
|
1407
|
+
const seen = local.get(m.seq);
|
|
1408
|
+
return { seq: m.seq, fields: ["at", "sha256", "from"].filter((field) => !seen || (seen[field] ?? null) !== (m[field] ?? null)) };
|
|
1409
|
+
}).filter((difference) => difference.fields.length);
|
|
1410
|
+
result.signers_not_verified_locally = entries.filter((e) => e.from && !e.verified).map((e) => e.seq);
|
|
1411
|
+
}
|
|
1412
|
+
if (held > data.count) {
|
|
1413
|
+
result.local_check = "Mismatch: the receipt counts fewer messages than this process read. The thread was replaced or the service lost messages.";
|
|
1414
|
+
} else if (!comparable) {
|
|
1415
|
+
result.local_check = `Not compared: this process holds ${held} of the ${data.count} messages the receipt counts, so a local root would differ for a reason that is not the receipt's.`
|
|
1416
|
+
+ (earlier !== null ? ` What this process read at this address was on another thread, ${which(earlier)}; this receipt is for ${which(data)}, so none of that counts here.` : "")
|
|
1417
|
+
+ " root_adds_up checks only the receipt's arithmetic; our signature records what was fetched, not that its claims are true. For the independent check, take the receipt in the process that read the messages.";
|
|
1418
|
+
}
|
|
1419
|
+
if (anchor === true) {
|
|
1420
|
+
const idempotencyKey = sha256hex(`aamio-listen:${this.keys.hash}:${data.root}`).slice(0, 32);
|
|
1421
|
+
try {
|
|
1422
|
+
const proof = await this.client.anchor(`sha256:${data.root}`, { endpoint: this.verifyum, idempotencyKey });
|
|
1423
|
+
result.anchor = { proof_id: proof.proofId, proof_url: proof.proofUrl ?? null, status: proof.status ?? null, commitment: proof.commitment };
|
|
1424
|
+
} catch (error) {
|
|
1425
|
+
// The receipt stands; only the anchoring failed, and it says how: the
|
|
1426
|
+
// HTTP status, 0 when nothing answered, and Verifyum's own words when
|
|
1427
|
+
// its tool answered with an error.
|
|
1428
|
+
const said = error.body?.result?.content?.[0]?.text;
|
|
1429
|
+
result.anchor = { error: error.status ?? 0, detail: typeof said === "string" ? said : error.body ?? error.message };
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
return result;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1076
1435
|
/** Messages on every channel still read, the inbox first. The inbox is opened and presence published as the read starts. */
|
|
1077
|
-
async read({ wait = 0, limit = 50 } = {}) {
|
|
1436
|
+
async read({ wait = 0, limit = 50, maxBytes = null } = {}) {
|
|
1437
|
+
const noted = this.attention.length;
|
|
1078
1438
|
await this.ensureInbox();
|
|
1079
1439
|
await this.publishPresence();
|
|
1080
1440
|
const now = this.now();
|
|
1441
|
+
// What was read of a thread is kept for its receipt while the receipt can
|
|
1442
|
+
// still be taken, and no longer: a server that ran for days held the rows
|
|
1443
|
+
// of every inbox it had read (a review of 10 November 2026).
|
|
1444
|
+
for (const w of [...this.observed.keys()]) {
|
|
1445
|
+
const known = this.state.channels.find((c) => isObject(c) && c.w === w);
|
|
1446
|
+
if (!known || Number(known.expire_at) + RECEIPT_KEEP < now) this.observed.delete(w);
|
|
1447
|
+
}
|
|
1081
1448
|
const channels = this.state.channels.filter((c) => isObject(c) && !c.muted && Number(c.expire_at) > now)
|
|
1082
1449
|
.sort((a, b) => (a.label === "inbox" ? -1 : b.label === "inbox" ? 1 : 0));
|
|
1083
1450
|
const messages = [];
|
|
1451
|
+
// A channel this read had no room left to ask is named, as aamio-python
|
|
1452
|
+
// names it: what waits there was not looked at, and a read that stopped at
|
|
1453
|
+
// its limit is not a read of everything.
|
|
1454
|
+
const notAsked = [];
|
|
1455
|
+
const notAskedAfter = [];
|
|
1084
1456
|
// Every channel without waiting first: a message on an older inbox that
|
|
1085
1457
|
// is still read is handed over at once, not after a wait on the new one.
|
|
1458
|
+
// The byte budget is per channel, as in aamio-python: one split between
|
|
1459
|
+
// them would refuse a message that fits.
|
|
1086
1460
|
for (const channel of channels) {
|
|
1087
|
-
if (messages.length >= limit)
|
|
1088
|
-
|
|
1461
|
+
if (messages.length >= limit) {
|
|
1462
|
+
notAsked.push(channel);
|
|
1463
|
+
continue;
|
|
1464
|
+
}
|
|
1465
|
+
messages.push(...(await this.poll(channel, 0, limit - messages.length, maxBytes)));
|
|
1089
1466
|
}
|
|
1090
1467
|
const inbox = this.channel("inbox");
|
|
1091
1468
|
if (!messages.length && wait > 0 && inbox && !inbox.muted) {
|
|
1092
|
-
messages.push(...(await this.poll(inbox, wait, limit)));
|
|
1469
|
+
messages.push(...(await this.poll(inbox, wait, limit, maxBytes)));
|
|
1093
1470
|
// What reached the other channels during the wait comes with this
|
|
1094
1471
|
// answer, as in aamio-python: only the inbox can end the wait early. It
|
|
1095
1472
|
// used to wait for the next read (a review of 9 November 2026).
|
|
1096
1473
|
for (const channel of channels) {
|
|
1097
|
-
if (channel === inbox
|
|
1098
|
-
messages.
|
|
1474
|
+
if (channel === inbox) continue;
|
|
1475
|
+
if (messages.length >= limit) {
|
|
1476
|
+
notAskedAfter.push(channel);
|
|
1477
|
+
continue;
|
|
1478
|
+
}
|
|
1479
|
+
messages.push(...(await this.poll(channel, 0, limit - messages.length, maxBytes)));
|
|
1099
1480
|
}
|
|
1100
1481
|
}
|
|
1482
|
+
for (const channel of notAsked) {
|
|
1483
|
+
this.note(channel.label, "more", `this read reached its limit of ${limit} message(s) before it asked this channel, so what waits here was not looked at. Nothing was passed over: read again for it.`, channel.w);
|
|
1484
|
+
}
|
|
1485
|
+
for (const channel of notAskedAfter) {
|
|
1486
|
+
this.note(channel.label, "more", `this read reached its limit of ${limit} message(s) during the wait, so this channel was asked before the wait and not after it. What reached it meanwhile comes with the next read.`, channel.w);
|
|
1487
|
+
}
|
|
1488
|
+
// A read says a thing once. A channel asked before a wait and in it gave
|
|
1489
|
+
// the same too_large twice (a review of 10 November 2026).
|
|
1490
|
+
const said = new Set();
|
|
1491
|
+
const fresh = this.attention.slice(noted).filter((note) => {
|
|
1492
|
+
const key = `${note.w}\u0000${note.state}\u0000${note.what}`;
|
|
1493
|
+
if (said.has(key)) return false;
|
|
1494
|
+
said.add(key);
|
|
1495
|
+
return true;
|
|
1496
|
+
});
|
|
1497
|
+
this.attention.splice(noted, this.attention.length - noted, ...fresh);
|
|
1101
1498
|
return messages;
|
|
1102
1499
|
}
|
|
1103
1500
|
}
|