@uuaid/pillar 2.0.3 → 2.3.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/HOSTING-LIMITS.md +33 -14
- package/INTERACTION-CONVENTION.md +10 -6
- package/README.md +118 -12
- package/bin/carrier.mjs +51 -2
- package/bin/pillar-directory.mjs +2 -2
- package/bin/pillar-escrow.mjs +2 -2
- package/bin/pillar-group-v2.mjs +17 -0
- package/bin/pillar-seed.mjs +11 -4
- package/bin/pillar.mjs +727 -73
- package/package.json +2 -1
- package/src/address-book.mjs +135 -10
- package/src/blob/blob.mjs +12 -14
- package/src/blob/http.mjs +4 -4
- package/src/crypto/e2e.mjs +3 -2
- package/src/group-v2-cli.mjs +231 -0
- package/src/groups-v2.mjs +1108 -0
- package/src/groups.mjs +175 -0
- package/src/identity/jcs.mjs +20 -8
- package/src/identity/keychain.mjs +51 -2
- package/src/index.mjs +2 -2
- package/src/mailbox/mailbox.mjs +296 -12
- package/src/net/carrier-client.mjs +29 -7
- package/src/net/carrier-receipt.mjs +77 -0
- package/src/net/carrier-server.mjs +175 -20
- package/src/net/refusals.mjs +4 -2
- package/src/pillar.mjs +416 -35
- package/src/readers.mjs +120 -0
- package/src/receipts.mjs +145 -0
package/src/groups.mjs
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group threads — the `pillar-group` v1 app convention (INTERACTION-CONVENTION.md
|
|
3
|
+
* "group chats"), as the mobile app and chat-web already speak it.
|
|
4
|
+
*
|
|
5
|
+
* There is still no group key and no wire change: a group message is one
|
|
6
|
+
* ordinary 1:1 envelope per member, and the group rides INSIDE each sealed
|
|
7
|
+
* payload as `_group`. What this adds over the old `{ name, members }` tag is
|
|
8
|
+
* what makes it a thread rather than a fan-out:
|
|
9
|
+
*
|
|
10
|
+
* id stable `grp-<16 hex>`, so every member files it under one group
|
|
11
|
+
* msgId `gm-…`, identical across the fan-out copies of one message
|
|
12
|
+
* op message | create | rename | add | leave (roster changes travel
|
|
13
|
+
* as ordinary group messages)
|
|
14
|
+
* keys optional sealing hints, binding-checked on receipt, so members
|
|
15
|
+
* can reply to the whole group without a prior contact exchange
|
|
16
|
+
*
|
|
17
|
+
* Every member keeps its own roster (mailbox `groups` table) and applies the
|
|
18
|
+
* same rules: only a current member may rename or add, nobody can remove
|
|
19
|
+
* anyone but themselves, and a group a non-contact drops you into is a
|
|
20
|
+
* pending invitation until you accept it.
|
|
21
|
+
*/
|
|
22
|
+
import { randomBytes } from "node:crypto";
|
|
23
|
+
import { localIdFromKey } from "./identity/keychain.mjs";
|
|
24
|
+
|
|
25
|
+
export const GROUP_TAG_KEY = "_group";
|
|
26
|
+
export const GROUP_TAG_V = 1;
|
|
27
|
+
export const GROUP_OPS = Object.freeze(["message", "create", "rename", "add", "leave"]);
|
|
28
|
+
export const MAX_GROUP_MEMBERS = 256;
|
|
29
|
+
export const MAX_GROUP_NAME = 100;
|
|
30
|
+
|
|
31
|
+
const GROUP_ID_RE = /^grp-[a-z0-9][a-z0-9-]{3,63}$/i;
|
|
32
|
+
const UUAID_RE = /^uuaid:[a-z0-9-]+:[a-z0-9-]+:[a-z0-9][a-z0-9-]*$/i;
|
|
33
|
+
const PUBKEY_RE = /^[0-9a-f]{64}$/i;
|
|
34
|
+
|
|
35
|
+
export function isGroupId(id) {
|
|
36
|
+
return typeof id === "string" && GROUP_ID_RE.test(id);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function newGroupId() {
|
|
40
|
+
return `grp-${randomBytes(8).toString("hex")}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function newGroupMsgId() {
|
|
44
|
+
return `gm-${Date.now().toString(36)}-${randomBytes(6).toString("hex")}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The key really owns the uuaid (the same check seal() makes). */
|
|
48
|
+
export function keyBindsUuaid(uuaid, publicKeyHex) {
|
|
49
|
+
if (typeof uuaid !== "string" || typeof publicKeyHex !== "string" || !PUBKEY_RE.test(publicKeyHex)) return false;
|
|
50
|
+
try { return localIdFromKey(Buffer.from(publicKeyHex, "hex")) === uuaid.split(":")[3]; }
|
|
51
|
+
catch { return false; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function djb2(s) {
|
|
55
|
+
let h = 5381;
|
|
56
|
+
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
|
|
57
|
+
return h;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A local id for an id-less tag (an older pillar that sent only
|
|
62
|
+
* `{ name, members }`). Same derivation as the mobile app, so the same
|
|
63
|
+
* conversation groups the same way there and here. Never an authority.
|
|
64
|
+
*/
|
|
65
|
+
export function deriveGroupId(name, members) {
|
|
66
|
+
const canon = `${String(name).trim().toLowerCase()}|${[...members].sort().join(",")}`;
|
|
67
|
+
const a = djb2(canon).toString(16).padStart(8, "0");
|
|
68
|
+
const b = djb2(canon.split("").reverse().join("")).toString(16).padStart(8, "0");
|
|
69
|
+
return `grp-ref-${a}${b}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Deduplicated, validated member list, order kept. */
|
|
73
|
+
export function normalizeMembers(members) {
|
|
74
|
+
const out = [];
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
for (const m of Array.isArray(members) ? members : []) {
|
|
77
|
+
if (typeof m !== "string" || !UUAID_RE.test(m) || seen.has(m)) continue;
|
|
78
|
+
seen.add(m);
|
|
79
|
+
out.push(m);
|
|
80
|
+
if (out.length >= MAX_GROUP_MEMBERS) break; // fan-out amplification guard
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Parse a `_group` tag out of a decrypted payload; null when absent or unusable. */
|
|
86
|
+
export function extractGroupTag(payload) {
|
|
87
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
|
|
88
|
+
const g = payload[GROUP_TAG_KEY];
|
|
89
|
+
if (!g || typeof g !== "object" || Array.isArray(g)) return null;
|
|
90
|
+
const name = typeof g.name === "string" ? g.name.trim().slice(0, MAX_GROUP_NAME) : "";
|
|
91
|
+
const members = normalizeMembers(g.members);
|
|
92
|
+
if (!members.length) return null;
|
|
93
|
+
const derivedId = !isGroupId(g.id);
|
|
94
|
+
const id = derivedId ? deriveGroupId(name || "group", members) : g.id;
|
|
95
|
+
let keys;
|
|
96
|
+
if (g.keys && typeof g.keys === "object" && !Array.isArray(g.keys)) {
|
|
97
|
+
for (const [u, pk] of Object.entries(g.keys)) {
|
|
98
|
+
if (UUAID_RE.test(u) && typeof pk === "string" && PUBKEY_RE.test(pk)) (keys ??= {})[u] = pk.toLowerCase();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const msgId = typeof g.msgId === "string" && g.msgId.length > 0 && g.msgId.length <= 64 ? g.msgId : null;
|
|
102
|
+
const op = typeof g.op === "string" && GROUP_OPS.includes(g.op) ? g.op : "message";
|
|
103
|
+
return { v: typeof g.v === "number" ? g.v : GROUP_TAG_V, id, name: name || "Group", members, keys, msgId, op, derivedId };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The `_group` tag for an outbound payload. */
|
|
107
|
+
export function buildGroupTag(group, { msgId, keys, op = "message" }) {
|
|
108
|
+
const tag = { v: GROUP_TAG_V, id: group.id, name: group.name, members: [...group.members], msgId };
|
|
109
|
+
if (keys && Object.keys(keys).length) tag.keys = keys;
|
|
110
|
+
if (op && op !== "message") tag.op = op;
|
|
111
|
+
return tag;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Fold an inbound tag into the local roster (mailbox `groups`). Returns the
|
|
116
|
+
* group as stored, or null when the tag changed nothing we keep.
|
|
117
|
+
*
|
|
118
|
+
* unknown group -> join it; pending when the sender is not a contact. A
|
|
119
|
+
* wire id (not derived here) needs the SENDER on its own
|
|
120
|
+
* roster: the first tag seen for an id sets the roster, so
|
|
121
|
+
* a stranger must not be able to seed it, and after that
|
|
122
|
+
* the real members would be the strangers
|
|
123
|
+
* known group -> only a current MEMBER may change it: `leave` drops the
|
|
124
|
+
* sender, `create`/`rename` set the name, every op unions
|
|
125
|
+
* new members in; a re-`add` naming us clears our `left`
|
|
126
|
+
* key hints -> kept per group, only when the key derives its uuaid;
|
|
127
|
+
* they let us SEAL to a member, and never make anyone a
|
|
128
|
+
* trusted sender (the address book and trust.json do that)
|
|
129
|
+
*/
|
|
130
|
+
export function absorbGroupTag(mailbox, tag, { sender, self, text = "", fromContact = false }) {
|
|
131
|
+
const hints = {};
|
|
132
|
+
for (const [u, pk] of Object.entries(tag.keys ?? {})) {
|
|
133
|
+
if (u !== self && keyBindsUuaid(u, pk)) hints[u] = pk;
|
|
134
|
+
}
|
|
135
|
+
const existing = mailbox.getGroup(tag.id);
|
|
136
|
+
if (!existing) {
|
|
137
|
+
if (!tag.derivedId && !tag.members.includes(sender)) return null;
|
|
138
|
+
const members = tag.members.includes(self) ? tag.members : [...tag.members, self];
|
|
139
|
+
return mailbox.putGroup({
|
|
140
|
+
id: tag.id, name: tag.name, members, keys: hints, createdBy: sender, pending: !fromContact,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
if (!existing.members.includes(sender)) return existing; // strangers cannot touch our roster
|
|
144
|
+
const keys = { ...hints, ...existing.keys }; // first key wins
|
|
145
|
+
if (tag.op === "leave") {
|
|
146
|
+
return mailbox.putGroup({ ...existing, keys, members: existing.members.filter((m) => m !== sender) });
|
|
147
|
+
}
|
|
148
|
+
const members = [...existing.members];
|
|
149
|
+
for (const m of tag.members) if (!members.includes(m) && members.length < MAX_GROUP_MEMBERS) members.push(m);
|
|
150
|
+
const addedUs = tag.op === "add" && String(text).split(",").map((x) => x.trim()).includes(self);
|
|
151
|
+
return mailbox.putGroup({
|
|
152
|
+
...existing,
|
|
153
|
+
keys,
|
|
154
|
+
members,
|
|
155
|
+
name: tag.op === "rename" || tag.op === "create" ? tag.name : existing.name,
|
|
156
|
+
left: existing.left && !addedUs,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Resolve a group by exact id, else by unique case-insensitive name.
|
|
162
|
+
* Throws with the candidates listed when the name is ambiguous or unknown.
|
|
163
|
+
*/
|
|
164
|
+
export function findGroup(mailbox, ref) {
|
|
165
|
+
const byId = mailbox.getGroup(ref);
|
|
166
|
+
if (byId) return byId;
|
|
167
|
+
const all = mailbox.listGroups();
|
|
168
|
+
const hits = all.filter((g) => g.name.toLowerCase() === String(ref).toLowerCase());
|
|
169
|
+
if (hits.length === 1) return hits[0];
|
|
170
|
+
const e = new Error(hits.length
|
|
171
|
+
? `group name "${ref}" is ambiguous: ${hits.map((g) => g.id).join(", ")} — use the id`
|
|
172
|
+
: `no group "${ref}" in this home${all.length ? ` (known: ${all.map((g) => `${g.name} ${g.id}`).join("; ")})` : ""} — list them with: pillar groups`);
|
|
173
|
+
e.code = hits.length ? "group-ambiguous" : "group-unknown";
|
|
174
|
+
throw e;
|
|
175
|
+
}
|
package/src/identity/jcs.mjs
CHANGED
|
@@ -2,16 +2,27 @@
|
|
|
2
2
|
* RFC 8785 JCS canonicalization. Same implementation as the pillar-core reference,
|
|
3
3
|
* kept independent so the pillar-node package has no upward dependency.
|
|
4
4
|
*
|
|
5
|
-
* Numbers
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Numbers — two entry points with one byte rule for integers:
|
|
6
|
+
* jcs() INTEGERS ONLY; floats throw. Every transport document (envelopes,
|
|
7
|
+
* delegations, receipts, escrow terms, blobs, VDA) keeps using it, so
|
|
8
|
+
* what the carrier and escrow refuse is unchanged — escrow v1/v2 rely
|
|
9
|
+
* on that throw to reject a hostile offer (src/escrow/terms.mjs).
|
|
10
|
+
* jcsRfc8785() full RFC 8785 §3.2.2.3 numbers (ECMAScript Number::toString, i.e.
|
|
11
|
+
* JSON.stringify(n)); used only for the SEALED PAYLOAD (crypto/e2e.mjs
|
|
12
|
+
* sealPayload) and documents carried inside it (PAM-1, AinFi). Byte-
|
|
13
|
+
* identical to jcs() for every finite integer. Added 2026-10-24 so an
|
|
14
|
+
* agent can send a device a value such as 21.5; byte-matches
|
|
15
|
+
* @uuaid/pillar-client jcsRfc8785.
|
|
9
16
|
*/
|
|
10
17
|
export function jcs(value) {
|
|
11
|
-
return _ser(value);
|
|
18
|
+
return _ser(value, false);
|
|
12
19
|
}
|
|
13
20
|
|
|
14
|
-
function
|
|
21
|
+
export function jcsRfc8785(value) {
|
|
22
|
+
return _ser(value, true);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function _ser(v, fractions) {
|
|
15
26
|
if (v === null) return "null";
|
|
16
27
|
if (v === true) return "true";
|
|
17
28
|
if (v === false) return "false";
|
|
@@ -19,16 +30,17 @@ function _ser(v) {
|
|
|
19
30
|
if (typeof v === "number") {
|
|
20
31
|
if (!Number.isFinite(v)) throw new Error("JCS: NaN/Infinity not permitted");
|
|
21
32
|
if (Number.isInteger(v)) return String(v);
|
|
33
|
+
if (fractions) return JSON.stringify(v); // RFC 8785 §3.2.2.3
|
|
22
34
|
throw new Error("JCS: non-integer numbers not permitted; use string decimal encoding");
|
|
23
35
|
}
|
|
24
36
|
if (typeof v === "bigint") return v.toString();
|
|
25
|
-
if (Array.isArray(v)) return "[" + v.map(_ser).join(",") + "]";
|
|
37
|
+
if (Array.isArray(v)) return "[" + v.map((x) => _ser(x, fractions)).join(",") + "]";
|
|
26
38
|
if (typeof v === "object") {
|
|
27
39
|
// RFC 8785 §3.2.3: keys sort by their UTF-16 code units, which is exactly
|
|
28
40
|
// JavaScript's default string order. (Comparing UTF-16LE *bytes* is not: it
|
|
29
41
|
// puts "Ā" (U+0100) before "a", and disagreed with @uuaid/pillar-client.)
|
|
30
42
|
const keys = Object.keys(v).sort(); // @rule jcs.code-unit-order
|
|
31
|
-
return "{" + keys.map(k => JSON.stringify(k) + ":" + _ser(v[k])).join(",") + "}";
|
|
43
|
+
return "{" + keys.map(k => JSON.stringify(k) + ":" + _ser(v[k], fractions)).join(",") + "}";
|
|
32
44
|
}
|
|
33
45
|
throw new Error("JCS: unsupported type " + typeof v);
|
|
34
46
|
}
|
|
@@ -65,6 +65,54 @@ export function localIdFromKey(rawPub) {
|
|
|
65
65
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/** Registry namespaces cannot be asserted by possession of a key alone. */
|
|
69
|
+
export const SELF_CERTIFYING_NAMESPACES = Object.freeze(["foundation", "pillar", "agentnet"]);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The namespace `pillar init` mints a NEW agent identity in when no --namespace is given.
|
|
73
|
+
* Expand before contract (owner order 2026-10-24; Uuaid-Codex review K1): 2.2.0 makes every
|
|
74
|
+
* verifier accept foundation, pillar and agentnet but still mints `foundation`, because a
|
|
75
|
+
* `pillar:` identity cannot read its inbox on a pre-2.2.0 carrier. 2.2.1 flips this to
|
|
76
|
+
* `pillar` once the carrier fleet, the main relay and the blob brokers run 2.2.x/1.2.0.
|
|
77
|
+
* Existing keychains keep the UUAID they were saved with. `Keychain.generate`'s own default
|
|
78
|
+
* stays `foundation` for library callers; CLI mint sites pass `mintNamespace(...)` instead.
|
|
79
|
+
*/
|
|
80
|
+
export const NEW_IDENTITY_NAMESPACE = "foundation";
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Carrier-infrastructure identities (pillar-seed, pillar-directory, pillar-escrow) stay in
|
|
84
|
+
* `foundation`: they are spoken to by third-party seeds and DHT peers on any version, and
|
|
85
|
+
* the owner's rule covers the identities people create, not the fleet's service keys
|
|
86
|
+
* (Uuaid-Codex review K2).
|
|
87
|
+
*/
|
|
88
|
+
export const SERVICE_IDENTITY_NAMESPACE = "foundation";
|
|
89
|
+
|
|
90
|
+
/** The namespace to mint in: `requested` if it is self-certifying, else NEW_IDENTITY_NAMESPACE. Throws on any other namespace. */
|
|
91
|
+
export function mintNamespace(requested) {
|
|
92
|
+
if (requested === undefined || requested === null) return NEW_IDENTITY_NAMESPACE;
|
|
93
|
+
if (typeof requested !== "string" || !SELF_CERTIFYING_NAMESPACES.includes(requested)) {
|
|
94
|
+
throw new Error(`namespace must be one of ${SELF_CERTIFYING_NAMESPACES.join(", ")} (a key alone cannot claim ${String(requested)})`);
|
|
95
|
+
}
|
|
96
|
+
return requested;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Rebuild the complete key-derived identity; minting still defaults to foundation. */
|
|
100
|
+
export function keyDerivedUuaidFor(publicKeyHex, { namespace = "foundation", objectType = "agent" } = {}) {
|
|
101
|
+
if (!SELF_CERTIFYING_NAMESPACES.includes(namespace)
|
|
102
|
+
|| typeof objectType !== "string" || !/^[a-z0-9-]+$/.test(objectType)
|
|
103
|
+
|| typeof publicKeyHex !== "string" || !/^[0-9a-f]{64}$/i.test(publicKeyHex)) return null;
|
|
104
|
+
return `uuaid:${namespace}:${objectType}:${localIdFromKey(Buffer.from(publicKeyHex, "hex"))}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Compare the full identity, optionally constraining a path's existing object type. */
|
|
108
|
+
export function keyDerivesUuaid(publicKeyHex, uuaid, objectType = null) {
|
|
109
|
+
if (typeof uuaid !== "string") return false;
|
|
110
|
+
const parts = uuaid.split(":");
|
|
111
|
+
if (parts.length !== 4 || parts[0] !== "uuaid" || (objectType !== null && parts[2] !== objectType)) return false;
|
|
112
|
+
const expected = keyDerivedUuaidFor(publicKeyHex, { namespace: parts[1], objectType: parts[2] });
|
|
113
|
+
return expected !== null && uuaid === expected;
|
|
114
|
+
}
|
|
115
|
+
|
|
68
116
|
export class Keychain {
|
|
69
117
|
constructor(path) {
|
|
70
118
|
this.path = path;
|
|
@@ -78,8 +126,9 @@ export class Keychain {
|
|
|
78
126
|
/**
|
|
79
127
|
* Generate a fresh identity. Never overwrites.
|
|
80
128
|
*
|
|
81
|
-
* uuaidNamespace: default "foundation"
|
|
82
|
-
*
|
|
129
|
+
* uuaidNamespace: default "foundation" (the genesis namespace) for library
|
|
130
|
+
* callers. The Pillar CLIs pass mintNamespace(...) explicitly, so a new CLI
|
|
131
|
+
* identity is `pillar` unless the operator asks for agentnet or foundation.
|
|
83
132
|
* objectType: default "agent"; overridable to "service" etc.
|
|
84
133
|
* localId: default random UUIDv4-like; caller may supply.
|
|
85
134
|
*/
|
package/src/index.mjs
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* Entry point. Not intended for import in browsers today — browser build
|
|
8
8
|
* lives at @uuaid/pillar-browser (later).
|
|
9
9
|
*/
|
|
10
|
-
export { Keychain, localIdFromKey } from "./identity/keychain.mjs";
|
|
11
|
-
export { jcs } from "./identity/jcs.mjs";
|
|
10
|
+
export { Keychain, localIdFromKey, SELF_CERTIFYING_NAMESPACES, NEW_IDENTITY_NAMESPACE, SERVICE_IDENTITY_NAMESPACE, mintNamespace, keyDerivedUuaidFor, keyDerivesUuaid } from "./identity/keychain.mjs";
|
|
11
|
+
export { jcs, jcsRfc8785 } from "./identity/jcs.mjs";
|
|
12
12
|
export { seal, open, decrypt, envelopeSha, keychainSeed, ENVELOPE_VERSION, E2E_ALG } from "./net/envelope.mjs";
|
|
13
13
|
export { DELEGATION_VERSION, verifyDelegationDoc, delegationCoversKind } from "./net/delegation.mjs";
|
|
14
14
|
export { sealPayload, openPayload, edPubToX25519, edSeedToX25519 } from "./crypto/e2e.mjs";
|
package/src/mailbox/mailbox.mjs
CHANGED
|
@@ -69,12 +69,99 @@ export class Mailbox {
|
|
|
69
69
|
this.db.pragma("journal_mode = WAL");
|
|
70
70
|
this.db.pragma("synchronous = NORMAL");
|
|
71
71
|
this.db.exec(SCHEMA);
|
|
72
|
+
// Several processes open one home at once (the courier's `inbox` beside a
|
|
73
|
+
// daemon's `recv`). A check-then-ALTER outside a lock lets the loser throw
|
|
74
|
+
// "duplicate column name", so every migration runs in ONE immediate
|
|
75
|
+
// transaction that re-reads table_info inside it: the second opener waits
|
|
76
|
+
// on the write lock (busy timeout) and then finds nothing left to add.
|
|
77
|
+
this.db.transaction(() => this._migrate()).immediate();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_migrate() {
|
|
72
81
|
// v2 migration: quarantine marker (trust-policy gate). Nullable text =
|
|
73
82
|
// reason; null = normal mail. Chain hashes are unaffected (metadata only).
|
|
74
83
|
const cols = this.db.pragma("table_info(inbox)").map(c => c.name);
|
|
75
84
|
if (!cols.includes("quarantined")) {
|
|
76
85
|
this.db.exec("alter table inbox add column quarantined text");
|
|
77
86
|
}
|
|
87
|
+
// v3 migration. Every column is metadata beside the chain (the chain
|
|
88
|
+
// hashes envelope_sha256 only), so none of it can alter an audit.
|
|
89
|
+
// inbox.pulled_by the reader key of the process that stored the row;
|
|
90
|
+
// lets a reader see that SOMEONE ELSE pulled its mail.
|
|
91
|
+
// inbox.group_id the pillar-group id the sealed payload carried.
|
|
92
|
+
// outbox.kind/thread/group_id what a receipt is matched against: the
|
|
93
|
+
// sender cannot decrypt its own sealed outbox rows.
|
|
94
|
+
// outbox.recipient_* the recipient's own signed ack / read receipt.
|
|
95
|
+
// `status = 'delivered'` keeps meaning what it always
|
|
96
|
+
// meant: a CARRIER accepted the envelope.
|
|
97
|
+
const addCols = (table, specs) => {
|
|
98
|
+
const have = new Set(this.db.pragma(`table_info(${table})`).map(c => c.name));
|
|
99
|
+
for (const [name, type] of specs) {
|
|
100
|
+
if (!have.has(name)) this.db.exec(`alter table ${table} add column ${name} ${type}`);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
addCols("inbox", [["pulled_by", "text"], ["group_id", "text"]]);
|
|
104
|
+
addCols("outbox", [
|
|
105
|
+
["kind", "text"], ["thread", "text"], ["group_id", "text"], ["group_msg_id", "text"],
|
|
106
|
+
["recipient_ack_at", "text"], ["recipient_ack_id", "text"], ["recipient_read_at", "text"],
|
|
107
|
+
]);
|
|
108
|
+
this.db.exec(`
|
|
109
|
+
create table if not exists readers (
|
|
110
|
+
name text primary key,
|
|
111
|
+
cursor integer not null default 0,
|
|
112
|
+
pid integer,
|
|
113
|
+
host text,
|
|
114
|
+
cwd text,
|
|
115
|
+
command text,
|
|
116
|
+
last_seen text
|
|
117
|
+
);
|
|
118
|
+
create table if not exists reader_leases (
|
|
119
|
+
reader text not null,
|
|
120
|
+
host text not null,
|
|
121
|
+
pid integer not null,
|
|
122
|
+
cwd text,
|
|
123
|
+
command text,
|
|
124
|
+
started_at text not null,
|
|
125
|
+
lease_until text not null,
|
|
126
|
+
primary key (reader, host, pid)
|
|
127
|
+
);
|
|
128
|
+
create table if not exists groups (
|
|
129
|
+
id text primary key,
|
|
130
|
+
name text not null,
|
|
131
|
+
members text not null,
|
|
132
|
+
keys text,
|
|
133
|
+
created_by text,
|
|
134
|
+
created_at text not null,
|
|
135
|
+
updated_at text not null,
|
|
136
|
+
pending integer not null default 0,
|
|
137
|
+
left_at text
|
|
138
|
+
);
|
|
139
|
+
create table if not exists group_v2_sendq (
|
|
140
|
+
id text primary key,
|
|
141
|
+
group_id text not null,
|
|
142
|
+
recipient text not null,
|
|
143
|
+
recipient_key text,
|
|
144
|
+
op text not null,
|
|
145
|
+
mv_version integer not null,
|
|
146
|
+
want_from integer,
|
|
147
|
+
want_to integer,
|
|
148
|
+
manifest_hash text,
|
|
149
|
+
conflicts_with text,
|
|
150
|
+
queued_at text not null,
|
|
151
|
+
state text not null default 'queued',
|
|
152
|
+
state_at text,
|
|
153
|
+
envelope_id text,
|
|
154
|
+
reason text
|
|
155
|
+
);
|
|
156
|
+
create index if not exists group_v2_sendq_state on group_v2_sendq (state, group_id);
|
|
157
|
+
create index if not exists inbox_group on inbox (group_id);
|
|
158
|
+
create index if not exists outbox_thread on outbox (recipient, thread);
|
|
159
|
+
`);
|
|
160
|
+
// groups.v2_synced_at: set when the pillar-group/2 sync writes the row (the
|
|
161
|
+
// verified head minus leavers). A marked row is a v2 group's for good: v1 tags
|
|
162
|
+
// and `pillar group` verbs naming it go to v2 (Pillar._receive, groupCommandRoute),
|
|
163
|
+
// and with its v2 store file gone it is cleared, not handed back to v1.
|
|
164
|
+
addCols("groups", [["v2_synced_at", "text"]]);
|
|
78
165
|
}
|
|
79
166
|
|
|
80
167
|
close() { try { this.db.close(); } catch (_e) {} }
|
|
@@ -108,16 +195,17 @@ export class Mailbox {
|
|
|
108
195
|
* and MUST include at least `id`, `recipient`, `createdAt`. Chain hash is computed
|
|
109
196
|
* atomically with the insert; concurrent appends are serialized via the SQLite transaction.
|
|
110
197
|
*/
|
|
111
|
-
appendOutbound(envelope) {
|
|
198
|
+
appendOutbound(envelope, { thread = null, groupId = null, groupMsgId = null } = {}) {
|
|
112
199
|
return this.db.transaction(() => {
|
|
113
200
|
const envSha = Mailbox.envelopeSha(envelope);
|
|
114
201
|
const prev = this._getHead("outbox");
|
|
115
202
|
const chainInput = prev == null ? CHAIN_SEED + envSha : prev + envSha;
|
|
116
203
|
const chain = createHash("sha256").update(chainInput).digest("hex");
|
|
117
204
|
this.db.prepare(`
|
|
118
|
-
insert into outbox (envelope_id, envelope_sha256, recipient, created_at, envelope_json, chain_hash, status)
|
|
119
|
-
values (?, ?, ?, ?, ?, ?, 'pending')
|
|
120
|
-
`).run(envelope.id, envSha, envelope.recipient, envelope.createdAt, JSON.stringify(envelope), chain
|
|
205
|
+
insert into outbox (envelope_id, envelope_sha256, recipient, created_at, envelope_json, chain_hash, status, kind, thread, group_id, group_msg_id)
|
|
206
|
+
values (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)
|
|
207
|
+
`).run(envelope.id, envSha, envelope.recipient, envelope.createdAt, JSON.stringify(envelope), chain,
|
|
208
|
+
envelope.kind ?? null, thread ?? envelope.id, groupId, groupMsgId);
|
|
121
209
|
this._setHead("outbox", chain);
|
|
122
210
|
return { envelopeId: envelope.id, envelopeSha256: envSha, chainHash: chain };
|
|
123
211
|
})();
|
|
@@ -126,23 +214,24 @@ export class Mailbox {
|
|
|
126
214
|
/**
|
|
127
215
|
* Append an incoming envelope. Same shape. MUST be idempotent on envelope.id;
|
|
128
216
|
* a duplicate delivery does NOT re-append and does NOT advance the chain.
|
|
217
|
+
* `pulledBy` is the reader key of the storing process (metadata only).
|
|
129
218
|
*/
|
|
130
|
-
appendInbound(envelope) {
|
|
219
|
+
appendInbound(envelope, { pulledBy = null } = {}) {
|
|
131
220
|
return this.db.transaction(() => {
|
|
132
|
-
const existing = this.db.prepare("select envelope_id, envelope_sha256 from inbox where envelope_id = ?").get(envelope.id);
|
|
221
|
+
const existing = this.db.prepare("select seq, envelope_id, envelope_sha256 from inbox where envelope_id = ?").get(envelope.id);
|
|
133
222
|
if (existing) {
|
|
134
|
-
return { duplicate: true, envelopeId: envelope.id, envelopeSha256: existing.envelope_sha256 };
|
|
223
|
+
return { duplicate: true, seq: existing.seq, envelopeId: envelope.id, envelopeSha256: existing.envelope_sha256 };
|
|
135
224
|
}
|
|
136
225
|
const envSha = Mailbox.envelopeSha(envelope);
|
|
137
226
|
const prev = this._getHead("inbox");
|
|
138
227
|
const chainInput = prev == null ? CHAIN_SEED + envSha : prev + envSha;
|
|
139
228
|
const chain = createHash("sha256").update(chainInput).digest("hex");
|
|
140
|
-
this.db.prepare(`
|
|
141
|
-
insert into inbox (envelope_id, envelope_sha256, sender, received_at, envelope_json, chain_hash)
|
|
142
|
-
values (?, ?, ?, ?, ?, ?)
|
|
143
|
-
`).run(envelope.id, envSha, envelope.sender ?? envelope.from ?? "unknown", new Date().toISOString(), JSON.stringify(envelope), chain);
|
|
229
|
+
const r = this.db.prepare(`
|
|
230
|
+
insert into inbox (envelope_id, envelope_sha256, sender, received_at, envelope_json, chain_hash, pulled_by)
|
|
231
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
232
|
+
`).run(envelope.id, envSha, envelope.sender ?? envelope.from ?? "unknown", new Date().toISOString(), JSON.stringify(envelope), chain, pulledBy);
|
|
144
233
|
this._setHead("inbox", chain);
|
|
145
|
-
return { duplicate: false, envelopeId: envelope.id, envelopeSha256: envSha, chainHash: chain };
|
|
234
|
+
return { duplicate: false, seq: Number(r.lastInsertRowid), envelopeId: envelope.id, envelopeSha256: envSha, chainHash: chain };
|
|
146
235
|
})();
|
|
147
236
|
}
|
|
148
237
|
|
|
@@ -191,11 +280,206 @@ export class Mailbox {
|
|
|
191
280
|
.run(String(reason).slice(0, 100), envelopeId);
|
|
192
281
|
}
|
|
193
282
|
|
|
283
|
+
// ── pillar-group/2 send queue: what the v2 receiver asked to send ─────────
|
|
284
|
+
// Nothing is sent from here. A row leaves only through an explicit command
|
|
285
|
+
// (Pillar.syncGroupV2), and an egress filter can read every queued recipient
|
|
286
|
+
// with: select recipient from group_v2_sendq where state = 'queued'.
|
|
287
|
+
|
|
288
|
+
static GROUP_V2_SENDQ_MAX = 1000;
|
|
289
|
+
|
|
290
|
+
/** Queue sends ({ to, key?, op, mvVersion, want?, manifestHash?, conflictsWith? }). Returns the ids queued. */
|
|
291
|
+
enqueueGroupV2Sends(groupId, sends, { now = new Date().toISOString(), newId }) {
|
|
292
|
+
const ids = [];
|
|
293
|
+
this.db.transaction(() => {
|
|
294
|
+
let queued = this.db.prepare("select count(*) as n from group_v2_sendq where state = 'queued'").get().n;
|
|
295
|
+
const ins = this.db.prepare(`insert into group_v2_sendq (id, group_id, recipient, recipient_key, op, mv_version, want_from, want_to, manifest_hash, conflicts_with, queued_at)
|
|
296
|
+
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
297
|
+
for (const s of sends) {
|
|
298
|
+
if (queued >= Mailbox.GROUP_V2_SENDQ_MAX) break; // bounded: the caller counts what did not fit
|
|
299
|
+
const id = newId();
|
|
300
|
+
ins.run(id, groupId, s.to, s.key ?? null, s.op, s.mvVersion, s.want?.from ?? null, s.want ? s.want.to : null,
|
|
301
|
+
s.manifestHash ?? null, s.conflictsWith ?? null, now);
|
|
302
|
+
ids.push(id);
|
|
303
|
+
queued++;
|
|
304
|
+
}
|
|
305
|
+
}).immediate();
|
|
306
|
+
return ids;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Queued rows, oldest first; one group or all. */
|
|
310
|
+
queuedGroupV2Sends(groupId = null) {
|
|
311
|
+
return groupId
|
|
312
|
+
? this.db.prepare("select * from group_v2_sendq where state = 'queued' and group_id = ? order by queued_at, rowid").all(groupId)
|
|
313
|
+
: this.db.prepare("select * from group_v2_sendq where state = 'queued' order by queued_at, rowid").all();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** state: sent | dropped (final), or queued with a reason (a failed attempt). */
|
|
317
|
+
markGroupV2Send(id, state, { envelopeId = null, reason = null } = {}) {
|
|
318
|
+
this.db.prepare("update group_v2_sendq set state = ?, state_at = ?, envelope_id = coalesce(?, envelope_id), reason = ? where id = ?")
|
|
319
|
+
.run(state, new Date().toISOString(), envelopeId, reason ? String(reason).slice(0, 200) : null, id);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
clearQuarantine(envelopeId) {
|
|
323
|
+
this.db.prepare("update inbox set quarantined = null where envelope_id = ?").run(envelopeId);
|
|
324
|
+
}
|
|
325
|
+
|
|
194
326
|
markInboxRead(envelopeId) {
|
|
195
327
|
this.db.prepare("update inbox set read_at = ? where envelope_id = ? and read_at is null")
|
|
196
328
|
.run(new Date().toISOString(), envelopeId);
|
|
197
329
|
}
|
|
198
330
|
|
|
331
|
+
setInboundGroup(envelopeId, groupId) {
|
|
332
|
+
this.db.prepare("update inbox set group_id = ? where envelope_id = ?").run(groupId, envelopeId);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
inboxTipSeq() {
|
|
336
|
+
return this.db.prepare("select coalesce(max(seq), 0) as n from inbox").get().n;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Non-quarantined rows after `seq`, oldest first — what a reader has not seen. */
|
|
340
|
+
inboxAfter(seq = 0, limit = 500) {
|
|
341
|
+
return this.db.prepare("select * from inbox where seq > ? and quarantined is null order by seq asc limit ?").all(seq, limit);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ── Readers ────────────────────────────────────────────────────────────────
|
|
345
|
+
// A reader is one consumer of this home's inbox, with its OWN cursor over
|
|
346
|
+
// the local inbox table. The carrier cursor stays shared (carrier_cursors):
|
|
347
|
+
// fetching is idempotent on envelope id, so whoever pulls, every reader
|
|
348
|
+
// still sees the row after its own cursor.
|
|
349
|
+
|
|
350
|
+
getReader(name) {
|
|
351
|
+
return this.db.prepare("select * from readers where name = ?").get(name) ?? null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
listReaders() {
|
|
355
|
+
return this.db.prepare("select * from readers order by name").all();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Create or refresh a reader's last-run details; never moves its cursor. */
|
|
359
|
+
touchReader(name, { pid = null, host = null, cwd = null, command = null, cursor = 0 } = {}) {
|
|
360
|
+
this.db.prepare(`
|
|
361
|
+
insert into readers (name, cursor, pid, host, cwd, command, last_seen)
|
|
362
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
363
|
+
on conflict(name) do update set pid = excluded.pid, host = excluded.host, cwd = excluded.cwd,
|
|
364
|
+
command = excluded.command, last_seen = excluded.last_seen
|
|
365
|
+
`).run(name, cursor, pid, host, cwd, command, new Date().toISOString());
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
setReaderCursor(name, seq) {
|
|
369
|
+
this.db.prepare("update readers set cursor = max(cursor, ?) where name = ?").run(seq, name);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** A long-running consumer (pillar recv) holds a lease; renew it on a heartbeat. */
|
|
373
|
+
holdLease(reader, { host, pid, cwd = null, command = null, ttlMs = 90_000 }) {
|
|
374
|
+
const now = new Date();
|
|
375
|
+
this.db.prepare(`
|
|
376
|
+
insert into reader_leases (reader, host, pid, cwd, command, started_at, lease_until)
|
|
377
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
378
|
+
on conflict(reader, host, pid) do update set lease_until = excluded.lease_until
|
|
379
|
+
`).run(reader, host, pid, cwd, command, now.toISOString(), new Date(now.getTime() + ttlMs).toISOString());
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
releaseLease(reader, { host, pid }) {
|
|
383
|
+
this.db.prepare("delete from reader_leases where reader = ? and host = ? and pid = ?").run(reader, host, pid);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Leases not yet expired. The caller decides which are really alive (pid check). */
|
|
387
|
+
unexpiredLeases() {
|
|
388
|
+
this.db.prepare("delete from reader_leases where lease_until < ?").run(new Date(Date.now() - 3600_000).toISOString());
|
|
389
|
+
return this.db.prepare("select * from reader_leases where lease_until >= ? order by started_at").all(new Date().toISOString());
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ── Receipts (the recipient's own signed ack / read receipt) ──────────────
|
|
393
|
+
|
|
394
|
+
/** Record a recipient-signed ack. Only for a row actually sent TO `from`. */
|
|
395
|
+
markOutboundAcked(envelopeId, { from, ackId = null, at = null }) {
|
|
396
|
+
const r = this.db.prepare(`
|
|
397
|
+
update outbox set recipient_ack_at = coalesce(recipient_ack_at, ?), recipient_ack_id = coalesce(recipient_ack_id, ?)
|
|
398
|
+
where envelope_id = ? and recipient = ?
|
|
399
|
+
`).run(at ?? new Date().toISOString(), ackId, envelopeId, from);
|
|
400
|
+
return r.changes > 0;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Mark rows read (read implies acked: a missing ack is backfilled). */
|
|
404
|
+
markOutboundRead(envelopeIds, { at = null } = {}) {
|
|
405
|
+
const ts = at ?? new Date().toISOString();
|
|
406
|
+
const stmt = this.db.prepare(`
|
|
407
|
+
update outbox set recipient_read_at = coalesce(recipient_read_at, ?), recipient_ack_at = coalesce(recipient_ack_at, ?)
|
|
408
|
+
where envelope_id = ?
|
|
409
|
+
`);
|
|
410
|
+
let n = 0;
|
|
411
|
+
this.db.transaction(() => { for (const id of envelopeIds) n += stmt.run(ts, ts, id).changes; })();
|
|
412
|
+
return n;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Outbound rows to one recipient, oldest first (receipt matching). */
|
|
416
|
+
outboundTo(recipient) {
|
|
417
|
+
return this.db.prepare("select * from outbox where recipient = ? order by seq asc").all(recipient);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
outboxRecent(limit = 20) {
|
|
421
|
+
return this.db.prepare("select * from outbox order by seq desc limit ?").all(limit);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ── Groups (pillar-group v1 rosters, INTERACTION-CONVENTION.md) ───────────
|
|
425
|
+
|
|
426
|
+
_groupRow(row) {
|
|
427
|
+
if (!row) return null;
|
|
428
|
+
return {
|
|
429
|
+
id: row.id,
|
|
430
|
+
name: row.name,
|
|
431
|
+
members: JSON.parse(row.members),
|
|
432
|
+
keys: row.keys ? JSON.parse(row.keys) : {},
|
|
433
|
+
createdBy: row.created_by,
|
|
434
|
+
createdAt: row.created_at,
|
|
435
|
+
updatedAt: row.updated_at,
|
|
436
|
+
pending: !!row.pending,
|
|
437
|
+
left: !!row.left_at,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
getGroup(id) {
|
|
442
|
+
return this._groupRow(this.db.prepare("select * from groups where id = ?").get(id));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
listGroups() {
|
|
446
|
+
return this.db.prepare("select * from groups order by updated_at desc").all().map(r => this._groupRow(r));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Mark a groups row as written by the pillar-group/2 sync (see _migrate). The row must exist. */
|
|
450
|
+
markGroupV2(id, at = new Date().toISOString()) {
|
|
451
|
+
this.db.prepare("update groups set v2_synced_at = ? where id = ?").run(at, id);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
isGroupV2Marked(id) {
|
|
455
|
+
return !!this.db.prepare("select v2_synced_at from groups where id = ?").get(id)?.v2_synced_at;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Ids of every row the v2 sync has marked. */
|
|
459
|
+
groupV2MarkedIds() {
|
|
460
|
+
return this.db.prepare("select id from groups where v2_synced_at is not null order by id").all().map(r => r.id);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
putGroup(g) {
|
|
464
|
+
const now = new Date().toISOString();
|
|
465
|
+
this.db.prepare(`
|
|
466
|
+
insert into groups (id, name, members, keys, created_by, created_at, updated_at, pending, left_at)
|
|
467
|
+
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
468
|
+
on conflict(id) do update set name = excluded.name, members = excluded.members, keys = excluded.keys,
|
|
469
|
+
updated_at = excluded.updated_at, pending = excluded.pending, left_at = excluded.left_at
|
|
470
|
+
`).run(g.id, g.name, JSON.stringify(g.members), JSON.stringify(g.keys ?? {}), g.createdBy ?? null,
|
|
471
|
+
g.createdAt ?? now, now, g.pending ? 1 : 0, g.left ? (g.leftAt ?? now) : null);
|
|
472
|
+
return this.getGroup(g.id);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
groupInbound(groupId, limit = 50) {
|
|
476
|
+
return this.db.prepare("select * from inbox where group_id = ? and quarantined is null order by seq desc limit ?").all(groupId, limit).reverse();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
groupOutbound(groupId, limit = 200) {
|
|
480
|
+
return this.db.prepare("select * from outbox where group_id = ? order by seq desc limit ?").all(groupId, limit).reverse();
|
|
481
|
+
}
|
|
482
|
+
|
|
199
483
|
/**
|
|
200
484
|
* Recompute the outbox chain from scratch and compare to stored chain_hash column.
|
|
201
485
|
* Returns { ok, badSeq? }. Used by adversarial tests + operator-driven audits.
|