@cello-protocol/daemon 0.0.229 → 0.0.231
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/dist/channel-admin-lookup.d.ts +54 -0
- package/dist/channel-admin-lookup.d.ts.map +1 -0
- package/dist/channel-admin-lookup.js +122 -0
- package/dist/channel-admin-lookup.js.map +1 -0
- package/dist/channel-collect-tick.d.ts +36 -0
- package/dist/channel-collect-tick.d.ts.map +1 -0
- package/dist/channel-collect-tick.js +133 -0
- package/dist/channel-collect-tick.js.map +1 -0
- package/dist/channel-collector.d.ts +99 -0
- package/dist/channel-collector.d.ts.map +1 -0
- package/dist/channel-collector.js +249 -0
- package/dist/channel-collector.js.map +1 -0
- package/dist/channel-config-store.d.ts +36 -0
- package/dist/channel-config-store.d.ts.map +1 -0
- package/dist/channel-config-store.js +84 -0
- package/dist/channel-config-store.js.map +1 -0
- package/dist/channel-inbox-store.d.ts +46 -0
- package/dist/channel-inbox-store.d.ts.map +1 -0
- package/dist/channel-inbox-store.js +88 -0
- package/dist/channel-inbox-store.js.map +1 -0
- package/dist/channel-join-exchange.d.ts +87 -0
- package/dist/channel-join-exchange.d.ts.map +1 -0
- package/dist/channel-join-exchange.js +289 -0
- package/dist/channel-join-exchange.js.map +1 -0
- package/dist/channel-log-store.d.ts +14 -0
- package/dist/channel-log-store.d.ts.map +1 -1
- package/dist/channel-log-store.js +58 -0
- package/dist/channel-log-store.js.map +1 -1
- package/dist/channel-membership-store.d.ts +118 -0
- package/dist/channel-membership-store.d.ts.map +1 -0
- package/dist/channel-membership-store.js +226 -0
- package/dist/channel-membership-store.js.map +1 -0
- package/dist/channel-membership-wiring.d.ts +90 -0
- package/dist/channel-membership-wiring.d.ts.map +1 -0
- package/dist/channel-membership-wiring.js +387 -0
- package/dist/channel-membership-wiring.js.map +1 -0
- package/dist/channel-publish-handlers.d.ts +42 -0
- package/dist/channel-publish-handlers.d.ts.map +1 -0
- package/dist/channel-publish-handlers.js +212 -0
- package/dist/channel-publish-handlers.js.map +1 -0
- package/dist/channel-publish-wiring.d.ts +52 -0
- package/dist/channel-publish-wiring.d.ts.map +1 -0
- package/dist/channel-publish-wiring.js +143 -0
- package/dist/channel-publish-wiring.js.map +1 -0
- package/dist/channel-publisher.d.ts +221 -0
- package/dist/channel-publisher.d.ts.map +1 -0
- package/dist/channel-publisher.js +430 -0
- package/dist/channel-publisher.js.map +1 -0
- package/dist/channel-relay-client.d.ts +88 -0
- package/dist/channel-relay-client.d.ts.map +1 -0
- package/dist/channel-relay-client.js +180 -0
- package/dist/channel-relay-client.js.map +1 -0
- package/dist/channel-subscription-store.d.ts +137 -0
- package/dist/channel-subscription-store.d.ts.map +1 -0
- package/dist/channel-subscription-store.js +250 -0
- package/dist/channel-subscription-store.js.map +1 -0
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +54 -0
- package/dist/daemon.js.map +1 -1
- package/dist/inbound-sessions.d.ts +6 -0
- package/dist/inbound-sessions.d.ts.map +1 -1
- package/dist/inbound-sessions.js +27 -1
- package/dist/inbound-sessions.js.map +1 -1
- package/dist/refusal-reasons.d.ts +7 -0
- package/dist/refusal-reasons.d.ts.map +1 -1
- package/dist/refusal-reasons.js +15 -0
- package/dist/refusal-reasons.js.map +1 -1
- package/dist/session-content-context.d.ts +8 -0
- package/dist/session-content-context.d.ts.map +1 -1
- package/dist/session-content-ingest.d.ts.map +1 -1
- package/dist/session-content-ingest.js +28 -0
- package/dist/session-content-ingest.js.map +1 -1
- package/dist/session-node-manager.d.ts +4 -0
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +11 -0
- package/dist/session-node-manager.js.map +1 -1
- package/package.json +5 -5
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChannelPublisher — M16 018-PUBCOLLECT, the publishing half.
|
|
3
|
+
*
|
|
4
|
+
* A channel publishes through **two relays of its own choosing**, so one being down does not stop
|
|
5
|
+
* delivery. Nothing coordinates them: each holds its own queue, and the subscriber takes the union.
|
|
6
|
+
*
|
|
7
|
+
* ─── The order of operations, and why it is not negotiable ───────────────────────────────────
|
|
8
|
+
*
|
|
9
|
+
* 1. screen the title and body — refuse, never warn-and-send
|
|
10
|
+
* 2. encrypt the body (unless public)
|
|
11
|
+
* 3. take the next number, sign with BOTH keys
|
|
12
|
+
* 4. APPEND TO THE LOG
|
|
13
|
+
* 5. deposit on both relays, store each receipt
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ **STEP 4 IS BEFORE STEP 5, AND THAT IS THE WHOLE DESIGN.** A post that reached a relay but not
|
|
16
|
+
* the log is invisible to its own publisher: it cannot be resent to a relay that lost it, cannot be
|
|
17
|
+
* pruned, and cannot be proved. The log is the durable copy; the network is the optimistic part.
|
|
18
|
+
* `resendMissing` exists precisely because the log outlives any relay's memory.
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ **ONE RELAY FAILING IS NOT A FAILED PUBLISH.** Treating it as one would make a two-relay design
|
|
21
|
+
* strictly less available than a single-relay design, which inverts the reason for having two.
|
|
22
|
+
* Both failing gives `no_relay_accepted` — and the post STAYS in the log, because the next attempt
|
|
23
|
+
* has to send the same signed bytes rather than a new post at a new number.
|
|
24
|
+
*/
|
|
25
|
+
import type { Logger } from "./types.js";
|
|
26
|
+
import type { KeyProvider } from "@cello-protocol/crypto";
|
|
27
|
+
import { type ChannelAccess } from "@cello-protocol/protocol-types";
|
|
28
|
+
import { ChannelLogStore } from "./channel-log-store.js";
|
|
29
|
+
/** How the publisher reaches a relay. The transport is the caller's; this owns the decisions. */
|
|
30
|
+
export type RelayDepositSeam = (relay: string, req: {
|
|
31
|
+
post_cbor: Uint8Array;
|
|
32
|
+
/**
|
|
33
|
+
* ⚠️ **THIS IS WHAT MAKES AN EJECTION BITE AT THE RELAY.** The relay serves a non-public
|
|
34
|
+
* channel's queue only to a caller who can sign with the channel's current fetch key, and that
|
|
35
|
+
* key is DERIVED from the group key — so rotating the group key on an ejection rotates this
|
|
36
|
+
* too. Sent with the first deposit of each generation: until the relay has it, the ejected
|
|
37
|
+
* member can still FETCH (they just cannot decrypt), and "cannot read" is a weaker property
|
|
38
|
+
* than the one the order asks for.
|
|
39
|
+
*
|
|
40
|
+
* It carries its own channel signature because the post's does not cover it — see 017's note
|
|
41
|
+
* on the takeover this closes.
|
|
42
|
+
*/
|
|
43
|
+
fetch_key?: {
|
|
44
|
+
pubkey: Uint8Array;
|
|
45
|
+
time_ms: number;
|
|
46
|
+
signature: Uint8Array;
|
|
47
|
+
};
|
|
48
|
+
}) => Promise<{
|
|
49
|
+
ok: true;
|
|
50
|
+
receipt_cbor: Uint8Array;
|
|
51
|
+
} | {
|
|
52
|
+
ok: false;
|
|
53
|
+
reason: string;
|
|
54
|
+
skew_ms?: number;
|
|
55
|
+
}>;
|
|
56
|
+
/** Deposit the channel's info record. Separate from a post: it carries no sequence and no receipt. */
|
|
57
|
+
export type RelayInfoDepositSeam = (relay: string, req: {
|
|
58
|
+
info_cbor: Uint8Array;
|
|
59
|
+
}) => Promise<{
|
|
60
|
+
ok: true;
|
|
61
|
+
} | {
|
|
62
|
+
ok: false;
|
|
63
|
+
reason: string;
|
|
64
|
+
}>;
|
|
65
|
+
/** Ask a relay to drop everything through a post number, oldest end only. */
|
|
66
|
+
export type RelayPruneSeam = (relay: string, req: {
|
|
67
|
+
channelHex: string;
|
|
68
|
+
throughSeq: number;
|
|
69
|
+
timeMs: number;
|
|
70
|
+
signature: Uint8Array;
|
|
71
|
+
}) => Promise<{
|
|
72
|
+
ok: true;
|
|
73
|
+
dropped?: number;
|
|
74
|
+
} | {
|
|
75
|
+
ok: false;
|
|
76
|
+
reason: string;
|
|
77
|
+
}>;
|
|
78
|
+
export type ScreenVerdict = {
|
|
79
|
+
disposition: "allow" | "block" | "warn" | "redact";
|
|
80
|
+
reason?: string;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Gap between deposits during a refill, so a long backlog stays under the relay's per-publisher
|
|
84
|
+
* rate limit. Only `resendMissing` paces — an ordinary publish is two deposits and pacing it would
|
|
85
|
+
* add latency to every post to protect against a burst that cannot happen.
|
|
86
|
+
*/
|
|
87
|
+
export declare const DEFAULT_RESEND_PACE_MS = 50;
|
|
88
|
+
export interface ChannelPublisherOptions {
|
|
89
|
+
logger: Logger;
|
|
90
|
+
log: ChannelLogStore;
|
|
91
|
+
deposit: RelayDepositSeam;
|
|
92
|
+
depositInfo: RelayInfoDepositSeam;
|
|
93
|
+
/**
|
|
94
|
+
* Optional ONLY so a caller that cannot prune says so: an absent seam reports each relay as
|
|
95
|
+
* `relay_prune_unavailable`, never as a successful prune.
|
|
96
|
+
*/
|
|
97
|
+
prune?: RelayPruneSeam;
|
|
98
|
+
/**
|
|
99
|
+
* Where a relay's queue begins and ends. Optional: without it `resendMissing` re-sends everything
|
|
100
|
+
* logged, which is correct but chattier. See the note at its use for why a RECEIPT is not the
|
|
101
|
+
* right question to ask.
|
|
102
|
+
*/
|
|
103
|
+
relayHead?: (relay: string, channelHex: string) => Promise<{
|
|
104
|
+
first_held_seq: number | null;
|
|
105
|
+
last_seq: number | null;
|
|
106
|
+
}>;
|
|
107
|
+
screenOutbound: (bytes: Uint8Array, ctx: {
|
|
108
|
+
agentName: string;
|
|
109
|
+
correlationId?: string;
|
|
110
|
+
}) => Promise<ScreenVerdict>;
|
|
111
|
+
getChannelKey: (channelHex: string) => KeyProvider | null;
|
|
112
|
+
getAgentKey: (agentName: string) => KeyProvider | null;
|
|
113
|
+
/**
|
|
114
|
+
* 019 owns the group key; the publisher must not be able to tell what this does.
|
|
115
|
+
*
|
|
116
|
+
* ⚠️ TAKES THE POST NUMBER, because the body's associated data binds it. Without seq here the
|
|
117
|
+
* encryption cannot bind the position, and a hostile relay can serve one post's body at another's
|
|
118
|
+
* number with every signature still verifying.
|
|
119
|
+
*/
|
|
120
|
+
encryptBody: (plaintext: Uint8Array, channelHex: string, seq: number) => Promise<Uint8Array>;
|
|
121
|
+
/** Overridable so a test can refill without waiting; production takes the default. */
|
|
122
|
+
resendPaceMs?: number;
|
|
123
|
+
/**
|
|
124
|
+
* The channel's CURRENT fetch key, or undefined for a public channel (which has none — anyone may
|
|
125
|
+
* read it). Asked per publish so a re-key reaches the relays on the very next post rather than
|
|
126
|
+
* waiting for a restart.
|
|
127
|
+
*/
|
|
128
|
+
currentFetchKey?: (channelHex: string) => Promise<{
|
|
129
|
+
pubkey: Uint8Array;
|
|
130
|
+
time_ms: number;
|
|
131
|
+
signature: Uint8Array;
|
|
132
|
+
} | undefined>;
|
|
133
|
+
channelInfo: (channelHex: string) => {
|
|
134
|
+
access: ChannelAccess;
|
|
135
|
+
relays: string[];
|
|
136
|
+
guidance: string;
|
|
137
|
+
retention_seconds: number;
|
|
138
|
+
} | null;
|
|
139
|
+
now?: () => number;
|
|
140
|
+
}
|
|
141
|
+
export type PublishRefusal = "blocked_by_screen" | "channel_unknown" | "key_unavailable" | "no_relay_accepted" | "post_invalid";
|
|
142
|
+
export interface DepositOutcome {
|
|
143
|
+
relay: string;
|
|
144
|
+
/** Did this relay TAKE the post. Nothing else belongs in this flag — see `receipt_unfiled`. */
|
|
145
|
+
ok: boolean;
|
|
146
|
+
reason?: string;
|
|
147
|
+
/** What the relay said our clock is off by, carried so the correction can use the largest. */
|
|
148
|
+
skew_ms?: number;
|
|
149
|
+
/**
|
|
150
|
+
* The relay took the post and its receipt could not be filed (unverifiable, or naming bytes this
|
|
151
|
+
* log does not hold). SEPARATE FROM `ok` on purpose: folding it in reported a post that is safely
|
|
152
|
+
* on two relays as `no_relay_accepted`, and sent the operator to resend something already there.
|
|
153
|
+
*/
|
|
154
|
+
receipt_unfiled?: string;
|
|
155
|
+
}
|
|
156
|
+
export type PublishResult = {
|
|
157
|
+
ok: true;
|
|
158
|
+
seq: number;
|
|
159
|
+
deposited: DepositOutcome[];
|
|
160
|
+
} | {
|
|
161
|
+
ok: false;
|
|
162
|
+
reason: PublishRefusal;
|
|
163
|
+
detail?: string;
|
|
164
|
+
seq?: number;
|
|
165
|
+
deposited?: DepositOutcome[];
|
|
166
|
+
};
|
|
167
|
+
export declare class ChannelPublisher {
|
|
168
|
+
#private;
|
|
169
|
+
constructor(opts: ChannelPublisherOptions);
|
|
170
|
+
publish(agentName: string, channelHex: string, title: string, body: string, correlationId?: string): Promise<PublishResult>;
|
|
171
|
+
/**
|
|
172
|
+
* Re-deposit everything a relay has not receipted, oldest first.
|
|
173
|
+
*
|
|
174
|
+
* This is what refills a relay that lost content and what fills a newly added one — the same path,
|
|
175
|
+
* which is why it is exercised by ordinary operation rather than only by a crash.
|
|
176
|
+
*/
|
|
177
|
+
resendMissing(agentName: string, channelHex: string, relay: string, correlationId?: string): Promise<{
|
|
178
|
+
deposited: number;
|
|
179
|
+
}>;
|
|
180
|
+
/**
|
|
181
|
+
* Prune the log, then tell both relays to drop the same range.
|
|
182
|
+
*
|
|
183
|
+
* ⚠️ **EACH RELAY'S OUTCOME IS THE ONE IT GAVE.** This used to report `ok: true` for every relay
|
|
184
|
+
* without contacting any of them, so an operator pruning 500 posts was shown two successful
|
|
185
|
+
* relays while both still held and served every post. A relay that is down is `ok: false` with its
|
|
186
|
+
* reason, and its copy survives until its own retention sweeps it — which is what retention is for.
|
|
187
|
+
*/
|
|
188
|
+
pruneChannel(agentName: string, channelHex: string, throughSeq: number, correlationId?: string): Promise<{
|
|
189
|
+
pruned: number;
|
|
190
|
+
relays: Array<{
|
|
191
|
+
relay: string;
|
|
192
|
+
ok: boolean;
|
|
193
|
+
reason?: string;
|
|
194
|
+
}>;
|
|
195
|
+
}>;
|
|
196
|
+
/**
|
|
197
|
+
* Sign and deposit the channel's info record. Only the CHANNEL key signs it.
|
|
198
|
+
*
|
|
199
|
+
* ⚠️ **THE DEPOSIT IS THE POINT, NOT THE SIGNATURE.** The record is the only way a subscriber
|
|
200
|
+
* learns a channel's relays, access and admin key. This used to sign it, log
|
|
201
|
+
* `channel.info.published` and return the bytes to the caller without contacting a relay — so an
|
|
202
|
+
* operator was told their channel was published while no one could find it. The event now names
|
|
203
|
+
* only the relays that actually took it.
|
|
204
|
+
*/
|
|
205
|
+
publishInfo(agentName: string, channelHex: string, correlationId?: string): Promise<{
|
|
206
|
+
ok: true;
|
|
207
|
+
info_cbor: Uint8Array;
|
|
208
|
+
relays: Array<{
|
|
209
|
+
relay: string;
|
|
210
|
+
ok: boolean;
|
|
211
|
+
reason?: string;
|
|
212
|
+
}>;
|
|
213
|
+
} | {
|
|
214
|
+
ok: false;
|
|
215
|
+
reason: PublishRefusal;
|
|
216
|
+
detail?: string;
|
|
217
|
+
}>;
|
|
218
|
+
/** The relays this channel publishes to, so a caller can refill all of them without naming one. */
|
|
219
|
+
relaysFor(channelHex: string): string[];
|
|
220
|
+
}
|
|
221
|
+
//# sourceMappingURL=channel-publisher.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"channel-publisher.d.ts","sourceRoot":"","sources":["../src/channel-publisher.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { decodeRelayPostReceipt, encodeBroadcastArtifact, encodeChannelInfo, signBroadcastArtifact, signChannelInfo, verifyRelayPostReceipt, buildChannelPruneTbs, } from "@cello-protocol/protocol-types";
|
|
2
|
+
/**
|
|
3
|
+
* Gap between deposits during a refill, so a long backlog stays under the relay's per-publisher
|
|
4
|
+
* rate limit. Only `resendMissing` paces — an ordinary publish is two deposits and pacing it would
|
|
5
|
+
* add latency to every post to protect against a burst that cannot happen.
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_RESEND_PACE_MS = 50;
|
|
8
|
+
export class ChannelPublisher {
|
|
9
|
+
#opts;
|
|
10
|
+
#now;
|
|
11
|
+
constructor(opts) {
|
|
12
|
+
this.#opts = opts;
|
|
13
|
+
this.#now = opts.now ?? (() => Date.now());
|
|
14
|
+
}
|
|
15
|
+
async publish(agentName, channelHex, title, body, correlationId) {
|
|
16
|
+
const { logger, log } = this.#opts;
|
|
17
|
+
const info = this.#opts.channelInfo(channelHex);
|
|
18
|
+
if (!info)
|
|
19
|
+
return { ok: false, reason: "channel_unknown", detail: "no channel info for that pubkey" };
|
|
20
|
+
const channelKey = this.#opts.getChannelKey(channelHex);
|
|
21
|
+
const agentKey = this.#opts.getAgentKey(agentName);
|
|
22
|
+
if (!channelKey || !agentKey) {
|
|
23
|
+
return { ok: false, reason: "key_unavailable", detail: "the channel key or the agent key is not loaded" };
|
|
24
|
+
}
|
|
25
|
+
// 1. Screen BEFORE anything is signed or stored. The subscriber's inbound screen is the
|
|
26
|
+
// enforcement; this is the early check that spares an honest publisher the friction of
|
|
27
|
+
// publishing something every reader will refuse.
|
|
28
|
+
const verdict = await this.#opts.screenOutbound(new TextEncoder().encode(`${title}\n${body}`), { agentName, ...(correlationId !== undefined ? { correlationId } : {}) });
|
|
29
|
+
if (verdict.disposition !== "allow") {
|
|
30
|
+
logger.info("channel.publish.refused", {
|
|
31
|
+
...(correlationId !== undefined ? { correlationId } : {}),
|
|
32
|
+
channel_pubkey: channelHex, disposition: verdict.disposition, reason: verdict.reason ?? "blocked",
|
|
33
|
+
});
|
|
34
|
+
return { ok: false, reason: "blocked_by_screen", detail: verdict.reason ?? verdict.disposition };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 2. THE POSITION COMES FIRST, because the encryption binds it.
|
|
38
|
+
*
|
|
39
|
+
* ⚠️ The order's step list says encrypt then take the number, and that cannot be implemented:
|
|
40
|
+
* the body's associated data is (channel, seq, generation), so encrypting before the number
|
|
41
|
+
* exists means binding the wrong one — or not binding it, which is what lets a hostile relay
|
|
42
|
+
* serve post 7's body at position 3 with every signature still checking out. Taking the number
|
|
43
|
+
* first changes nothing else: the LOG is still written after signing and before any deposit,
|
|
44
|
+
* which is the part of that order that carries weight.
|
|
45
|
+
*/
|
|
46
|
+
log.ensureChannel(channelHex);
|
|
47
|
+
const { seq } = log.nextPosition(channelHex);
|
|
48
|
+
// 3. A public channel is readable by anyone, so encrypting it would be theatre — and would lock
|
|
49
|
+
// out the subscribers it exists for, who hold no key.
|
|
50
|
+
const plaintext = new TextEncoder().encode(body);
|
|
51
|
+
const wire = info.access === "public" ? plaintext : await this.#opts.encryptBody(plaintext, channelHex, seq);
|
|
52
|
+
let post;
|
|
53
|
+
try {
|
|
54
|
+
post = await signBroadcastArtifact(channelKey, agentKey, {
|
|
55
|
+
seq, published_at: this.#now(), title, body: wire, supersedes: null, ext: null,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
return { ok: false, reason: "post_invalid", detail: extract(err) };
|
|
60
|
+
}
|
|
61
|
+
// 4. THE LOG, BEFORE THE NETWORK.
|
|
62
|
+
log.append(channelHex, post, correlationId);
|
|
63
|
+
/**
|
|
64
|
+
* 5. Both relays, in parallel — a deposit is independent of the other, and making the second
|
|
65
|
+
* wait on the first would double the latency of the ordinary case for no gain.
|
|
66
|
+
*
|
|
67
|
+
* ⚠️ THE FETCH KEY RIDES ALONG. A public channel has none. For every other channel this is what
|
|
68
|
+
* makes an ejection bite AT THE RELAY rather than only at the ciphertext: until the relay holds
|
|
69
|
+
* the new generation's key, an ejected member can still pull the queue.
|
|
70
|
+
*/
|
|
71
|
+
const fetchKey = info.access === "public" ? undefined : await this.#opts.currentFetchKey?.(channelHex);
|
|
72
|
+
/**
|
|
73
|
+
* ⚠️ **A NON-PUBLIC CHANNEL WITH NO FETCH KEY REFUSES TO PUBLISH.** Depositing without one tells
|
|
74
|
+
* the relay nothing about who may read, so it serves the queue to ANY caller — an `access` of
|
|
75
|
+
* invite_only with a queue open to the world, which is worse than not publishing. The absent key
|
|
76
|
+
* means no group key has been minted (nobody has joined yet, or this daemon does not administer
|
|
77
|
+
* the channel), and both are states to name rather than paper over.
|
|
78
|
+
*/
|
|
79
|
+
if (info.access !== "public" && !fetchKey) {
|
|
80
|
+
logger.warn("channel.publish.refused", {
|
|
81
|
+
...(correlationId !== undefined ? { correlationId } : {}),
|
|
82
|
+
channel_pubkey: channelHex, reason: "no_fetch_key",
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
ok: false, reason: "key_unavailable", seq,
|
|
86
|
+
detail: "this channel has no group key yet, so the relays cannot be told who may read it; admit a member first",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
let deposited = await Promise.all(info.relays.map((relay) => this.#depositOnce(relay, post, channelHex, correlationId, fetchKey)));
|
|
90
|
+
let ok = deposited.filter((d) => d.ok);
|
|
91
|
+
/**
|
|
92
|
+
* ⚠️ **THE CLOCK-SKEW CORRECTION IS ONE POST FOR ALL RELAYS, AND ONLY WHILE NONE HAS TAKEN IT.**
|
|
93
|
+
*
|
|
94
|
+
* `published_at` is inside both signatures, so a corrected post is different BYTES at the same
|
|
95
|
+
* number. Correcting per relay would let relay A hold one body at seq N and relay B another —
|
|
96
|
+
* and a subscriber taking the union of the two sees exactly what a fork looks like, produced by
|
|
97
|
+
* an honest publisher with a wrong clock. So the correction happens here, once, and only when
|
|
98
|
+
* NO relay accepted the original: a relay that already answered holds a receipt bound to those
|
|
99
|
+
* bytes by hash, and re-signing under it would strand the proof.
|
|
100
|
+
*/
|
|
101
|
+
if (ok.length === 0 && deposited.some((d) => d.reason === "clock_skew")) {
|
|
102
|
+
const corrected = await this.#resignForSkew(agentName, channelHex, post, deposited, correlationId);
|
|
103
|
+
if (corrected) {
|
|
104
|
+
post = corrected;
|
|
105
|
+
deposited = await Promise.all(info.relays.map((relay) => this.#depositOnce(relay, post, channelHex, correlationId)));
|
|
106
|
+
ok = deposited.filter((d) => d.ok);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
logger.info("channel.post.published", {
|
|
110
|
+
...(correlationId !== undefined ? { correlationId } : {}),
|
|
111
|
+
channel_pubkey: channelHex,
|
|
112
|
+
seq,
|
|
113
|
+
relays_ok: ok.map((d) => d.relay),
|
|
114
|
+
relays_failed: deposited.filter((d) => !d.ok).map((d) => d.relay),
|
|
115
|
+
});
|
|
116
|
+
if (ok.length === 0) {
|
|
117
|
+
// The post STAYS in the log. A retry must send these bytes, not a new post at a new number:
|
|
118
|
+
// the number and the time are inside both signatures.
|
|
119
|
+
return { ok: false, reason: "no_relay_accepted", seq, deposited };
|
|
120
|
+
}
|
|
121
|
+
return { ok: true, seq, deposited };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* One deposit on one relay. No retry lives here — see the skew note in `publish`, which is where
|
|
125
|
+
* a correction has to happen if it is to produce the same bytes for every relay.
|
|
126
|
+
*
|
|
127
|
+
* A relay that TOOK the post is `ok: true` even when its receipt could not be filed. The two facts
|
|
128
|
+
* are reported separately because folding them together turned a post that is safely on two relays
|
|
129
|
+
* into `no_relay_accepted`, and sent the operator to resend something already there.
|
|
130
|
+
*/
|
|
131
|
+
async #depositOnce(relay, post, channelHex, correlationId, fetchKey) {
|
|
132
|
+
const { logger } = this.#opts;
|
|
133
|
+
let answer;
|
|
134
|
+
try {
|
|
135
|
+
answer = await this.#opts.deposit(relay, {
|
|
136
|
+
post_cbor: encodeBroadcastArtifact(post),
|
|
137
|
+
...(fetchKey ? { fetch_key: fetchKey } : {}),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
logger.warn("channel.post.deposit_failed", {
|
|
142
|
+
...(correlationId !== undefined ? { correlationId } : {}),
|
|
143
|
+
channel_pubkey: channelHex, seq: post.seq, relay, reason: extract(err),
|
|
144
|
+
});
|
|
145
|
+
return { relay, ok: false, reason: extract(err) };
|
|
146
|
+
}
|
|
147
|
+
if (answer.ok) {
|
|
148
|
+
const filed = this.#storeReceipt(channelHex, post, answer.receipt_cbor, relay, correlationId);
|
|
149
|
+
return filed.ok ? { relay, ok: true } : { relay, ok: true, receipt_unfiled: filed.reason };
|
|
150
|
+
}
|
|
151
|
+
logger.warn("channel.post.deposit_failed", {
|
|
152
|
+
...(correlationId !== undefined ? { correlationId } : {}),
|
|
153
|
+
channel_pubkey: channelHex, seq: post.seq, relay, reason: answer.reason,
|
|
154
|
+
});
|
|
155
|
+
return {
|
|
156
|
+
relay, ok: false, reason: answer.reason,
|
|
157
|
+
...(answer.skew_ms !== undefined ? { skew_ms: answer.skew_ms } : {}),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Re-sign a post ONCE at a clock the relays will accept, and put the corrected bytes in the log.
|
|
162
|
+
*
|
|
163
|
+
* The correction is the LARGEST skew any relay reported: a post the fastest relay will take is one
|
|
164
|
+
* the others will too, and correcting to the smallest would leave the strictest relay refusing
|
|
165
|
+
* again with no retry left.
|
|
166
|
+
*
|
|
167
|
+
* Returns `null` when the correction cannot be made — the keys are gone, or the log refuses to
|
|
168
|
+
* replace bytes it has already receipted. `null` leaves the ORIGINAL post standing, which is the
|
|
169
|
+
* safe direction: the post is in the log at its number and `resendMissing` can carry it later.
|
|
170
|
+
*/
|
|
171
|
+
async #resignForSkew(agentName, channelHex, post, deposited, correlationId) {
|
|
172
|
+
const { logger } = this.#opts;
|
|
173
|
+
const channelKey = this.#opts.getChannelKey(channelHex);
|
|
174
|
+
// BY NAME, from the caller. This read the empty string once, which is no agent, so the lookup
|
|
175
|
+
// was always null and the correction never ran in production — the test harness ignored the
|
|
176
|
+
// argument, so nothing said so.
|
|
177
|
+
const agentKey = this.#opts.getAgentKey(agentName);
|
|
178
|
+
if (!channelKey || !agentKey) {
|
|
179
|
+
logger.warn("channel.post.resign_failed", {
|
|
180
|
+
...(correlationId !== undefined ? { correlationId } : {}),
|
|
181
|
+
channel_pubkey: channelHex, seq: post.seq, reason: "key_unavailable",
|
|
182
|
+
});
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
const skews = deposited.map((d) => d.skew_ms ?? 0);
|
|
186
|
+
const corrected = this.#now() - Math.max(...skews);
|
|
187
|
+
let resigned;
|
|
188
|
+
try {
|
|
189
|
+
resigned = await signBroadcastArtifact(channelKey, agentKey, {
|
|
190
|
+
seq: post.seq, published_at: corrected, title: post.title,
|
|
191
|
+
body: post.body, supersedes: post.supersedes, ext: null,
|
|
192
|
+
});
|
|
193
|
+
this.#opts.log.replaceUnreceipted(channelHex, resigned, correlationId);
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
logger.warn("channel.post.resign_failed", {
|
|
197
|
+
...(correlationId !== undefined ? { correlationId } : {}),
|
|
198
|
+
channel_pubkey: channelHex, seq: post.seq, reason: extract(err),
|
|
199
|
+
});
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
return resigned;
|
|
203
|
+
}
|
|
204
|
+
/** Verify a receipt against the post it names before storing it; an unverified one is worthless. */
|
|
205
|
+
#storeReceipt(channelHex, post, receiptCbor, relay, correlationId) {
|
|
206
|
+
const decoded = decodeRelayPostReceipt(receiptCbor);
|
|
207
|
+
if (!decoded.ok || !verifyRelayPostReceipt(decoded.receipt, post)) {
|
|
208
|
+
this.#opts.logger.warn("channel.post.deposit_failed", {
|
|
209
|
+
...(correlationId !== undefined ? { correlationId } : {}),
|
|
210
|
+
channel_pubkey: channelHex, seq: post.seq, relay, reason: "receipt_invalid",
|
|
211
|
+
});
|
|
212
|
+
return { ok: false, reason: "receipt_invalid" };
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
this.#opts.log.recordReceipt(channelHex, decoded.receipt, correlationId);
|
|
216
|
+
return { ok: true };
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
// A receipt naming bytes this log does not hold. The relay DID take the post — the caller
|
|
220
|
+
// reports that separately, because calling the deposit failed would send the operator to
|
|
221
|
+
// resend a post the relay already has.
|
|
222
|
+
this.#opts.logger.warn("channel.post.receipt_unfiled", {
|
|
223
|
+
...(correlationId !== undefined ? { correlationId } : {}),
|
|
224
|
+
channel_pubkey: channelHex, seq: post.seq, relay, reason: extract(err),
|
|
225
|
+
});
|
|
226
|
+
return { ok: false, reason: extract(err) };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Re-deposit everything a relay has not receipted, oldest first.
|
|
231
|
+
*
|
|
232
|
+
* This is what refills a relay that lost content and what fills a newly added one — the same path,
|
|
233
|
+
* which is why it is exercised by ordinary operation rather than only by a crash.
|
|
234
|
+
*/
|
|
235
|
+
async resendMissing(agentName, channelHex, relay, correlationId) {
|
|
236
|
+
const { log, logger } = this.#opts;
|
|
237
|
+
// A channel that has been set up but never published has no log row yet, and `head` THROWS on
|
|
238
|
+
// one. Nothing to resend is an ANSWER — `deposited: 0` — not an error: a freshly created channel
|
|
239
|
+
// is the most likely thing an operator runs this against, and it came back looking broken.
|
|
240
|
+
log.ensureChannel(channelHex);
|
|
241
|
+
const head = log.head(channelHex);
|
|
242
|
+
if (head.first_seq === null || head.last_seq === null)
|
|
243
|
+
return { deposited: 0 };
|
|
244
|
+
/**
|
|
245
|
+
* ⚠️ WHAT THE RELAY HOLDS NOW, NOT WHAT IT ONCE RECEIPTED — and this is a deviation from the
|
|
246
|
+
* order, raised there.
|
|
247
|
+
*
|
|
248
|
+
* The order says to deposit "every logged post that relay has not receipted". But a receipt
|
|
249
|
+
* proves the relay TOOK the post once, not that it still has it — and the case the same sentence
|
|
250
|
+
* names, "refills a relay that lost content", is precisely a relay whose receipts are all in the
|
|
251
|
+
* log and whose queue is empty. Skipping on receipts refills nothing, which the enforcer caught:
|
|
252
|
+
* a restarted relay got 3 posts back out of 6.
|
|
253
|
+
*
|
|
254
|
+
* So the relay is asked where its queue begins and ends, and anything outside that is sent. When
|
|
255
|
+
* it cannot be asked, every logged post is sent — a repeat is a no-op the relay answers with the
|
|
256
|
+
* receipt it already signed, so the cost of over-sending is bandwidth and the cost of
|
|
257
|
+
* under-sending is a relay permanently missing posts.
|
|
258
|
+
*/
|
|
259
|
+
let holds = null;
|
|
260
|
+
if (this.#opts.relayHead) {
|
|
261
|
+
try {
|
|
262
|
+
const reported = await this.#opts.relayHead(relay, channelHex);
|
|
263
|
+
if (reported.first_held_seq !== null && reported.last_seq !== null) {
|
|
264
|
+
holds = { first: reported.first_held_seq, last: reported.last_seq };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
logger.warn("channel.resend.head_unavailable", {
|
|
269
|
+
channel_pubkey: channelHex, relay, reason: extract(err),
|
|
270
|
+
impact: "every logged post is re-sent; a repeat is a no-op at the relay",
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
let deposited = 0;
|
|
275
|
+
// Oldest first: a relay's queue only accepts the next number, so any other order stalls at the
|
|
276
|
+
// first gap and refills nothing after it.
|
|
277
|
+
for (const post of log.readRange(channelHex, head.first_seq, head.last_seq)) {
|
|
278
|
+
if (holds !== null && post.seq >= holds.first && post.seq <= holds.last)
|
|
279
|
+
continue;
|
|
280
|
+
// PACED. A refill of a long backbone is the one path that deposits hundreds of posts in a
|
|
281
|
+
// row, and the relay rate-limits per publisher — so an unpaced refill trips the limiter part
|
|
282
|
+
// way through and the rest of the backlog is refused, which looks exactly like a relay that
|
|
283
|
+
// will not take the channel's posts at all.
|
|
284
|
+
if (deposited > 0)
|
|
285
|
+
await this.#pause(this.#opts.resendPaceMs ?? DEFAULT_RESEND_PACE_MS);
|
|
286
|
+
const outcome = await this.#depositOnce(relay, post, channelHex, correlationId);
|
|
287
|
+
if (outcome.ok)
|
|
288
|
+
deposited += 1;
|
|
289
|
+
}
|
|
290
|
+
logger.info("channel.resend.completed", {
|
|
291
|
+
...(correlationId !== undefined ? { correlationId } : {}),
|
|
292
|
+
// WHO ran it. A refill and a prune are the two verbs that change what the world can read, and
|
|
293
|
+
// an operator asking "why did the backbone change" needs the acting agent in the line.
|
|
294
|
+
agent: agentName,
|
|
295
|
+
channel_pubkey: channelHex, relay, deposited,
|
|
296
|
+
});
|
|
297
|
+
return { deposited };
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Prune the log, then tell both relays to drop the same range.
|
|
301
|
+
*
|
|
302
|
+
* ⚠️ **EACH RELAY'S OUTCOME IS THE ONE IT GAVE.** This used to report `ok: true` for every relay
|
|
303
|
+
* without contacting any of them, so an operator pruning 500 posts was shown two successful
|
|
304
|
+
* relays while both still held and served every post. A relay that is down is `ok: false` with its
|
|
305
|
+
* reason, and its copy survives until its own retention sweeps it — which is what retention is for.
|
|
306
|
+
*/
|
|
307
|
+
async pruneChannel(agentName, channelHex, throughSeq, correlationId) {
|
|
308
|
+
const { logger } = this.#opts;
|
|
309
|
+
// Same as `resendMissing`: pruning a channel with nothing in it yet is "pruned 0", not an error.
|
|
310
|
+
this.#opts.log.ensureChannel(channelHex);
|
|
311
|
+
const { pruned } = this.#opts.log.pruneThrough(channelHex, throughSeq);
|
|
312
|
+
const info = this.#opts.channelInfo(channelHex);
|
|
313
|
+
const relays = info?.relays ?? [];
|
|
314
|
+
/**
|
|
315
|
+
* ⚠️ THE PRUNE IS SIGNED BY THE CHANNEL KEY, and it has to be: the frame is not a post, so
|
|
316
|
+
* nothing else proves the caller owns the channel. An unsigned prune would let anyone who knows
|
|
317
|
+
* a channel's public key delete its backbone from both relays.
|
|
318
|
+
*/
|
|
319
|
+
const channelKey = this.#opts.getChannelKey(channelHex);
|
|
320
|
+
const timeMs = this.#now();
|
|
321
|
+
const signature = channelKey
|
|
322
|
+
? await channelKey.sign(buildChannelPruneTbs(Buffer.from(channelHex, "hex"), throughSeq, timeMs))
|
|
323
|
+
: null;
|
|
324
|
+
const outcomes = await Promise.all(relays.map(async (relay) => {
|
|
325
|
+
if (!this.#opts.prune || signature === null) {
|
|
326
|
+
// No seam wired, or no key to sign with, is not a successful prune. Say which it was.
|
|
327
|
+
return { relay, ok: false, reason: signature === null ? "key_unavailable" : "relay_prune_unavailable" };
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
const answer = await this.#opts.prune(relay, { channelHex, throughSeq, timeMs, signature });
|
|
331
|
+
if (!answer.ok) {
|
|
332
|
+
logger.warn("channel.prune.relay_refused", {
|
|
333
|
+
...(correlationId !== undefined ? { correlationId } : {}),
|
|
334
|
+
channel_pubkey: channelHex, relay, through_seq: throughSeq, reason: answer.reason,
|
|
335
|
+
});
|
|
336
|
+
return { relay, ok: false, reason: answer.reason };
|
|
337
|
+
}
|
|
338
|
+
return { relay, ok: true };
|
|
339
|
+
}
|
|
340
|
+
catch (err) {
|
|
341
|
+
logger.warn("channel.prune.relay_unreachable", {
|
|
342
|
+
...(correlationId !== undefined ? { correlationId } : {}),
|
|
343
|
+
channel_pubkey: channelHex, relay, through_seq: throughSeq, reason: extract(err),
|
|
344
|
+
});
|
|
345
|
+
return { relay, ok: false, reason: extract(err) };
|
|
346
|
+
}
|
|
347
|
+
}));
|
|
348
|
+
logger.info("channel.log.pruned", {
|
|
349
|
+
...(correlationId !== undefined ? { correlationId } : {}),
|
|
350
|
+
agent: agentName,
|
|
351
|
+
channel_pubkey: channelHex, through_seq: throughSeq, pruned,
|
|
352
|
+
relays_pruned: outcomes.filter((o) => o.ok).map((o) => o.relay),
|
|
353
|
+
relays_still_holding: outcomes.filter((o) => !o.ok).map((o) => o.relay),
|
|
354
|
+
});
|
|
355
|
+
return { pruned, relays: outcomes };
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Sign and deposit the channel's info record. Only the CHANNEL key signs it.
|
|
359
|
+
*
|
|
360
|
+
* ⚠️ **THE DEPOSIT IS THE POINT, NOT THE SIGNATURE.** The record is the only way a subscriber
|
|
361
|
+
* learns a channel's relays, access and admin key. This used to sign it, log
|
|
362
|
+
* `channel.info.published` and return the bytes to the caller without contacting a relay — so an
|
|
363
|
+
* operator was told their channel was published while no one could find it. The event now names
|
|
364
|
+
* only the relays that actually took it.
|
|
365
|
+
*/
|
|
366
|
+
async publishInfo(agentName, channelHex, correlationId) {
|
|
367
|
+
const { logger } = this.#opts;
|
|
368
|
+
const info = this.#opts.channelInfo(channelHex);
|
|
369
|
+
const channelKey = this.#opts.getChannelKey(channelHex);
|
|
370
|
+
const agentKey = this.#opts.getAgentKey(agentName);
|
|
371
|
+
if (!info)
|
|
372
|
+
return { ok: false, reason: "channel_unknown" };
|
|
373
|
+
if (!channelKey || !agentKey)
|
|
374
|
+
return { ok: false, reason: "key_unavailable" };
|
|
375
|
+
const record = await signChannelInfo(channelKey, {
|
|
376
|
+
access: info.access,
|
|
377
|
+
admin_pubkey: await agentKey.getPublicKey(),
|
|
378
|
+
relays: info.relays,
|
|
379
|
+
guidance: info.guidance,
|
|
380
|
+
retention_seconds: info.retention_seconds,
|
|
381
|
+
updated_at: this.#now(),
|
|
382
|
+
ext: null,
|
|
383
|
+
});
|
|
384
|
+
const info_cbor = encodeChannelInfo(record);
|
|
385
|
+
const outcomes = await Promise.all(info.relays.map(async (relay) => {
|
|
386
|
+
try {
|
|
387
|
+
const answer = await this.#opts.depositInfo(relay, { info_cbor });
|
|
388
|
+
if (!answer.ok)
|
|
389
|
+
return { relay, ok: false, reason: answer.reason };
|
|
390
|
+
return { relay, ok: true };
|
|
391
|
+
}
|
|
392
|
+
catch (err) {
|
|
393
|
+
return { relay, ok: false, reason: extract(err) };
|
|
394
|
+
}
|
|
395
|
+
}));
|
|
396
|
+
const took = outcomes.filter((o) => o.ok);
|
|
397
|
+
if (took.length === 0) {
|
|
398
|
+
logger.warn("channel.info.deposit_failed", {
|
|
399
|
+
...(correlationId !== undefined ? { correlationId } : {}),
|
|
400
|
+
channel_pubkey: channelHex,
|
|
401
|
+
reasons: outcomes.map((o) => o.reason ?? "unknown"),
|
|
402
|
+
});
|
|
403
|
+
return { ok: false, reason: "no_relay_accepted", detail: outcomes.map((o) => `${o.relay}: ${o.reason ?? "unknown"}`).join("; ") };
|
|
404
|
+
}
|
|
405
|
+
logger.info("channel.info.published", {
|
|
406
|
+
...(correlationId !== undefined ? { correlationId } : {}),
|
|
407
|
+
channel_pubkey: channelHex, access: info.access,
|
|
408
|
+
relays_ok: took.map((o) => o.relay),
|
|
409
|
+
relays_failed: outcomes.filter((o) => !o.ok).map((o) => o.relay),
|
|
410
|
+
});
|
|
411
|
+
return { ok: true, info_cbor, relays: outcomes };
|
|
412
|
+
}
|
|
413
|
+
/** The relays this channel publishes to, so a caller can refill all of them without naming one. */
|
|
414
|
+
relaysFor(channelHex) {
|
|
415
|
+
return this.#opts.channelInfo(channelHex)?.relays ?? [];
|
|
416
|
+
}
|
|
417
|
+
/** The pacing gap between refill deposits. Its own method so a test can drive it to zero. */
|
|
418
|
+
#pause(ms) {
|
|
419
|
+
if (ms <= 0)
|
|
420
|
+
return Promise.resolve();
|
|
421
|
+
return new Promise((resolve) => { setTimeout(resolve, ms); });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
function extract(err) {
|
|
425
|
+
if (err instanceof Error)
|
|
426
|
+
return err.message;
|
|
427
|
+
const m = err?.message;
|
|
428
|
+
return typeof m === "string" ? m : JSON.stringify(err);
|
|
429
|
+
}
|
|
430
|
+
//# sourceMappingURL=channel-publisher.js.map
|