@uuaid/pillar 0.2.0-alpha.13 → 0.2.0-alpha.16
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/INTERACTION-CONVENTION.md +192 -0
- package/README.md +275 -11
- package/bin/pillar-blob.mjs +112 -0
- package/bin/pillar-directory.mjs +231 -0
- package/bin/pillar-seed.mjs +112 -13
- package/bin/pillar.mjs +6 -2
- package/package.json +20 -12
- package/src/blob/blob.mjs +426 -0
- package/src/blob/broker.mjs +129 -0
- package/src/blob/http.mjs +196 -0
- package/src/blob/store.mjs +55 -0
- package/src/identity/tier.mjs +24 -3
- package/src/index.mjs +24 -1
- package/src/net/carrier-client.mjs +55 -9
- package/src/net/carrier-server.mjs +169 -11
- package/src/net/delegation.mjs +152 -0
- package/src/net/envelope.mjs +81 -0
- package/src/net/seed-registry.mjs +467 -0
- package/src/net/seed-tier.mjs +185 -0
- package/src/pillar.mjs +96 -3
|
@@ -66,3 +66,195 @@ and put the real verb inside the sealed payload.
|
|
|
66
66
|
sends a `request` and blocks until the `ack`+`reply` arrive (default
|
|
67
67
|
120s timeout), printing: request id → ack latency → reply latency → text.
|
|
68
68
|
Synchronous Q→A over the same carrier as everything else.
|
|
69
|
+
|
|
70
|
+
## App-level convention: read receipts (`pillar-read-receipt`)
|
|
71
|
+
|
|
72
|
+
*Status: app-level convention, first implemented by the Pillar mobile app.
|
|
73
|
+
No wire change — it rides the existing `ack` kind inside an ordinary
|
|
74
|
+
E2E-encrypted payload; carriers and non-participating clients are unaffected.*
|
|
75
|
+
|
|
76
|
+
A client signals "I have seen your messages up to here" by sending a sealed
|
|
77
|
+
`ack` envelope whose decrypted payload is exactly:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{ "v": 1, "type": "pillar-read-receipt", "upTo": "env-…", "thread": "env-…" }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- `upTo` — id of the most recent inbound envelope the reader has seen.
|
|
84
|
+
- `thread` — the thread root id (rule 1) the receipt applies to.
|
|
85
|
+
- `inReplyTo` is deliberately omitted; `upTo` plays that role, and keeping
|
|
86
|
+
the payload free of it lets older senders ignore the ack without
|
|
87
|
+
mis-marking a single message as delivered.
|
|
88
|
+
|
|
89
|
+
Semantics for the receiving (original sender) side:
|
|
90
|
+
|
|
91
|
+
- Mark every outbound envelope in `thread` created at or before `upTo` as
|
|
92
|
+
**read**; read implies delivered, so a missing ack state is backfilled.
|
|
93
|
+
- Only honor receipts whose sender is the thread's counterparty.
|
|
94
|
+
|
|
95
|
+
Etiquette for the sending (reader) side:
|
|
96
|
+
|
|
97
|
+
- Send when a thread is opened and when a new inbound envelope arrives while
|
|
98
|
+
the thread is open — at most one receipt per `upTo` value per view.
|
|
99
|
+
- Only send to known contacts (same gate as auto-acks; sealing needs the
|
|
100
|
+
peer's pubkey anyway).
|
|
101
|
+
- Read receipts MUST NOT be re-acked (rule 2 covers `request`/`reply` only),
|
|
102
|
+
SHOULD NOT be rendered as messages, and MUST NOT increment unread
|
|
103
|
+
counters.
|
|
104
|
+
- Privacy is symmetric (WhatsApp-style): a client configured not to send
|
|
105
|
+
read receipts also ignores incoming ones.
|
|
106
|
+
|
|
107
|
+
### What a read receipt does NOT mean
|
|
108
|
+
|
|
109
|
+
**A receipt proves that the endpoint holding the key acknowledged the
|
|
110
|
+
envelope. It does NOT prove that the human, agent or seat capable of acting
|
|
111
|
+
on the content has read it.** Implementations MUST NOT present it as more
|
|
112
|
+
than that, and MUST NOT let a UI label ("Read", a blue tick) — or a
|
|
113
|
+
machine-readable field name in a ledger or API response (e.g.
|
|
114
|
+
`delivery_verified: true`) — imply comprehension, agreement, or that a task
|
|
115
|
+
has been picked up. The prohibition binds the data model, not just the
|
|
116
|
+
presentation layer: a field name no human ever renders can oversell a
|
|
117
|
+
receipt exactly as effectively as a checkmark can.
|
|
118
|
+
|
|
119
|
+
This is not hypothetical. In a fleet deployment (2026-09-04) a governance
|
|
120
|
+
halt was delivered, acked in 21 s and replied to in one inbox surface while
|
|
121
|
+
the working session that actually held the capability never saw it: a fully
|
|
122
|
+
green receipt above a structurally ignorant actor. A receipt is evidence
|
|
123
|
+
about a *mailbox*, never about an *understanding*.
|
|
124
|
+
|
|
125
|
+
Consequences for anyone wiring receipts into an ack/receipt ledger:
|
|
126
|
+
|
|
127
|
+
- Label the state after the surface that produced it ("acknowledged in
|
|
128
|
+
inbox"), never after the actor you hope is behind it.
|
|
129
|
+
- A receipt is a lower bound on delivery and says nothing about routing
|
|
130
|
+
*inside* the recipient. If the recipient fans work out to other sessions,
|
|
131
|
+
seats or tabs, that hop is invisible here and needs its own evidence —
|
|
132
|
+
and that evidence has an owner: the RECIPIENT, not the sender and not the
|
|
133
|
+
courier, owes a second signal originating from the acting seat. An
|
|
134
|
+
unowned requirement is exactly how a green receipt ended up sitting above
|
|
135
|
+
a structurally ignorant actor in the incident above.
|
|
136
|
+
- Never close, escalate or exonerate on a receipt alone. It answers "did the
|
|
137
|
+
bytes arrive and get opened", which is strictly weaker than "was this
|
|
138
|
+
acted on" — and it is the second question that decisions usually turn on.
|
|
139
|
+
|
|
140
|
+
The receipt is still worth having: it is signed by the recipient's key, so it
|
|
141
|
+
is stronger evidence of arrival than any sender-side or render-side proxy
|
|
142
|
+
(e.g. reading a UI surface back). Claim exactly that much and no more.
|
|
143
|
+
|
|
144
|
+
## Handle convention (`...`)
|
|
145
|
+
|
|
146
|
+
*Status: app-level convention, first implemented by the Pillar mobile app.
|
|
147
|
+
Display/UX-level only — the wire protocol continues to carry full UUAIDs in
|
|
148
|
+
every envelope; nothing in this section changes any protocol behavior.*
|
|
149
|
+
|
|
150
|
+
The handle prefix in the Pillar ecosystem is **`...` (three dots)** — NOT
|
|
151
|
+
`@` or `#`. The ecosystem already truncates UUAIDs for display as
|
|
152
|
+
`…a2e0d9c6`; `...` formalizes that visual language into a handle syntax.
|
|
153
|
+
|
|
154
|
+
### Grammar
|
|
155
|
+
|
|
156
|
+
- `...<name>` — a directory-resolved handle: three dots, then
|
|
157
|
+
`[a-z0-9-]+` (case-insensitive). Matched case-insensitively against the
|
|
158
|
+
carrier directory's agent `name` and `role` fields (e.g. `...thoth`,
|
|
159
|
+
`...dbdash`). Resolution prefers an exact name/role match, then a prefix
|
|
160
|
+
match, then a substring match; the first non-empty tier wins. Multiple
|
|
161
|
+
matches → the client shows a picker; zero matches → a clear
|
|
162
|
+
"no agent found" error.
|
|
163
|
+
- `...<hex-suffix>` — three dots, then 6+ hex chars (case-insensitive).
|
|
164
|
+
Matched against the tail of directory agents' UUAID localIds AND their
|
|
165
|
+
pubkey-derived localIds (dashes stripped before comparison, e.g.
|
|
166
|
+
`...7c801eba`). If a suffix matches nobody in the directory it is NOT
|
|
167
|
+
usable on its own: the user must also supply the full UUAID + public key
|
|
168
|
+
— a suffix alone is never enough to trust.
|
|
169
|
+
|
|
170
|
+
Parsing is strict: exactly three leading dots (whitespace-trimmed). `..x`,
|
|
171
|
+
`....x`, a bare `...`, and characters outside the two charsets all fail to
|
|
172
|
+
parse. A value that is both a legal name and 6+ hex chars (`...cafe`,
|
|
173
|
+
`...decade`) is tried as a NAME first; suffix matching applies only when
|
|
174
|
+
name resolution finds nobody (documented decision: name-then-suffix, so a
|
|
175
|
+
directory agent literally named `decade` always beats a localId-tail
|
|
176
|
+
collision).
|
|
177
|
+
|
|
178
|
+
### Resolution and trust rules
|
|
179
|
+
|
|
180
|
+
- A handle ALWAYS resolves to a `(uuaid, publicKey)` pair taken from the
|
|
181
|
+
directory or an existing contact. A pubkey-less resolution MUST NOT be
|
|
182
|
+
accepted for sending — candidates without a valid 64-hex Ed25519 public
|
|
183
|
+
key are dropped before matching.
|
|
184
|
+
- When a handle resolves, the client shows the matched name + full UUAID +
|
|
185
|
+
public-key suffix and lets the user eyeball the binding before adding
|
|
186
|
+
(trust-on-first-use with visible binding). The resolved fields remain
|
|
187
|
+
editable.
|
|
188
|
+
|
|
189
|
+
Reference implementation: `apps/mobile/src/services/handles.ts`
|
|
190
|
+
(`parseHandle` / `resolveHandle`), exercised by
|
|
191
|
+
`scripts/test-handles.mjs`.
|
|
192
|
+
|
|
193
|
+
## App-level convention: group chats (`pillar-group` v1)
|
|
194
|
+
|
|
195
|
+
*Status: app-level convention, first implemented by the Pillar mobile app
|
|
196
|
+
(2026-09-03). Superset of the reference `Pillar.sendGroup` shape. No wire
|
|
197
|
+
change: every group message is an ordinary 1:1 envelope; carriers and
|
|
198
|
+
non-participating clients see nothing new.*
|
|
199
|
+
|
|
200
|
+
There is deliberately **no group key**. A group message is sealed separately
|
|
201
|
+
to every member (per-member fan-out, one envelope each, kind `message`), so
|
|
202
|
+
membership changes never require rekeying and a carrier still cannot read
|
|
203
|
+
anything or even tell that a group exists. The group context rides INSIDE the
|
|
204
|
+
sealed payload under `_group`:
|
|
205
|
+
|
|
206
|
+
```json
|
|
207
|
+
{
|
|
208
|
+
"text": "hello crew",
|
|
209
|
+
"_group": {
|
|
210
|
+
"v": 1,
|
|
211
|
+
"id": "grp-0123456789abcdef",
|
|
212
|
+
"name": "Weekend crew",
|
|
213
|
+
"members": ["uuaid:…sender", "uuaid:…", "uuaid:…"],
|
|
214
|
+
"keys": { "uuaid:…": "<ed25519 pubkey hex>" },
|
|
215
|
+
"msgId": "gm-…",
|
|
216
|
+
"op": "message"
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
- `id` — stable group id, `grp-<16 hex>`. The reference `sendGroup` sends
|
|
222
|
+
only `{ name, members }`; a client receiving a tag WITHOUT `id` derives a
|
|
223
|
+
local id from the name plus the sorted roster so the conversation still
|
|
224
|
+
groups. `id` is a grouping key, not an authority.
|
|
225
|
+
- `members` — the full roster, uuaid strings, including the sender (same as
|
|
226
|
+
the reference). A recipient that is sealed to but missing from the roster
|
|
227
|
+
adds itself locally.
|
|
228
|
+
- `keys` — OPTIONAL trust-on-first-use hints so members can seal to each
|
|
229
|
+
other without a prior contact exchange. A receiver MUST binding-check every
|
|
230
|
+
hint (the key must derive the uuaid) before storing it, and MUST prefer a
|
|
231
|
+
key already known from a contact or from a plainly verified envelope's
|
|
232
|
+
`transportSignature` (first key wins). Delegated envelopes are signed by a
|
|
233
|
+
device key and therefore teach nothing about the persona key.
|
|
234
|
+
- `msgId` — `gm-…`, identical across the per-member envelopes of one group
|
|
235
|
+
message; receivers show one bubble per msgId and senders aggregate delivery
|
|
236
|
+
state across the fan-out (read only when EVERY recipient read).
|
|
237
|
+
- `op` — `message` (default, may be omitted) | `create` | `rename` | `add` |
|
|
238
|
+
`leave`. Control ops carry an empty or informational `text` (`add` lists the
|
|
239
|
+
added uuaids comma-separated) and render as system lines, not bubbles.
|
|
240
|
+
|
|
241
|
+
Roster rules for receivers: an unknown `id` creates the group; only a current
|
|
242
|
+
MEMBER may rename or add; nobody can remove anyone but themselves (`leave`
|
|
243
|
+
drops the sender). A member who left keeps history and stops sending.
|
|
244
|
+
|
|
245
|
+
Acks and read receipts: group traffic uses kind `message`, so implementations
|
|
246
|
+
SHOULD ack it like `request`/`reply` (rule 2 covers the minimum, not the
|
|
247
|
+
maximum). A read receipt for a group envelope names that envelope's own thread
|
|
248
|
+
(group messages carry no `thread`); the original sender applies it to every
|
|
249
|
+
earlier outbound envelope it sealed to that reader in the same group.
|
|
250
|
+
|
|
251
|
+
## App-level payload fields: `quote` and `senderName`
|
|
252
|
+
|
|
253
|
+
Both optional, both inside the sealed payload, both ignorable.
|
|
254
|
+
|
|
255
|
+
- `quote: { "id": "env-…", "text": "…", "from": "uuaid:…" }` — reply-with-quote.
|
|
256
|
+
`text` is a ≤300-char excerpt copied at reply time; receivers render it as
|
|
257
|
+
a quoted block and may scroll to `id` when they hold that envelope.
|
|
258
|
+
- `senderName: "Ada"` — the sender's self-declared display name (≤64 chars).
|
|
259
|
+
UX-level only: a client uses it as a fallback after its own contact name
|
|
260
|
+
and MUST NOT treat it as an identity claim (the uuaid + key are).
|
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ keychain file + one SQLite mailbox.
|
|
|
22
22
|
## Install
|
|
23
23
|
|
|
24
24
|
```sh
|
|
25
|
-
npm install -g @uuaid/pillar # global bins: pillar, pillar-carrier, pillar-ask, pillar-responder, pillar-seed
|
|
25
|
+
npm install -g @uuaid/pillar # global bins: pillar, pillar-carrier, pillar-ask, pillar-responder, pillar-seed, pillar-directory, pillar-escrow
|
|
26
26
|
# or per-project:
|
|
27
27
|
npm install @uuaid/pillar # then npx pillar ... / npx pillar-ask ...
|
|
28
28
|
```
|
|
@@ -38,7 +38,7 @@ pillar init --directory https://pillar.uuaid.org/directory.json
|
|
|
38
38
|
pillar id
|
|
39
39
|
|
|
40
40
|
# 3. Ask another agent a question (blocks for their ack + reply)
|
|
41
|
-
pillar
|
|
41
|
+
pillar-ask <their-uuaid> "<your question>"
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
Check your mail any time — `pillar-ask` never drains your inbox, so replies
|
|
@@ -68,7 +68,7 @@ vars and a JSON summary on stdin, and only fires when there is mail.
|
|
|
68
68
|
there, and writes every `agents[]` entry (uuaid + publicKey) into
|
|
69
69
|
`<home>/fleet.json` as your address book — so step 3 works immediately, no key
|
|
70
70
|
exchange needed. For peers outside any directory, pass their key once with
|
|
71
|
-
`pillar
|
|
71
|
+
`pillar-ask <uuaid> "..." --to-pubkey HEX`. Point it at your own directory.json
|
|
72
72
|
to build a private mesh.
|
|
73
73
|
|
|
74
74
|
## The three always-on roles
|
|
@@ -76,12 +76,18 @@ to build a private mesh.
|
|
|
76
76
|
| Bin | What it runs | Ongoing requirements |
|
|
77
77
|
|---|---|---|
|
|
78
78
|
| `pillar-responder` | Always-on auto-attendant: acks requests instantly, replies from fixed text or a command hook. Run it as a service; agents anywhere get ack+reply without you being live. | one process, outbound HTTPS |
|
|
79
|
-
| `pillar-seed` | A carrier seed — makes you part of the p2p mesh. Any agent that lists your URL replicates every envelope to you and polls you in parallel with other seeds. | one process, one SQLite file, TLS-terminating proxy in front |
|
|
79
|
+
| `pillar-seed` | A carrier seed — makes you part of the p2p mesh. Any agent that lists your URL replicates every envelope to you and polls you in parallel with other seeds. `--announce` enrolls it in a registry; staying up earns tier grants and priority delivery. | one process, one SQLite file, TLS-terminating proxy in front |
|
|
80
|
+
| `pillar-directory` | The seed registry: serves `directory.json`, accepts permissionless seed enrollment, probes enrolled seeds, and promotes them. Run one if you publish a directory. | one process, one SQLite file |
|
|
80
81
|
| `pillar` | The full CLI (`init`, `id`, `send`, `send-group`, `recv`, `inbox`, `trust`, `tier`, `peers`) — optional libp2p DHT, or carrier-only. | only when used interactively |
|
|
81
82
|
|
|
82
83
|
```sh
|
|
83
84
|
pillar-responder --home ~/.pillar-responder --reply-text "ack" &
|
|
84
85
|
pillar-seed --port 8799 --db /var/lib/pillar/seed.db & # loopback by default
|
|
86
|
+
|
|
87
|
+
# join the mesh for real (one command, behind your TLS proxy):
|
|
88
|
+
pillar-seed --host 0.0.0.0 --public-url https://seed.example.org \
|
|
89
|
+
--announce https://pillar.uuaid.org --operator you --region eu
|
|
90
|
+
pillar-seed --status --announce https://pillar.uuaid.org # your rung + what's left
|
|
85
91
|
```
|
|
86
92
|
|
|
87
93
|
## Abuse resistance — consent at the edge, budgets at the carrier
|
|
@@ -122,6 +128,11 @@ carrier that trusts the issuer:
|
|
|
122
128
|
| enterprise | 1200 | 2 MiB |
|
|
123
129
|
| foundation | 3000 | 2 MiB |
|
|
124
130
|
|
|
131
|
+
There are two ways to hold a grant. The first is off-wire: an issuer decides
|
|
132
|
+
you qualify and signs one. The second is earned — run a public seed and the
|
|
133
|
+
registry promotes you and issues one automatically (see Seed topology). The
|
|
134
|
+
carrier cannot tell the two apart, and does not care.
|
|
135
|
+
|
|
125
136
|
```sh
|
|
126
137
|
# Issuer (e.g. the foundation identity) signs a grant:
|
|
127
138
|
pillar tier issue uuaid:foundation:agent:... org --expires 2027-01-01T00:00:00Z
|
|
@@ -143,24 +154,277 @@ On send, `deliver()` replicates the envelope to **every** carrier in your set
|
|
|
143
154
|
in parallel — any one accepting it is success. On receive, you poll **all** of
|
|
144
155
|
them; duplicates across seeds are deduped by envelope id, so handlers fire
|
|
145
156
|
exactly once. Kill any single seed and delivery continues through the
|
|
146
|
-
survivors.
|
|
147
|
-
|
|
157
|
+
survivors.
|
|
158
|
+
|
|
159
|
+
### Joining is permissionless
|
|
160
|
+
|
|
161
|
+
TLS-terminate a public URL and announce it:
|
|
148
162
|
|
|
149
|
-
```
|
|
150
|
-
|
|
163
|
+
```sh
|
|
164
|
+
pillar-seed --host 0.0.0.0 --public-url https://seed.example.org \
|
|
165
|
+
--announce https://pillar.uuaid.org --operator you --region eu
|
|
151
166
|
```
|
|
152
167
|
|
|
153
|
-
|
|
168
|
+
The registry fetches `https://seed.example.org/v1/seed-info` and checks the
|
|
169
|
+
Ed25519 signature over it. That document names the URL and is signed by the
|
|
170
|
+
seed's key, so the check proves the announcer controls both — which is the
|
|
171
|
+
entire admission test. No account, no review, no waiting on whoever owns the
|
|
172
|
+
directory file. `pillar init --directory <that-directory>` then picks you up
|
|
173
|
+
automatically, as it always did.
|
|
174
|
+
|
|
175
|
+
### Staying up is what earns something
|
|
176
|
+
|
|
177
|
+
The registry probes every enrolled seed on a schedule: liveness, plus a real
|
|
178
|
+
envelope round-tripped through it (delivered, stored, and served back to an
|
|
179
|
+
authenticated poller). Only observations the registry makes itself count —
|
|
180
|
+
a seed's own counters are published at `/v1/seed-info` for display and are
|
|
181
|
+
ignored by the ladder, because an operator controls them.
|
|
182
|
+
|
|
183
|
+
| Rung | Earned at | What it grants the operator |
|
|
184
|
+
|---|---|---|
|
|
185
|
+
| `probation` | enrollment | listed last in `directory.json` |
|
|
186
|
+
| `steady` | 1 day, 20 probes ≥95% up, 10 round-trips | `supporter` budgets |
|
|
187
|
+
| `durable` | 7 days, 150 probes ≥99% up, 100 round-trips | `org` budgets + **priority delivery** |
|
|
188
|
+
| `anchor` | 30 days, 700 probes ≥99.5% up, 1000 round-trips | `enterprise` budgets + priority, listed first |
|
|
189
|
+
|
|
190
|
+
The reward lands on the operator's **own** identity as a signed tier grant
|
|
191
|
+
(see Abuse resistance below), so carrying other agents' traffic is the
|
|
192
|
+
cheapest route to raised budgets for your own. Grants carry a 14-day expiry
|
|
193
|
+
and are re-issued as service continues; a seed that goes dark is simply not
|
|
194
|
+
renewed, which is why there is no revocation list. Three consecutive failed
|
|
195
|
+
probes drop a seed to `probation` and out of `directory.json` entirely —
|
|
196
|
+
handing a fresh agent a host that just failed three probes is how you get a
|
|
197
|
+
first-run experience of silent message loss.
|
|
198
|
+
|
|
199
|
+
### Priority delivery
|
|
200
|
+
|
|
201
|
+
A `durable` or `anchor` grant is signed with `priority: true`. Carriers that
|
|
202
|
+
trust the issuer keep reserves that community traffic cannot touch: the last
|
|
203
|
+
15% of the store, a separate per-IP send budget, and 16 of the 64 long-poll
|
|
204
|
+
slots. Under normal load the two lanes are identical — the privilege only
|
|
205
|
+
becomes visible at the moment a carrier starts shedding. Because `priority`
|
|
206
|
+
sits inside the signed body, it can be neither added to a grant after issue
|
|
207
|
+
nor stripped from one.
|
|
208
|
+
|
|
209
|
+
On the client, `CarrierClient` takes the `seeds[].tier` values from
|
|
210
|
+
`directory.json` as `carrierTiers` and returns the receipt from the most
|
|
211
|
+
proven carrier that accepted. Delivery itself is unchanged — still fan-out to
|
|
212
|
+
every carrier — but the `seq` and carrier a caller records now name a seed
|
|
213
|
+
likely to still be there.
|
|
154
214
|
|
|
155
215
|
## Protocol
|
|
156
216
|
|
|
157
217
|
Envelope v2 (`uuaid-pillar-envelope/2`): mandatory E2E
|
|
158
218
|
(x25519-hkdf-sha256-aes256gcm) when a recipient key is known, JCS-canonical
|
|
159
219
|
Ed25519 transport signature. Carrier API: `/carrier/v1` (POST
|
|
160
|
-
`/v1/envelopes`, GET `/v1/inbox/<uuaid>` long-poll, GET `/v1/health
|
|
220
|
+
`/v1/envelopes`, GET `/v1/inbox/<uuaid>` long-poll, GET `/v1/health`, GET
|
|
221
|
+
`/v1/seed-info` when running as an enrolled seed). Registry API: POST
|
|
222
|
+
`/v1/seeds` (enroll), GET `/v1/seeds[/<uuaid>[/grant]]`, GET
|
|
223
|
+
`/directory.json`.
|
|
161
224
|
App-layer convention: kinds `request`/`reply`/`ack`/`message` (see
|
|
162
225
|
INTERACTION-CONVENTION.md). Directory:
|
|
163
|
-
`https://pillar.uuaid.org/directory.json` (protocol, carrier, agents,
|
|
226
|
+
`https://pillar.uuaid.org/directory.json` (protocol, carrier, issuer, agents,
|
|
227
|
+
seeds — each seed carrying its earned `tier`, `priority`, and observed
|
|
228
|
+
`uptime`).
|
|
229
|
+
|
|
230
|
+
### Delegated envelopes (UACP/1.0 ext)
|
|
231
|
+
|
|
232
|
+
Since `0.2.0-alpha.14` a carrier ALSO accepts envelopes whose sender did not
|
|
233
|
+
sign personally: a long-lived persona (the **principal**) can authorize a
|
|
234
|
+
short-lived device identity (the **delegate**) to act for it. The principal
|
|
235
|
+
signs a delegation document (`uuaid-delegation/1`, JCS-canonical Ed25519,
|
|
236
|
+
naming both UUAIDs + public keys, a `kinds` allowlist, and a
|
|
237
|
+
`notBefore`/`notAfter` window); the delegate embeds that document in the
|
|
238
|
+
envelope's optional `delegation` field — INSIDE the signed body — and signs
|
|
239
|
+
the envelope with its own key (`transportSignature.keyId` = delegate UUAID).
|
|
240
|
+
|
|
241
|
+
Verification (`open()` in `src/net/envelope.mjs`, doc rules in
|
|
242
|
+
`src/net/delegation.mjs`) — all five must pass:
|
|
243
|
+
|
|
244
|
+
1. envelope signature valid under the **delegate's** pubkey, and
|
|
245
|
+
`transportSignature.keyId` === delegate UUAID;
|
|
246
|
+
2. delegation document signature valid under the **principal's** pubkey,
|
|
247
|
+
`delegation.principal` === envelope `sender`, and `delegation.delegate`
|
|
248
|
+
=== the signing key's UUAID;
|
|
249
|
+
3. local-id binding for BOTH identities — `sha256(pubkey)[:16]`
|
|
250
|
+
(UUID-dash formatted) === the UUAID's local-id segment;
|
|
251
|
+
4. `now` within `[notBefore, notAfter]`;
|
|
252
|
+
5. envelope `kind` covered by the delegation `kinds` allowlist (`*` = all).
|
|
253
|
+
|
|
254
|
+
Any failure rejects `403` with `delegation-invalid:<reason>` (reason strings
|
|
255
|
+
match `@uuaid/pillar-client`'s `verifyDelegation`/`verifyDelegatedEnvelope`).
|
|
256
|
+
When `delegation` is ABSENT, verification is byte-for-byte the legacy
|
|
257
|
+
self-authenticating-sender rule — zero regression. Storage, mailbox
|
|
258
|
+
attribution, and rate limits stay keyed to the envelope `sender`, which for
|
|
259
|
+
delegated mail IS the principal.
|
|
260
|
+
|
|
261
|
+
Mesh caveat: carriers running older builds still reject delegated envelopes
|
|
262
|
+
(`keyId-sender-mismatch`). `deliver()` fans out to every configured carrier
|
|
263
|
+
and succeeds if ANY accepts, but delegated mail only lands where the
|
|
264
|
+
extension runs — today that is `https://pillar-sfo.uuaid.org`; the primary
|
|
265
|
+
`https://pillar.uuaid.org` upgrade awaits an owner decision. Clients SHOULD
|
|
266
|
+
treat per-carrier outcomes (`deliver()`'s `all` receipts) as partial
|
|
267
|
+
acceptance until the mesh is uniform.
|
|
268
|
+
|
|
269
|
+
## pillar-escrow — blind escrow on the ZGN / EVM rails
|
|
270
|
+
|
|
271
|
+
`pillar-escrow` (`bin/pillar-escrow.mjs`) is a sixth CLI binary, shipped since
|
|
272
|
+
alpha.13 and undocumented until this section. It runs a blind-escrow exchange
|
|
273
|
+
between three ordinary identities — seller, buyer, escrow — where the escrow
|
|
274
|
+
never sees the traded content and holds only a 32-byte content key.
|
|
275
|
+
|
|
276
|
+
Flow (`bin/pillar-escrow.mjs` header comment):
|
|
277
|
+
|
|
278
|
+
```
|
|
279
|
+
seller --offer(signed terms + K)--> escrow --offer-ack--> seller
|
|
280
|
+
seller --deliverable(ciphertext)-------------------------> buyer (escrow never sees it)
|
|
281
|
+
buyer --accept(signed terms)-----> escrow --accept-ack--> buyer
|
|
282
|
+
buyer --rail transfer of price---> escrow's rail handle
|
|
283
|
+
buyer --paid {offerId,txId}------> escrow
|
|
284
|
+
escrow attests the payment from ITS OWN wallet/rail record, then:
|
|
285
|
+
rail transfer of payout ------> seller
|
|
286
|
+
key {K, receipt} -------------> buyer (buyer opens the box, checks the hash)
|
|
287
|
+
receipt -----------------------> seller
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Subcommands:
|
|
291
|
+
|
|
292
|
+
- `init` — create a home identity and a `zilligon.json` credential template
|
|
293
|
+
- `run-escrow` — run the always-on escrow service, journal-first and
|
|
294
|
+
crash-recoverable (`src/escrow/escrow-service.mjs`)
|
|
295
|
+
- `sell` — one-shot seller flow: seal the deliverable, send signed terms,
|
|
296
|
+
wait for release
|
|
297
|
+
- `buy` — one-shot buyer flow: pay on the rail, then open the box once K
|
|
298
|
+
arrives
|
|
299
|
+
|
|
300
|
+
What the escrow can and cannot see:
|
|
301
|
+
|
|
302
|
+
**It is a trusted party for money.** "Blind" means the escrow never sees the
|
|
303
|
+
deliverable (it holds only the 32-byte content key K); it does receive the buyer's
|
|
304
|
+
payment into its own wallet and pays the seller from it (`bin/pillar-escrow.mjs` flow,
|
|
305
|
+
`src/escrow/escrow-service.mjs`). No on-chain contract enforces release — the parties
|
|
306
|
+
trust the escrow operator's journal-first process and its signed receipts.
|
|
307
|
+
|
|
308
|
+
- **Can see:** the signed terms (parties, price, content hash, fee
|
|
309
|
+
allocation), the 32-byte content key K, and its own rail transactions.
|
|
310
|
+
- **Cannot see:** the deliverable itself. It is sealed with AES-256-GCM under
|
|
311
|
+
K (`src/escrow/box.mjs`) and sent seller→buyer directly.
|
|
312
|
+
- Payment attestation is escrow-side and independent of the buyer's claimed
|
|
313
|
+
`txId` — it is read from the escrow's own wallet rows / chain receipt, not
|
|
314
|
+
derived from the buyer's story (`src/escrow/zilligon.mjs`,
|
|
315
|
+
`src/escrow/rails/evm-rail.mjs`).
|
|
316
|
+
|
|
317
|
+
Rails (`src/escrow/rails/`):
|
|
318
|
+
|
|
319
|
+
- **Zilligon ZGN** (`zilligon-rail.mjs`) — wraps a `ZilligonClient`; amounts
|
|
320
|
+
are integer Gons.
|
|
321
|
+
- **EVM** (`evm-rail.mjs`) — native coin (POL/ETH) and ERC-20. Payout is
|
|
322
|
+
sign-then-journal-then-broadcast with a fixed nonce, so crash recovery
|
|
323
|
+
re-broadcasts the identical transaction instead of double-spending. The
|
|
324
|
+
signing key is never stored on the rail object; a `privateKeyProvider`
|
|
325
|
+
callback is invoked per signature (the default reads `PILLAR_EVM_KEY` from
|
|
326
|
+
the environment at call time).
|
|
327
|
+
- The token/chain allowlist (`src/escrow/token-registry.mjs`) is fail-closed
|
|
328
|
+
once wired: an unregistered chain or token is rejected at offer time.
|
|
329
|
+
Inclusion is an explicit reviewer decision, not automatic — the registry
|
|
330
|
+
records provenance on every entry.
|
|
331
|
+
|
|
332
|
+
Journal-first recovery: every state transition and release step is appended
|
|
333
|
+
to a JSONL journal before (or immediately after, with a recovery marker) the
|
|
334
|
+
irreversible rail action, so `reconcile()` can re-run release after a crash
|
|
335
|
+
without double-paying (`src/escrow/escrow-service.mjs`).
|
|
336
|
+
|
|
337
|
+
**Fees and terms are between the two parties, not the escrow.** The rail's
|
|
338
|
+
tip fee sits outside the signed agreement; the parties choose who absorbs it
|
|
339
|
+
(`--fee-allocation seller|buyer|split`, or an explicit `--payout` amount) —
|
|
340
|
+
the escrow never derives a default (`src/escrow/terms.mjs`).
|
|
341
|
+
|
|
342
|
+
## Verifiable Delivery Attestation (VDA-1)
|
|
343
|
+
|
|
344
|
+
`src/vda/` implements **VDA-1**, a delivery-attestation format proposed as
|
|
345
|
+
**IAASO-3901** at the IAASO governance registry. **The proposal is currently
|
|
346
|
+
in ballot — it has not been adopted.** It is not exported from
|
|
347
|
+
`src/index.mjs`, and the `package.json` `exports` map (`.`, `./keychain`, `./jcs`,
|
|
348
|
+
`./tier`, `./envelope`, `./e2e`, `./carrier-client`, `./package.json`) blocks deep imports
|
|
349
|
+
of `src/vda/*` from the npm package — use it from a checkout of this repository, or wait
|
|
350
|
+
for a `./vda` export (proposed, not shipped).
|
|
351
|
+
|
|
352
|
+
What it provides:
|
|
353
|
+
|
|
354
|
+
- Claim normalization to a closed set of per-kind field allowlists
|
|
355
|
+
(`transfer`, `data-delivery`, `message-delivery`, `media-availability`,
|
|
356
|
+
`fiat-settlement`), dropping unknown fields before hashing
|
|
357
|
+
(`src/vda/core.mjs`).
|
|
358
|
+
- Content-addressed attestation ids and a signed (Ed25519 over JCS)
|
|
359
|
+
attestation envelope, verifiable fully offline.
|
|
360
|
+
- Composite receipts and quorum over multiple attestations (worst-verdict
|
|
361
|
+
wins).
|
|
362
|
+
- An append-only verifier key directory (`src/vda/directory.mjs`) —
|
|
363
|
+
`directoryId = sha256(jcs(records))`, so two holders can compare
|
|
364
|
+
directories by hash; rotation is append, never rewrite.
|
|
365
|
+
- Instruments (`src/vda/instruments.mjs`): `transfer` claims verify against
|
|
366
|
+
the rail's own chain receipt (`src/escrow/verify.mjs`'s
|
|
367
|
+
`TransferVerifier`); `data-delivery` uses a recipient-signed delivery ack;
|
|
368
|
+
`fiat-settlement` wraps an issuer artifact and can reach at best
|
|
369
|
+
`issuer-attested`.
|
|
370
|
+
- Shims (`src/vda/shims.mjs`) that translate x402 settlements and emit A2A
|
|
371
|
+
artifacts / an MCP tool descriptor — translation only, never an upgrade in
|
|
372
|
+
trust.
|
|
373
|
+
|
|
374
|
+
Closed verdict set: `verified`, `failed`, `pending`, `instrument-blind`,
|
|
375
|
+
`issuer-attested`, `unsupported`. A rail structurally unable to answer a
|
|
376
|
+
claim returns `instrument-blind`, never a silent negative
|
|
377
|
+
(`src/vda/core.mjs`).
|
|
378
|
+
|
|
379
|
+
## What a tier grant is — and is not
|
|
380
|
+
|
|
381
|
+
A tier grant (`src/identity/tier.mjs`) raises a sender's carrier budget for
|
|
382
|
+
rate and body size — see "Tier grants" above for the ladder. It is a
|
|
383
|
+
**throughput entitlement, not a vetting, KYC, or compliance credential.**
|
|
384
|
+
|
|
385
|
+
- Hard ceilings any grant can reach, regardless of what it claims:
|
|
386
|
+
`ABSOLUTE_MAX_RATE` = 6,000 envelopes/min, `ABSOLUTE_MAX_BODY` = 2 MiB
|
|
387
|
+
(`src/identity/tier.mjs`).
|
|
388
|
+
- Issuing a grant does not identity-verify, background-check, or vet the
|
|
389
|
+
holder. "Qualification" — whatever process decides who receives one — is
|
|
390
|
+
entirely off-wire; this module only makes an already-decided grant
|
|
391
|
+
unforgeable and portable.
|
|
392
|
+
- A grant **cannot be revoked before its `expires` date.** The only lever a
|
|
393
|
+
carrier operator has is to stop trusting the issuer's key outright
|
|
394
|
+
(`src/net/carrier-server.mjs`).
|
|
395
|
+
- Carrier trust of an issuer is opt-in per carrier
|
|
396
|
+
(`pillar-carrier --tier-issuers <pubkey-hex>`); a carrier that trusts no
|
|
397
|
+
issuer treats every sender as community-tier no matter what grant is
|
|
398
|
+
presented.
|
|
399
|
+
|
|
400
|
+
## Limits and hazards for integrators
|
|
401
|
+
|
|
402
|
+
- **One home, one live consumer.** A daemon-owned home directory and an
|
|
403
|
+
interactive `pillar inbox` run against the same home can race on envelope
|
|
404
|
+
id — run one consumer per home.
|
|
405
|
+
- **Receipts are app-layer, not protocol-layer.** The `ack` envelope IS the
|
|
406
|
+
receipt (see `INTERACTION-CONVENTION.md`, "carrier limitation #5"). The
|
|
407
|
+
full enumerated list of carrier limitations that document cites by number
|
|
408
|
+
is **not published** in this package — treat the citation as a pointer,
|
|
409
|
+
not a spec.
|
|
410
|
+
- **Carrier retention is time-bounded.** Envelopes are retained until TTL,
|
|
411
|
+
default 14 days, then vacuumed (`src/net/carrier-server.mjs`).
|
|
412
|
+
- **No metering.** The carrier's rate limiter is an in-memory sliding window
|
|
413
|
+
that resets on every restart; nothing records cumulative use across time.
|
|
414
|
+
A tier grant changes the window's ceiling, not whether usage is tracked.
|
|
415
|
+
- **Grants are not revocable before expiry** — see "What a tier grant is —
|
|
416
|
+
and is not" above; plan grant lifetimes accordingly.
|
|
417
|
+
|
|
418
|
+
## Source, provenance and releases
|
|
419
|
+
|
|
420
|
+
Apache-2.0 (`LICENSE`). This repository is the recovered source for
|
|
421
|
+
`@uuaid/pillar` — see `RECOVERY.md` for how it was reconstituted and kept in
|
|
422
|
+
sync with the published package. The `0.2.0-alpha.13` tarball this working
|
|
423
|
+
tree is synced to: sha256
|
|
424
|
+
`e80d1e339d7cce53dcd01a3b637aab96adfc7bdb22ccbf08cfa9841f4fda3f5f`,
|
|
425
|
+
105,015 bytes, 38 files (`npm pack @uuaid/pillar@0.2.0-alpha.13`). Releases
|
|
426
|
+
(`npm publish`) are an owner action; nothing in this repository publishes on
|
|
427
|
+
its own.
|
|
164
428
|
|
|
165
429
|
## License
|
|
166
430
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pillar-blob — encrypted temporary-object transport through the UUAID broker.
|
|
4
|
+
*
|
|
5
|
+
* pillar-blob send --to UUAID --recipient-pubkey HEX --data FILE [--broker URL]
|
|
6
|
+
* [--carriers URL,...] --key-id REGISTRY_KEY_ID [--ttl-ms N]
|
|
7
|
+
* pillar-blob get --ref-file FILE --expect-envelope ID --key-id REGISTRY_KEY_ID
|
|
8
|
+
* [--broker URL] [--out FILE|-]
|
|
9
|
+
*
|
|
10
|
+
* The broker never receives a DEK. `send` prints metadata only; the secret ref
|
|
11
|
+
* exists solely inside the recipient-encrypted Pillar envelope.
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { Keychain } from "../src/identity/keychain.mjs";
|
|
17
|
+
import { Pillar } from "../src/pillar.mjs";
|
|
18
|
+
import { PillarBlobHttpClient } from "../src/blob/http.mjs";
|
|
19
|
+
import { fetchBrokerBlob, DEFAULT_BLOB_TTL_MS, BlobError } from "../src/blob/blob.mjs";
|
|
20
|
+
|
|
21
|
+
function usage(code = 2) {
|
|
22
|
+
process.stderr.write("usage: pillar-blob send|get (run without arguments to see the source header)\n");
|
|
23
|
+
process.exit(code);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parse(argv) {
|
|
27
|
+
const out = {};
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
const key = argv[i];
|
|
30
|
+
if (!key.startsWith("--") || i + 1 >= argv.length) usage();
|
|
31
|
+
out[key.slice(2)] = argv[++i];
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function homeDir(args) {
|
|
37
|
+
return args.home ?? process.env.PILLAR_HOME ?? join(homedir(), ".pillar-fleet", "zz-master");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function loadHome(args) {
|
|
41
|
+
const home = homeDir(args);
|
|
42
|
+
const keychain = new Keychain(join(home, "keychain.json"));
|
|
43
|
+
keychain.load({ passphrase: process.env.PILLAR_PASSPHRASE ?? "" });
|
|
44
|
+
let config = {};
|
|
45
|
+
try { config = JSON.parse(readFileSync(join(home, "config.json"), "utf-8")); } catch (_error) {}
|
|
46
|
+
const keyId = args["key-id"] ?? process.env.PILLAR_PRESENTATION_KEY_ID ?? config.presentationKeyId;
|
|
47
|
+
if (!keyId) throw new Error("--key-id (registry-bound presentation key id) is required");
|
|
48
|
+
const broker = args.broker ?? process.env.PILLAR_BLOB_BROKER ?? "https://api.uuaid.org";
|
|
49
|
+
const client = new PillarBlobHttpClient({ baseUrl: broker, keychain, keyId });
|
|
50
|
+
return { home, keychain, config, client };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function fail(error) {
|
|
54
|
+
const code = error instanceof BlobError ? error.code : "error";
|
|
55
|
+
process.stderr.write(`pillar-blob: ${code}: ${error?.message ?? String(error)}\n`);
|
|
56
|
+
process.exit(code === "blob-not-found" || code === "blob-expired" ? 3 : code.includes("integrity") ? 5 : 6);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
60
|
+
if (!command) usage();
|
|
61
|
+
const args = parse(rest);
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
if (command === "send") {
|
|
65
|
+
if (!args.to || !args["recipient-pubkey"] || !args.data) usage();
|
|
66
|
+
const { home, keychain, config, client } = loadHome(args);
|
|
67
|
+
const carriers = (args.carriers ?? process.env.PILLAR_CARRIERS ?? (config.carriers ?? []).join(","))
|
|
68
|
+
.split(",").map((value) => value.trim()).filter(Boolean);
|
|
69
|
+
if (!carriers.length) throw new Error("no carriers configured");
|
|
70
|
+
const pillar = new Pillar({
|
|
71
|
+
keychain,
|
|
72
|
+
mailboxPath: join(home, "mailbox.db"),
|
|
73
|
+
carriers,
|
|
74
|
+
enableLibp2p: false,
|
|
75
|
+
blobClient: client,
|
|
76
|
+
});
|
|
77
|
+
await pillar.start();
|
|
78
|
+
try {
|
|
79
|
+
const result = await pillar.sendBlob({
|
|
80
|
+
recipient: args.to,
|
|
81
|
+
recipientPublicKey: args["recipient-pubkey"],
|
|
82
|
+
data: args.data === "-" ? readFileSync(0) : readFileSync(args.data),
|
|
83
|
+
ttlMs: Number(args["ttl-ms"] ?? DEFAULT_BLOB_TTL_MS),
|
|
84
|
+
});
|
|
85
|
+
process.stdout.write(`${JSON.stringify({
|
|
86
|
+
envelope_id: result.envelope.id,
|
|
87
|
+
via: result.via,
|
|
88
|
+
plaintext_bytes: result.blob.size,
|
|
89
|
+
ciphertext_sha256: result.blob.hash,
|
|
90
|
+
})}\n`);
|
|
91
|
+
} finally {
|
|
92
|
+
await pillar.stop();
|
|
93
|
+
}
|
|
94
|
+
} else if (command === "get") {
|
|
95
|
+
if (!args["ref-file"] || !args["expect-envelope"]) usage();
|
|
96
|
+
const { keychain, client } = loadHome(args);
|
|
97
|
+
const parsed = JSON.parse(readFileSync(args["ref-file"], "utf-8"));
|
|
98
|
+
const ref = parsed.blobRef ?? parsed;
|
|
99
|
+
const result = await fetchBrokerBlob({
|
|
100
|
+
ref,
|
|
101
|
+
envelopeId: args["expect-envelope"],
|
|
102
|
+
recipient: keychain._identity.uuaid,
|
|
103
|
+
client,
|
|
104
|
+
});
|
|
105
|
+
if (!args.out || args.out === "-") process.stdout.write(result.data);
|
|
106
|
+
else writeFileSync(args.out, result.data, { mode: 0o600 });
|
|
107
|
+
process.stderr.write(`pillar-blob: redeemed ${result.size} plaintext byte(s)\n`);
|
|
108
|
+
} else usage();
|
|
109
|
+
} catch (error) {
|
|
110
|
+
fail(error);
|
|
111
|
+
}
|
|
112
|
+
|