aamio 0.6.3 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +262 -10
- package/bin/aamio.js +406 -39
- package/package.json +7 -1
- package/src/aamio.js +12 -3
- package/src/node-addon-process.js +495 -0
- package/src/node-addons.js +790 -0
- package/src/node-archive.js +349 -0
- package/src/node-doctor.js +292 -0
- package/src/node-lock.js +11 -2
- package/src/node-mcp.js +202 -27
- package/src/node-runtime.js +1507 -73
- package/src/node-scopes.js +208 -0
- package/src/node-trace.js +170 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { isScopeKey, keyHash, scopeAddress } from "./aamio.js";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* The scopes of the local runtime. A scope keeps board posts unlisted for a
|
|
5
|
+
* group. Here each one has a name, and the name is all a caller passes or
|
|
6
|
+
* sees. The key is the read capability: it stays in scopes.json, and a share
|
|
7
|
+
* hands it to a partner sealed, so no model has to hold it. The address is the
|
|
8
|
+
* write capability and is no secret. Unlisted is not private.
|
|
9
|
+
*
|
|
10
|
+
* scopes.json is the file aamio-python and aamio-php keep, in the same shape:
|
|
11
|
+
* a list of {name, key, address}, with taken_from on a scope that came by
|
|
12
|
+
* invitation, and {removed_share} for an invitation whose scope was removed.
|
|
13
|
+
* A home reads the same in all three.
|
|
14
|
+
*
|
|
15
|
+
* Everything here is a function of what it is given. The runtime holds the
|
|
16
|
+
* list and writes the file.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const SCOPE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
20
|
+
const ADDRESS = /^[a-z2-7]{20}$/;
|
|
21
|
+
// One invitation as a channel records a message it handed over, seen_from in
|
|
22
|
+
// node-runtime.js: the hash of its bytes, and who signed them.
|
|
23
|
+
const RECORD = /^[0-9a-f]{64}:(?:[0-9a-f]{16}|-|\?)$/;
|
|
24
|
+
// What a scope share says when scopes.json could not be written, and attention
|
|
25
|
+
// says why. Shorter than the form a kept share takes, which a share is weighed
|
|
26
|
+
// at when that is the longer.
|
|
27
|
+
export const SCOPE_NOT_SAVED = "not kept: could not be saved";
|
|
28
|
+
// How many invitations whose scope was removed scopes.json remembers. One is
|
|
29
|
+
// only read again while its thread lives, and the oldest go first.
|
|
30
|
+
export const SCOPE_REMOVED_KEEP = 256;
|
|
31
|
+
|
|
32
|
+
function isObject(value) {
|
|
33
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function scopeError(code, message) {
|
|
37
|
+
return Object.assign(new Error(message), { code });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The write capability of a scope as it goes on a post: 20 characters of a-z and 2-7. */
|
|
41
|
+
export function isScopeAddress(text) {
|
|
42
|
+
return typeof text === "string" && ADDRESS.test(text);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What an invitation is known by, when it is known: the record a channel keeps of the message. */
|
|
46
|
+
export function isShareRecord(text) {
|
|
47
|
+
return typeof text === "string" && RECORD.test(text);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A scope's name as every runtime takes it. Throws invalid_scope. */
|
|
51
|
+
export function checkScopeName(name) {
|
|
52
|
+
if (typeof name !== "string" || !SCOPE_NAME.test(name)) {
|
|
53
|
+
throw scopeError("invalid_scope", "a scope name is 1 to 64 letters, digits, dots, dashes and underscores, starting with a letter or a digit");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What a scope made elsewhere is given by, checked where nothing has been
|
|
59
|
+
* opened yet: the key, to read and post, or the address, to post only. Gives
|
|
60
|
+
* the address the scope has. Throws invalid_scope.
|
|
61
|
+
*/
|
|
62
|
+
export function checkScopeAdd(name, key = null, address = null) {
|
|
63
|
+
checkScopeName(name);
|
|
64
|
+
if (key === null && address === null) throw scopeError("invalid_scope", "give the key, to read and post, or the address, to post only");
|
|
65
|
+
if (key !== null && !isScopeKey(key)) throw scopeError("invalid_scope", "a scope key is 26 to 64 characters of a-z and 0-9. The 20 character address goes in address");
|
|
66
|
+
if (address !== null && !isScopeAddress(address)) throw scopeError("invalid_scope", "a scope address is the 20 characters of a-z and 2-7 that go on a post");
|
|
67
|
+
const derived = key !== null ? scopeAddress(key) : address;
|
|
68
|
+
if (address !== null && derived !== address) throw scopeError("invalid_scope", "that key does not give that address, so one of the two is wrong");
|
|
69
|
+
return derived;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What a share gives: read, the key, or write, the address alone. Throws invalid_access. */
|
|
73
|
+
export function checkAccess(access) {
|
|
74
|
+
if (access !== "read" && access !== "write") {
|
|
75
|
+
throw scopeError("invalid_access", "access is read, which gives the key to read and post, or write, which gives the address to post only");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** An entry of scopes.json that remembers an invitation whose scope was removed, as aamio-python and aamio-php write it. */
|
|
80
|
+
export function isRemovedShare(entry) {
|
|
81
|
+
return isObject(entry) && Object.keys(entry).length === 1 && isShareRecord(entry.removed_share);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The invitations whose scope was removed, among the entries kept aside. */
|
|
85
|
+
export function removedShares(aside) {
|
|
86
|
+
return new Set(aside.filter(isRemovedShare).map((entry) => entry.removed_share));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The entries of scopes.json this runtime can use, and the rest. The rest are
|
|
91
|
+
* never dropped: they go back into the file on every save as they were, so a
|
|
92
|
+
* hand edit with a typo is there to be corrected rather than gone with the
|
|
93
|
+
* key in it. unusable counts the ones that are not a removed invitation, which
|
|
94
|
+
* is remembered among the rest and is no typo to report.
|
|
95
|
+
*/
|
|
96
|
+
export function usableScopes(entries) {
|
|
97
|
+
const usable = [];
|
|
98
|
+
const aside = [];
|
|
99
|
+
const names = new Set();
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
const key = isObject(entry) ? entry.key ?? null : null;
|
|
102
|
+
const fine = isObject(entry) && typeof entry.name === "string" && SCOPE_NAME.test(entry.name) && !names.has(entry.name.toLowerCase())
|
|
103
|
+
&& isScopeAddress(entry.address) && (key === null || (isScopeKey(key) && scopeAddress(key) === entry.address));
|
|
104
|
+
if (fine) {
|
|
105
|
+
usable.push(entry);
|
|
106
|
+
names.add(entry.name.toLowerCase());
|
|
107
|
+
} else {
|
|
108
|
+
aside.push(entry);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { usable, aside, unusable: aside.filter((entry) => !isRemovedShare(entry)).length };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The scope with this name, apart from upper and lower case, or null. */
|
|
115
|
+
export function scopeCalled(scopes, name) {
|
|
116
|
+
const wanted = String(name).toLowerCase();
|
|
117
|
+
return scopes.find((entry) => entry.name.toLowerCase() === wanted) ?? null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A scope as a caller sees it: never with its key. */
|
|
121
|
+
export function scopeView(entry) {
|
|
122
|
+
return { name: entry.name, address: entry.address, can_read: Boolean(entry.key) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* What adding a scope would hold, and the list it would keep, or null for the
|
|
127
|
+
* list when nothing changes. Nothing is kept here. A name already taken by
|
|
128
|
+
* another address is refused, an address held under another name stays under
|
|
129
|
+
* that name, and a key for a scope held to post only adds reading to it.
|
|
130
|
+
*/
|
|
131
|
+
export function scopeAddPlan(scopes, name, key = null, address = null) {
|
|
132
|
+
const derived = checkScopeAdd(name, key, address);
|
|
133
|
+
const named = scopeCalled(scopes, name);
|
|
134
|
+
if (named !== null && named.address !== derived) {
|
|
135
|
+
throw scopeError("scope_exists", `there is a scope called ${name} already, with another address. Remove it or choose another name`);
|
|
136
|
+
}
|
|
137
|
+
const before = named ?? scopes.find((entry) => entry.address === derived) ?? null;
|
|
138
|
+
if (before === null) {
|
|
139
|
+
const held = { name, key, address: derived };
|
|
140
|
+
return [held, [...scopes, held]];
|
|
141
|
+
}
|
|
142
|
+
if (key !== null && !before.key) {
|
|
143
|
+
// Held to post only until now. The key adds reading.
|
|
144
|
+
const held = { ...before, key };
|
|
145
|
+
return [held, scopes.map((entry) => (entry === before ? held : entry))];
|
|
146
|
+
}
|
|
147
|
+
return [before, null];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The name a scope from a partner is kept under: the partner's name, a dot
|
|
152
|
+
* and the scope's. A partner names its own scopes, and a name is where posts
|
|
153
|
+
* go. Kept under the bare name, a partner could take review before review was
|
|
154
|
+
* made here, and the posts meant for it would go where that partner reads.
|
|
155
|
+
* The partner's part is made of what a name may hold, cut to 24 characters,
|
|
156
|
+
* and is the start of the key's hash when nothing of the name is left.
|
|
157
|
+
*/
|
|
158
|
+
export function sharedScopeName(partner, name) {
|
|
159
|
+
checkScopeName(name);
|
|
160
|
+
const prefix = String(partner.name).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[._-]+|[._-]+$/g, "").slice(0, 24) || keyHash(partner.key).slice(0, 8);
|
|
161
|
+
return `${prefix}.${name}`.slice(0, 64);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The invitations a scope was taken from, as the file holds them. */
|
|
165
|
+
export function takenFrom(entry) {
|
|
166
|
+
return Array.isArray(entry.taken_from) ? entry.taken_from : [];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The list without one scope, and the rest with the invitations it came from
|
|
171
|
+
* remembered, in one write: the same invitation read again then keeps
|
|
172
|
+
* nothing. The newest SCOPE_REMOVED_KEEP are remembered, and the oldest go.
|
|
173
|
+
*/
|
|
174
|
+
export function scopeRemovePlan(scopes, aside, entry) {
|
|
175
|
+
let rest = [...aside];
|
|
176
|
+
const removed = removedShares(rest);
|
|
177
|
+
for (const share of takenFrom(entry)) {
|
|
178
|
+
if (!isShareRecord(share) || removed.has(share)) continue;
|
|
179
|
+
rest.push({ removed_share: share });
|
|
180
|
+
removed.add(share);
|
|
181
|
+
}
|
|
182
|
+
const held = rest.filter(isRemovedShare);
|
|
183
|
+
if (held.length > SCOPE_REMOVED_KEEP) {
|
|
184
|
+
const dropped = new Set(held.slice(0, held.length - SCOPE_REMOVED_KEEP));
|
|
185
|
+
rest = rest.filter((item) => !dropped.has(item));
|
|
186
|
+
}
|
|
187
|
+
return [scopes.filter((item) => item !== entry), rest];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** How many bytes a form of a share takes in a message as it is handed over. */
|
|
191
|
+
const formBytes = (form) => Buffer.byteLength(JSON.stringify(form), "utf8");
|
|
192
|
+
|
|
193
|
+
/** The longer of the two forms a share that may be kept can end in: what a message is weighed at before it is known which. */
|
|
194
|
+
export function longerForm(shown, unsaved) {
|
|
195
|
+
return formBytes(unsaved) > formBytes(shown) ? unsaved : shown;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* value with the scope's key taken out of every string in it. The key goes to
|
|
200
|
+
* the board in the body of a find and reads the whole scope. Nothing the board
|
|
201
|
+
* answers has to carry it, and what a runtime shows is read by a model: a
|
|
202
|
+
* board that sent it back, or a post that quotes it, is shown without it.
|
|
203
|
+
*/
|
|
204
|
+
export function withoutScopeKey(value, key) {
|
|
205
|
+
if (typeof value === "string") return value.split(key).join("[scope key]");
|
|
206
|
+
const text = JSON.stringify(value);
|
|
207
|
+
return text.includes(key) ? JSON.parse(text.split(key).join("[scope key]")) : value;
|
|
208
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { isKey } from "./aamio.js";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* The conversation trace of the local runtime: what was sent to each
|
|
5
|
+
* counterpart and what came back, as hashes and shapes, never content.
|
|
6
|
+
* trace.json is the file aamio-python and aamio-php keep too, in the same
|
|
7
|
+
* shape, so a home reads the same in all three: a book per public key, with
|
|
8
|
+
* the last fifty sends, the last fifty arrivals, and the last message from
|
|
9
|
+
* them that was read and opened here, which the next message to them names as
|
|
10
|
+
* seen.
|
|
11
|
+
*
|
|
12
|
+
* Everything here is a function of what it is given. The runtime holds the
|
|
13
|
+
* books and writes the file.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const TRACE_KEEP = 50;
|
|
17
|
+
export const TRACE_KEYS = 100;
|
|
18
|
+
// How many field names a row keeps, and how long a name may be, in characters,
|
|
19
|
+
// as aamio-python and aamio-php count them. The names of a
|
|
20
|
+
// message that arrived are its sender's to choose: one message of the largest
|
|
21
|
+
// size can carry thousands of them, fifty such from one key were megabytes in a
|
|
22
|
+
// file that is written again at every read, and a board inbox takes any signed
|
|
23
|
+
// key. The fields this runtime and the other two write are a handful of short
|
|
24
|
+
// names, so what is cut is only what was made to be long.
|
|
25
|
+
export const TRACE_FIELDS = 32;
|
|
26
|
+
export const TRACE_FIELD_CHARS = 64;
|
|
27
|
+
|
|
28
|
+
const HASH = /^[0-9a-f]{64}$/;
|
|
29
|
+
|
|
30
|
+
/** The sha256 of a message as the service gives it: 64 lowercase hex characters. */
|
|
31
|
+
export function isMessageHash(value) {
|
|
32
|
+
return typeof value === "string" && HASH.test(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isObject(value) {
|
|
36
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// What a row of the trace may hold, and of what kind. trace.json is this
|
|
40
|
+
// runtime's own file and is still read as if anyone could have written it: a
|
|
41
|
+
// nested field of the wrong type once reached the code that records a send,
|
|
42
|
+
// after the message had gone, and the caller heard an error about a message
|
|
43
|
+
// that was delivered (aamio-python, 25 September 2026).
|
|
44
|
+
export const TRACE_SENT_FIELDS = {
|
|
45
|
+
message_id: "string", at: "int", w: "string", status: "int", outcome: "string", seq: "int", sha256: "hash",
|
|
46
|
+
sealed: "bool", bytes: "int", fields: "list", text_chars: "int", re: "hash", seen: "hash",
|
|
47
|
+
};
|
|
48
|
+
export const TRACE_RECEIVED_FIELDS = {
|
|
49
|
+
at: "int", channel: "string", w: "string", seq: "int", sha256: "hash", encrypted: "bool", format: "string",
|
|
50
|
+
error: "string", fields: "list", text_chars: "int", re: "hash", seen: "hash",
|
|
51
|
+
};
|
|
52
|
+
export const TRACE_READ_FIELDS = { sha256: "hash", seq: "int", w: "string", at: "int" };
|
|
53
|
+
|
|
54
|
+
function traceValue(kind, value) {
|
|
55
|
+
if (kind === "hash") return isMessageHash(value) ? value : null;
|
|
56
|
+
if (kind === "list") {
|
|
57
|
+
if (!Array.isArray(value)) return null;
|
|
58
|
+
// length counts UTF-16 units, never fewer than the characters: only a name longer than that is counted again.
|
|
59
|
+
const short = (item) => typeof item === "string" && (item.length <= TRACE_FIELD_CHARS || [...item].length <= TRACE_FIELD_CHARS);
|
|
60
|
+
return value.filter(short).sort().slice(0, TRACE_FIELDS);
|
|
61
|
+
}
|
|
62
|
+
if (kind === "int") return Number.isInteger(value) ? value : null;
|
|
63
|
+
if (kind === "bool") return typeof value === "boolean" ? value : null;
|
|
64
|
+
return typeof value === "string" ? value : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One row of a trace as the runtimes write it: the fields they know, each of the kind they write or null. */
|
|
68
|
+
export function traceRow(row, fields) {
|
|
69
|
+
if (!isObject(row)) return null;
|
|
70
|
+
const out = {};
|
|
71
|
+
for (const [name, kind] of Object.entries(fields)) if (name in row) out[name] = traceValue(kind, row[name]);
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Rows as the file held them. A row without what names it, the message id of a send or the hash of what arrived, says nothing and is left out. */
|
|
76
|
+
function traceRows(found, fields, keep, required) {
|
|
77
|
+
const rows = Array.isArray(found) ? found.map((item) => traceRow(item, fields)) : [];
|
|
78
|
+
return rows.filter((row) => row !== null && row[required] !== null && row[required] !== undefined).slice(-keep);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** trace.json as the runtimes write it, whatever the file held: a book per key, rows of a known shape, within the bounds. */
|
|
82
|
+
export function normalizeTraces(traces, keep = TRACE_KEEP, keys = TRACE_KEYS) {
|
|
83
|
+
const books = {};
|
|
84
|
+
for (const [key, book] of isObject(traces) ? Object.entries(traces) : []) {
|
|
85
|
+
if (!isKey(key) || !isObject(book)) continue;
|
|
86
|
+
const last = traceRow(book.last_read, TRACE_READ_FIELDS);
|
|
87
|
+
books[key] = {
|
|
88
|
+
sent: traceRows(book.sent, TRACE_SENT_FIELDS, keep, "message_id"),
|
|
89
|
+
received: traceRows(book.received, TRACE_RECEIVED_FIELDS, keep, "sha256"),
|
|
90
|
+
last_read: last && last.sha256 ? last : null,
|
|
91
|
+
active: Number.isInteger(book.active) ? book.active : 0,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const names = Object.keys(books);
|
|
95
|
+
for (const stale of names.sort((a, b) => books[a].active - books[b].active).slice(0, Math.max(0, names.length - keys))) delete books[stale];
|
|
96
|
+
return books;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* One counterpart's record, with each message sent next to what came back
|
|
101
|
+
* about it. A claim is exact. A verified message from them whose seen names a
|
|
102
|
+
* sha256 says their runtime read and opened that message, and nothing more: not
|
|
103
|
+
* the ones sent before it, not another channel's. A message nothing names is
|
|
104
|
+
* unknown, never unread.
|
|
105
|
+
*/
|
|
106
|
+
export function traceView(book, limit) {
|
|
107
|
+
const sentRows = (Array.isArray(book?.sent) ? book.sent : []).filter(isObject);
|
|
108
|
+
const receivedRows = (Array.isArray(book?.received) ? book.received : []).filter(isObject);
|
|
109
|
+
const claimed = new Set(receivedRows.filter((row) => isMessageHash(row.seen)).map((row) => row.seen));
|
|
110
|
+
const answered = new Set(receivedRows.filter((row) => isMessageHash(row.re)).map((row) => row.re));
|
|
111
|
+
const mine = new Map(sentRows.filter((row) => isMessageHash(row.sha256)).map((row) => [row.sha256, row]));
|
|
112
|
+
const delivered = sentRows.filter((row) => row.status === 201 && isMessageHash(row.sha256));
|
|
113
|
+
const noClaim = delivered.filter((row) => !claimed.has(row.sha256)).map((row) => row.message_id ?? null);
|
|
114
|
+
const sent = sentRows.slice(-limit).map((record) => {
|
|
115
|
+
const stored = record.status === 201 && isMessageHash(record.sha256) ? record.sha256 : null;
|
|
116
|
+
return { ...record, seen_by_them: stored !== null && claimed.has(stored) ? true : null, answered_by_them: stored !== null && answered.has(stored) ? true : null };
|
|
117
|
+
});
|
|
118
|
+
const received = receivedRows.slice(-limit).map((record) => {
|
|
119
|
+
const row = { ...record };
|
|
120
|
+
for (const [field, as] of [["re", "answers"], ["seen", "acknowledges"]]) {
|
|
121
|
+
if (!isMessageHash(record[field])) continue;
|
|
122
|
+
const ours = mine.get(record[field]);
|
|
123
|
+
row[as] = ours ? { seq: ours.seq ?? null, sha256: ours.sha256, at: ours.at ?? null } : { sha256: record[field], note: "no confirmed send in this record has this sha256" };
|
|
124
|
+
}
|
|
125
|
+
return row;
|
|
126
|
+
});
|
|
127
|
+
const elsewhere = [...claimed].filter((hash) => !mine.has(hash));
|
|
128
|
+
return { sent, received, no_read_claim: noClaim, note: traceNote(sentRows, receivedRows, delivered, noClaim, elsewhere) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* What the record says, in words, and no more than it says. A send the service
|
|
133
|
+
* did not confirm is not a send it did not store: the answer can be lost after
|
|
134
|
+
* the message was stored, and whoever is told it was not stored sends the text
|
|
135
|
+
* again as new bytes, which is a second message. The words are aamio-python's.
|
|
136
|
+
*/
|
|
137
|
+
export function traceNote(sent, received, delivered, noClaim, elsewhere) {
|
|
138
|
+
if (!sent.length) return "Nothing sent to them is recorded here.";
|
|
139
|
+
const named = received.some((row) => isMessageHash(row.seen));
|
|
140
|
+
const turnedAway = sent.filter((row) => row.outcome === "refused" && row.status !== 201).length;
|
|
141
|
+
const unsettled = sent.length - delivered.length - turnedAway;
|
|
142
|
+
let note;
|
|
143
|
+
if (!delivered.length) {
|
|
144
|
+
note = "No message here has an answer from the service that confirms it was stored.";
|
|
145
|
+
} else if (!named) {
|
|
146
|
+
const said = received.length ? "Nothing from them has named a message of yours as read" : "Nothing has come back from them";
|
|
147
|
+
note = said + ", so each is unknown, not unread. A client that does not send seen says nothing, and neither does one "
|
|
148
|
+
+ "that cannot open what it gets: every message here is sealed to their key, so a reader without it sees an "
|
|
149
|
+
+ "envelope and no text. The sha256 of each message is what the service stored, byte for byte; ask them for "
|
|
150
|
+
+ "the sha256 they read.";
|
|
151
|
+
} else {
|
|
152
|
+
note = `${delivered.length} delivered, and their runtime says it read and opened ${delivered.length - noClaim.length} of them.`;
|
|
153
|
+
if (noClaim.length) {
|
|
154
|
+
note += ` For ${noClaim.length} there is no claim kept here: that is unknown, not unread, since each message from them names only `
|
|
155
|
+
+ "the last of yours it had read when it was written, and this record keeps fifty each way.";
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (elsewhere.length) {
|
|
159
|
+
note += ` They named ${elsewhere.length} message(s) this record cannot match to a send the service confirmed: older than this record, `
|
|
160
|
+
+ "sent from elsewhere, or sent from here without an answer that confirmed it.";
|
|
161
|
+
}
|
|
162
|
+
if (unsettled) {
|
|
163
|
+
note += ` For ${unsettled} no answer settled whether the service stored it, so it may be stored already. Check each one's status `
|
|
164
|
+
+ "and outcome first, and if it must go again, retry it from the outbox: the same bytes are marked a replay "
|
|
165
|
+
+ "where the first arrived, and new bytes would be a second message.";
|
|
166
|
+
}
|
|
167
|
+
if (turnedAway) note += ` The service turned away ${turnedAway}; the status of each says why.`;
|
|
168
|
+
if (named) note += " A claim covers the one message it names. It says their runtime opened it, not that anyone understood it or acted on it.";
|
|
169
|
+
return note;
|
|
170
|
+
}
|