@plane31/blind-relay 0.0.0-stage → 0.1.2
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/LICENSE +202 -0
- package/README.md +26 -2
- package/dist/adapters/index.d.ts +18 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +22 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/audience.d.ts +77 -0
- package/dist/audience.d.ts.map +1 -0
- package/dist/audience.js +76 -0
- package/dist/audience.js.map +1 -0
- package/dist/bytes.d.ts +54 -0
- package/dist/bytes.d.ts.map +1 -0
- package/dist/bytes.js +200 -0
- package/dist/bytes.js.map +1 -0
- package/dist/circle.d.ts +59 -0
- package/dist/circle.d.ts.map +1 -0
- package/dist/circle.js +105 -0
- package/dist/circle.js.map +1 -0
- package/dist/client.d.ts +234 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1365 -0
- package/dist/client.js.map +1 -0
- package/dist/codec.d.ts +189 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/codec.js +512 -0
- package/dist/codec.js.map +1 -0
- package/dist/crypto/backend.d.ts +39 -0
- package/dist/crypto/backend.d.ts.map +1 -0
- package/dist/crypto/backend.js +8 -0
- package/dist/crypto/backend.js.map +1 -0
- package/dist/crypto/noble.d.ts +3 -0
- package/dist/crypto/noble.d.ts.map +1 -0
- package/dist/crypto/noble.js +107 -0
- package/dist/crypto/noble.js.map +1 -0
- package/dist/crypto/sodium.d.ts +9 -0
- package/dist/crypto/sodium.d.ts.map +1 -0
- package/dist/crypto/sodium.js +51 -0
- package/dist/crypto/sodium.js.map +1 -0
- package/dist/crypto.d.ts +66 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +225 -0
- package/dist/crypto.js.map +1 -0
- package/dist/epoch.d.ts +93 -0
- package/dist/epoch.d.ts.map +1 -0
- package/dist/epoch.js +121 -0
- package/dist/epoch.js.map +1 -0
- package/dist/errors.d.ts +17 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +30 -0
- package/dist/errors.js.map +1 -0
- package/dist/historyIndex.d.ts +15 -0
- package/dist/historyIndex.d.ts.map +1 -0
- package/dist/historyIndex.js +57 -0
- package/dist/historyIndex.js.map +1 -0
- package/dist/identity.d.ts +62 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +99 -0
- package/dist/identity.js.map +1 -0
- package/dist/inbox.d.ts +153 -0
- package/dist/inbox.d.ts.map +1 -0
- package/dist/inbox.js +472 -0
- package/dist/inbox.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/outbox.d.ts +96 -0
- package/dist/outbox.d.ts.map +1 -0
- package/dist/outbox.js +339 -0
- package/dist/outbox.js.map +1 -0
- package/dist/policy.d.ts +33 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +71 -0
- package/dist/policy.js.map +1 -0
- package/dist/record.d.ts +49 -0
- package/dist/record.d.ts.map +1 -0
- package/dist/record.js +46 -0
- package/dist/record.js.map +1 -0
- package/dist/reset.d.ts +74 -0
- package/dist/reset.d.ts.map +1 -0
- package/dist/reset.js +127 -0
- package/dist/reset.js.map +1 -0
- package/dist/rows.d.ts +16 -0
- package/dist/rows.d.ts.map +1 -0
- package/dist/rows.js +103 -0
- package/dist/rows.js.map +1 -0
- package/dist/slots.d.ts +22 -0
- package/dist/slots.d.ts.map +1 -0
- package/dist/slots.js +38 -0
- package/dist/slots.js.map +1 -0
- package/dist/store.d.ts +357 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +192 -0
- package/dist/store.js.map +1 -0
- package/dist/testing/clock.d.ts +14 -0
- package/dist/testing/clock.d.ts.map +1 -0
- package/dist/testing/clock.js +27 -0
- package/dist/testing/clock.js.map +1 -0
- package/dist/testing/fakeHost.d.ts +118 -0
- package/dist/testing/fakeHost.d.ts.map +1 -0
- package/dist/testing/fakeHost.js +462 -0
- package/dist/testing/fakeHost.js.map +1 -0
- package/dist/testing/faults.d.ts +97 -0
- package/dist/testing/faults.d.ts.map +1 -0
- package/dist/testing/faults.js +264 -0
- package/dist/testing/faults.js.map +1 -0
- package/dist/testing/fixtures.d.ts +15 -0
- package/dist/testing/fixtures.d.ts.map +1 -0
- package/dist/testing/fixtures.js +22 -0
- package/dist/testing/fixtures.js.map +1 -0
- package/dist/testing/index.d.ts +7 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +7 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/providers.d.ts +16 -0
- package/dist/testing/providers.d.ts.map +1 -0
- package/dist/testing/providers.js +36 -0
- package/dist/testing/providers.js.map +1 -0
- package/dist/testing/sweep.d.ts +46 -0
- package/dist/testing/sweep.d.ts.map +1 -0
- package/dist/testing/sweep.js +366 -0
- package/dist/testing/sweep.js.map +1 -0
- package/dist/transactions.d.ts +75 -0
- package/dist/transactions.d.ts.map +1 -0
- package/dist/transactions.js +212 -0
- package/dist/transactions.js.map +1 -0
- package/dist/transport.d.ts +125 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +6 -0
- package/dist/transport.js.map +1 -0
- package/dist/vault.d.ts +59 -0
- package/dist/vault.d.ts.map +1 -0
- package/dist/vault.js +69 -0
- package/dist/vault.js.map +1 -0
- package/package.json +67 -3
- package/src/adapters/index.ts +102 -0
- package/src/audience.ts +123 -0
- package/src/bytes.ts +209 -0
- package/src/circle.ts +127 -0
- package/src/client.ts +1442 -0
- package/src/codec.ts +642 -0
- package/src/crypto/backend.ts +48 -0
- package/src/crypto/noble.ts +104 -0
- package/src/crypto/node-module.d.ts +11 -0
- package/src/crypto/sodium.ts +53 -0
- package/src/crypto.ts +266 -0
- package/src/epoch.ts +201 -0
- package/src/errors.ts +78 -0
- package/src/historyIndex.ts +61 -0
- package/src/identity.ts +158 -0
- package/src/inbox.ts +560 -0
- package/src/index.ts +59 -0
- package/src/outbox.ts +385 -0
- package/src/policy.ts +88 -0
- package/src/record.ts +106 -0
- package/src/reset.ts +176 -0
- package/src/rows.ts +116 -0
- package/src/slots.ts +55 -0
- package/src/store.ts +557 -0
- package/src/testing/clock.ts +29 -0
- package/src/testing/fakeHost.ts +482 -0
- package/src/testing/faults.ts +321 -0
- package/src/testing/fixtures.ts +35 -0
- package/src/testing/index.ts +6 -0
- package/src/testing/providers.ts +39 -0
- package/src/testing/sweep.ts +407 -0
- package/src/transactions.ts +231 -0
- package/src/transport.ts +106 -0
- package/src/vault.ts +126 -0
package/src/outbox.ts
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The outbox and the sender (protocol section 10, publishing; transactions T6 and T6h).
|
|
3
|
+
*/
|
|
4
|
+
import { fromHex, toHex } from "./bytes.js";
|
|
5
|
+
import { rotationReason, MAX_COUNTER } from "./epoch.js";
|
|
6
|
+
import { bodyHash } from "./crypto.js";
|
|
7
|
+
import { KavachError } from "./errors.js";
|
|
8
|
+
import { indexHistory } from "./historyIndex.js";
|
|
9
|
+
import { kindOf, type RetentionPolicy } from "./policy.js";
|
|
10
|
+
import { clampEventTime, takeSlot } from "./slots.js";
|
|
11
|
+
import { sealRecord } from "./record.js";
|
|
12
|
+
import { big, unbig } from "./rows.js";
|
|
13
|
+
import type { HistoryRow, OutboxRow, Store, Tx } from "./store.js";
|
|
14
|
+
import { META, REVISION, STREAM, currentRevision, t3ApplyChange } from "./transactions.js";
|
|
15
|
+
import type { AppendOutcome, Relay } from "./transport.js";
|
|
16
|
+
import { recordWireBytes, type RecordKind } from "./codec.js";
|
|
17
|
+
|
|
18
|
+
export interface OutboxEvents {
|
|
19
|
+
queued?(recordId: Uint8Array, kind: RecordKind): void;
|
|
20
|
+
accepted?(recordId: Uint8Array): void;
|
|
21
|
+
cancelled?(recordId: Uint8Array, reason: string): void;
|
|
22
|
+
/** A record of a kind with a newest slot was cancelled or is in doubt: the app may resend its current state of that kind. */
|
|
23
|
+
refresh?(reason: string): void;
|
|
24
|
+
securityError?(code: string, detail: string): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface OutboxDeps {
|
|
28
|
+
readonly store: Store;
|
|
29
|
+
readonly relay: Relay;
|
|
30
|
+
readonly policy: RetentionPolicy;
|
|
31
|
+
readonly now: () => bigint;
|
|
32
|
+
readonly author: () => { streamId: Uint8Array; signingSecretKey: Uint8Array; generation: bigint; identityId: Uint8Array };
|
|
33
|
+
readonly events?: OutboxEvents;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function requireReady(tx: Pick<Tx, "get">): void {
|
|
37
|
+
const meta = tx.get("meta", META);
|
|
38
|
+
if (!meta) throw new KavachError("RECOVERY_REQUIRED", "enqueue", "no header");
|
|
39
|
+
if (meta.state === "RETIRED") throw new KavachError("SUPERSEDED_DEVICE", "enqueue", "this device is retired");
|
|
40
|
+
if (meta.state !== "ACTIVE") throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", "enqueue", `store is ${meta.state}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface EnqueueOptions {
|
|
44
|
+
/**
|
|
45
|
+
* A caller-chosen key for this hand-over, derived from the sample itself (its fix time and
|
|
46
|
+
* source, say). The guarantee: a keyed enqueue that returns has committed its record and the
|
|
47
|
+
* key in one transaction; a second enqueue under the same key, from a retry after a crash or
|
|
48
|
+
* from another runtime on the same store, returns that record with `duplicate: true` and seals
|
|
49
|
+
* nothing, concurrent calls included, since the store's writer lock orders them; a refused
|
|
50
|
+
* enqueue stores no key, so its retry is checked again. The key is kept for the delivery
|
|
51
|
+
* window and covers the hand-over, not delivery: the record it names can still be cancelled by
|
|
52
|
+
* a rotation, which the `cancelled` event reports as for any record. Up to 128 characters.
|
|
53
|
+
*/
|
|
54
|
+
readonly idempotencyKey?: string;
|
|
55
|
+
/**
|
|
56
|
+
* The event time of what the record describes (a fix time, say), carried in the encrypted
|
|
57
|
+
* envelope. A reader orders the kind's newest slot by it, clamped to the send time. Absent for a
|
|
58
|
+
* kind with no natural event time; the slot then orders by send time. Zero means absent: the
|
|
59
|
+
* envelope has no other way to say "none", and the sender's own slot follows the same reading.
|
|
60
|
+
*/
|
|
61
|
+
readonly eventTime?: bigint;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface Enqueued {
|
|
65
|
+
recordId: Uint8Array;
|
|
66
|
+
counter: number;
|
|
67
|
+
epoch: number;
|
|
68
|
+
/** True when the idempotency key matched an earlier enqueue and this call made no record. */
|
|
69
|
+
duplicate: boolean;
|
|
70
|
+
/** True when this record took the sender's own newest slot for its kind. */
|
|
71
|
+
own: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const MAX_IDEMPOTENCY_KEY = 128;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* T6: require a usable active epoch under the current desired revision, enforce the queue limits,
|
|
78
|
+
* seal once, write the outbox row and the own history row, and advance the counter. The record
|
|
79
|
+
* id is returned only after commit. A kind declared latestWins cancels a queued record of the same
|
|
80
|
+
* kind when a newer one is enqueued, never a sent one; a slot-only kind writes no history row. An
|
|
81
|
+
* idempotency key is answered before any check: its record is already durable, whatever has
|
|
82
|
+
* happened to the queue since.
|
|
83
|
+
*/
|
|
84
|
+
export async function enqueue(deps: OutboxDeps, kind: RecordKind, payload: Uint8Array, options: EnqueueOptions = {}): Promise<Enqueued> {
|
|
85
|
+
const stage = "enqueue";
|
|
86
|
+
const declared = kindOf(deps.policy, kind);
|
|
87
|
+
if (!declared) throw new KavachError("MALFORMED", stage, `kind ${kind} is not declared in the retention policy`);
|
|
88
|
+
const key = options.idempotencyKey;
|
|
89
|
+
if (key !== undefined && (key.length === 0 || key.length > MAX_IDEMPOTENCY_KEY)) throw new KavachError("MALFORMED", stage, "idempotency key must be 1 to 128 characters");
|
|
90
|
+
// One reading of the event time for the envelope, the own slot and the history row: zero is none.
|
|
91
|
+
const eventTime = options.eventTime === undefined || options.eventTime === 0n ? undefined : options.eventTime;
|
|
92
|
+
if (eventTime !== undefined && eventTime < 0n) throw new KavachError("MALFORMED", stage, "event time before the epoch");
|
|
93
|
+
const superseded: Uint8Array[] = [];
|
|
94
|
+
const result = await deps.store.transaction((tx) => {
|
|
95
|
+
requireReady(tx);
|
|
96
|
+
if (key !== undefined) {
|
|
97
|
+
const seen = tx.get("enqueueKeys", key);
|
|
98
|
+
if (seen) return { recordId: fromHex(seen.recordId), counter: seen.counter, epoch: seen.epoch, duplicate: true, own: false } as const;
|
|
99
|
+
}
|
|
100
|
+
const revision = tx.get("revision", REVISION);
|
|
101
|
+
if (revision?.paused === "metadata") throw new KavachError("METADATA_REQUIRED", stage);
|
|
102
|
+
if (revision?.paused === "limit") throw new KavachError("RECIPIENT_LIMIT", stage);
|
|
103
|
+
const stream = tx.get("stream", STREAM);
|
|
104
|
+
if (!stream || stream.activeEpoch === undefined || revision?.paused === "rotation") throw new KavachError("ROTATION_REQUIRED", stage, "no active epoch");
|
|
105
|
+
const epoch = tx.get("epochs", String(stream.activeEpoch));
|
|
106
|
+
if (!epoch || epoch.status !== "active") throw new KavachError("ROTATION_REQUIRED", stage, "epoch not active");
|
|
107
|
+
const now = deps.now();
|
|
108
|
+
const reason = rotationReason({ createdAt: unbig(epoch.createdAt), nextCounter: epoch.nextCounter, lastSentAt: epoch.lastSentAt === undefined ? undefined : unbig(epoch.lastSentAt) }, now, deps.policy.hygieneRotationMs);
|
|
109
|
+
if (reason) {
|
|
110
|
+
// Commit the invalidation (T3) and report the rotation after the transaction, so the
|
|
111
|
+
// burned counters and cancelled rows are durable even though nothing was enqueued.
|
|
112
|
+
t3ApplyChange(tx, { kind: reason });
|
|
113
|
+
return { rotate: reason } as const;
|
|
114
|
+
}
|
|
115
|
+
let count = 0;
|
|
116
|
+
let bytes = 0;
|
|
117
|
+
for (const [key, row] of tx.list("outbox")) {
|
|
118
|
+
if (declared.queue === "latestWins" && row.kind === kind && row.status === "queued") {
|
|
119
|
+
tx.put("outbox", key, terminal(row, "cancelled", "superseded"));
|
|
120
|
+
superseded.push(fromHex(row.recordId));
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (row.status === "queued" || row.status === "inflight") {
|
|
124
|
+
count++;
|
|
125
|
+
bytes += row.wire.length / 2;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (count >= deps.policy.maxOutboxRecords || bytes + recordWireBytes(payload.length) > deps.policy.maxOutboxBytes) throw new KavachError("QUEUE_FULL", stage);
|
|
129
|
+
const a = deps.author();
|
|
130
|
+
const sealed = sealRecord({
|
|
131
|
+
streamId: a.streamId,
|
|
132
|
+
epoch: epoch.epoch,
|
|
133
|
+
nextCounter: epoch.nextCounter,
|
|
134
|
+
kind,
|
|
135
|
+
eventTime,
|
|
136
|
+
headerClass: declared.headerClass,
|
|
137
|
+
payload,
|
|
138
|
+
sentAt: now,
|
|
139
|
+
epochKey: fromHex(epoch.epochKey),
|
|
140
|
+
signingSecretKey: a.signingSecretKey
|
|
141
|
+
});
|
|
142
|
+
const id = toHex(sealed.recordId);
|
|
143
|
+
tx.put("outbox", id, {
|
|
144
|
+
recordId: id,
|
|
145
|
+
wire: toHex(sealed.wire),
|
|
146
|
+
epoch: epoch.epoch,
|
|
147
|
+
counter: sealed.counter,
|
|
148
|
+
kind,
|
|
149
|
+
sentAt: big(now),
|
|
150
|
+
desiredRevision: big(currentRevision(tx)),
|
|
151
|
+
status: "queued",
|
|
152
|
+
attempts: 0,
|
|
153
|
+
reason: undefined
|
|
154
|
+
});
|
|
155
|
+
if (key !== undefined) tx.put("enqueueKeys", key, { recordId: id, epoch: epoch.epoch, counter: sealed.counter, sentAt: big(now) });
|
|
156
|
+
// The sender's own row, under the rules a reader applies (section 10): history when the kind
|
|
157
|
+
// keeps it, and the own newest slot when the kind has one, so `newest` answers for self.
|
|
158
|
+
const self = toHex(a.identityId);
|
|
159
|
+
const hash = toHex(bodyHash(sealed.wire.subarray(0, sealed.wire.length - 64)));
|
|
160
|
+
const own = declared.newest ? takeSlot(tx, self, kind, { generation: a.generation, eventTime: clampEventTime(eventTime, now), epoch: epoch.epoch, counter: sealed.counter, recordId: id, bodyHash: hash, sentAt: now, receivedAt: now, payload }, toHex) : false;
|
|
161
|
+
if (declared.history) {
|
|
162
|
+
const ownRow: HistoryRow = {
|
|
163
|
+
recordId: id,
|
|
164
|
+
authorIdentityId: toHex(a.identityId),
|
|
165
|
+
streamId: toHex(a.streamId),
|
|
166
|
+
generation: big(a.generation),
|
|
167
|
+
epoch: epoch.epoch,
|
|
168
|
+
counter: sealed.counter,
|
|
169
|
+
kind,
|
|
170
|
+
sentAt: big(now),
|
|
171
|
+
receivedAt: big(now),
|
|
172
|
+
plaintext: toHex(payload),
|
|
173
|
+
superseded: false,
|
|
174
|
+
own: true,
|
|
175
|
+
...(eventTime === undefined ? {} : { eventTime: big(eventTime) })
|
|
176
|
+
};
|
|
177
|
+
tx.put("history", id, ownRow);
|
|
178
|
+
indexHistory(tx, ownRow);
|
|
179
|
+
}
|
|
180
|
+
const nextCounter = epoch.nextCounter + 1;
|
|
181
|
+
tx.put("epochs", String(epoch.epoch), { ...epoch, nextCounter, lastSentAt: big(now) });
|
|
182
|
+
if (nextCounter > MAX_COUNTER) {
|
|
183
|
+
// The final counter was just used: the next enqueue rotates.
|
|
184
|
+
}
|
|
185
|
+
return { recordId: sealed.recordId, counter: sealed.counter, epoch: epoch.epoch, duplicate: false, own } as const;
|
|
186
|
+
});
|
|
187
|
+
if ("rotate" in result) {
|
|
188
|
+
throw new KavachError(result.rotate === "counterExhausted" ? "COUNTER_EXHAUSTED" : "ROTATION_REQUIRED", stage, result.rotate);
|
|
189
|
+
}
|
|
190
|
+
if (result.duplicate) return result;
|
|
191
|
+
for (const id of superseded) deps.events?.cancelled?.(id, "superseded");
|
|
192
|
+
deps.events?.queued?.(result.recordId, kind);
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface DrainSummary {
|
|
197
|
+
sent: number;
|
|
198
|
+
accepted: number;
|
|
199
|
+
cancelled: number;
|
|
200
|
+
unknown: number;
|
|
201
|
+
retry: number;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** "none": nothing to send, the drain ends. "skipped": a row was cancelled, the next one is tried. */
|
|
205
|
+
type HandoffStep = { kind: "none" } | { kind: "skipped" } | { kind: "sent"; key: string; row: OutboxRow; outcome: Promise<AppendOutcome> };
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The single serialised sender: one record in flight per stream, drained in (epoch, counter)
|
|
209
|
+
* order. Each handoff is T6h: re-check eligibility, mark in flight, start the send inside the
|
|
210
|
+
* transaction, commit; the response is awaited outside and recorded in a new transaction that
|
|
211
|
+
* revalidates first.
|
|
212
|
+
*/
|
|
213
|
+
export async function drain(deps: OutboxDeps, maxRecords = 1000): Promise<DrainSummary> {
|
|
214
|
+
const summary: DrainSummary = { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 };
|
|
215
|
+
for (let i = 0; i < maxRecords; i++) {
|
|
216
|
+
const step = await handoffNext(deps, summary);
|
|
217
|
+
if (step.kind === "none") break;
|
|
218
|
+
if (step.kind === "skipped") continue; // a cancelled row counts against the limit, never ends the drain
|
|
219
|
+
summary.sent++;
|
|
220
|
+
let outcome: AppendOutcome | undefined;
|
|
221
|
+
try {
|
|
222
|
+
outcome = await step.outcome;
|
|
223
|
+
} catch {
|
|
224
|
+
outcome = undefined; // lost response
|
|
225
|
+
}
|
|
226
|
+
const final = await recordOutcome(deps, step.key, outcome);
|
|
227
|
+
if (final === "accepted") summary.accepted++;
|
|
228
|
+
else if (final === "cancelled") summary.cancelled++;
|
|
229
|
+
else if (final === "unknown") summary.unknown++;
|
|
230
|
+
else {
|
|
231
|
+
summary.retry++;
|
|
232
|
+
break; // a retryable failure ends this drain; the next sync re-enters T6h
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return summary;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Dead rows cancelled per handoff transaction; a larger backlog is finished over the next handoffs. */
|
|
239
|
+
const CANCEL_BATCH = 500;
|
|
240
|
+
|
|
241
|
+
/** A row that will not be sent again keeps its summary and sheds the ciphertext. */
|
|
242
|
+
function terminal(row: OutboxRow, status: "cancelled" | "unknown", reason: string): OutboxRow {
|
|
243
|
+
return { ...row, wire: "", status, reason };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function handoffNext(deps: OutboxDeps, summary: DrainSummary): Promise<HandoffStep> {
|
|
247
|
+
// Application events fire after the commit, never from inside it: a subscriber cannot roll a
|
|
248
|
+
// transition back, and never sees state the store has not kept yet.
|
|
249
|
+
const after: (() => void)[] = [];
|
|
250
|
+
const step = await deps.store.transaction((tx): HandoffStep => {
|
|
251
|
+
const meta = tx.get("meta", META);
|
|
252
|
+
if (!meta || meta.state !== "ACTIVE") return { kind: "none" };
|
|
253
|
+
const now = deps.now();
|
|
254
|
+
const revision = currentRevision(tx);
|
|
255
|
+
// One pass: every row that is no longer sendable (its revision moved on, or its window has
|
|
256
|
+
// passed) is cancelled here, in bounded batches, and the first sendable row goes out in the
|
|
257
|
+
// same transaction. A backlog of dead rows costs one scan, not one transaction per row.
|
|
258
|
+
const live: [string, OutboxRow][] = [];
|
|
259
|
+
let cancelled = 0;
|
|
260
|
+
let refresh = false;
|
|
261
|
+
for (const [key, row] of [...tx.list("outbox")].sort(([, a], [, b]) => a.epoch - b.epoch || a.counter - b.counter)) {
|
|
262
|
+
if (row.status !== "queued" && row.status !== "inflight") continue;
|
|
263
|
+
const reason = cancelled >= CANCEL_BATCH ? undefined : unbig(row.desiredRevision) !== revision ? "rotation" : unbig(row.sentAt) + deps.policy.deliveryWindowMs <= now ? "expired" : undefined;
|
|
264
|
+
if (!reason) {
|
|
265
|
+
live.push([key, row]);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
tx.put("outbox", key, terminal(row, "cancelled", reason));
|
|
269
|
+
cancelled++;
|
|
270
|
+
after.push(() => deps.events?.cancelled?.(fromHex(row.recordId), reason));
|
|
271
|
+
if (reason === "rotation" && kindOf(deps.policy, row.kind)?.newest) refresh = true;
|
|
272
|
+
}
|
|
273
|
+
summary.cancelled += cancelled;
|
|
274
|
+
if (refresh) after.push(() => deps.events?.refresh?.("rotation"));
|
|
275
|
+
// A full batch means unchecked rows remain: finish them in the next handoff before sending.
|
|
276
|
+
if (cancelled >= CANCEL_BATCH) return { kind: "skipped" };
|
|
277
|
+
const next = live.find(([, r]) => r.status === "inflight") ?? live[0];
|
|
278
|
+
if (!next) return { kind: "none" };
|
|
279
|
+
const [key, row] = next;
|
|
280
|
+
const marked: OutboxRow = { ...row, status: "inflight", attempts: row.attempts + 1 };
|
|
281
|
+
tx.put("outbox", key, marked);
|
|
282
|
+
let outcome: Promise<AppendOutcome>;
|
|
283
|
+
try {
|
|
284
|
+
outcome = tx.handoff(() => deps.relay.appendRecord(fromHex(row.recordId), fromHex(row.wire)));
|
|
285
|
+
} catch {
|
|
286
|
+
// A synchronous transport failure: roll the in-flight mark back and stop this drain.
|
|
287
|
+
tx.put("outbox", key, { ...row, attempts: row.attempts + 1 });
|
|
288
|
+
summary.retry++;
|
|
289
|
+
return { kind: "none" };
|
|
290
|
+
}
|
|
291
|
+
return { kind: "sent", key, row: marked, outcome };
|
|
292
|
+
});
|
|
293
|
+
for (const fire of after) fire();
|
|
294
|
+
return step;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
type Final = "accepted" | "cancelled" | "unknown" | "retry";
|
|
298
|
+
|
|
299
|
+
export async function recordOutcome(deps: OutboxDeps, key: string, outcome: AppendOutcome | undefined): Promise<Final> {
|
|
300
|
+
const after: (() => void)[] = [];
|
|
301
|
+
const final = await deps.store.transaction((tx): Final => {
|
|
302
|
+
const row = tx.get("outbox", key);
|
|
303
|
+
if (!row || row.status !== "inflight") return "retry";
|
|
304
|
+
const id = fromHex(row.recordId);
|
|
305
|
+
const requeue = (): void => tx.put("outbox", key, { ...row, status: "queued", reason: undefined });
|
|
306
|
+
const cancel = (reason: string): void => tx.put("outbox", key, terminal(row, "cancelled", reason));
|
|
307
|
+
if (!outcome) {
|
|
308
|
+
requeue(); // lost response: retry the identical bytes; the relay answers from its checkpoint
|
|
309
|
+
return "retry";
|
|
310
|
+
}
|
|
311
|
+
switch (outcome.status) {
|
|
312
|
+
case "ACCEPTED":
|
|
313
|
+
case "ALREADY_ACCEPTED":
|
|
314
|
+
tx.delete("outbox", key);
|
|
315
|
+
after.push(() => deps.events?.accepted?.(id));
|
|
316
|
+
return "accepted";
|
|
317
|
+
case "RETRYABLE":
|
|
318
|
+
requeue();
|
|
319
|
+
return "retry";
|
|
320
|
+
case "STALE_APPEND":
|
|
321
|
+
tx.put("outbox", key, terminal(row, "unknown", "STALE_APPEND"));
|
|
322
|
+
after.push(() => deps.events?.cancelled?.(id, "acceptanceUnknown"));
|
|
323
|
+
if (kindOf(deps.policy, row.kind)?.newest) after.push(() => deps.events?.refresh?.("acceptanceUnknown"));
|
|
324
|
+
return "unknown";
|
|
325
|
+
case "EQUIVOCATION":
|
|
326
|
+
cancel("EQUIVOCATION");
|
|
327
|
+
after.push(() => deps.events?.securityError?.("EQUIVOCATION", "the relay holds a different record under this id"));
|
|
328
|
+
return "cancelled";
|
|
329
|
+
case "EPOCH_FENCED":
|
|
330
|
+
case "SUPERSEDED_DEVICE":
|
|
331
|
+
case "UNAUTHORIZED":
|
|
332
|
+
cancel(outcome.status);
|
|
333
|
+
after.push(() => deps.events?.cancelled?.(id, outcome.status));
|
|
334
|
+
if (kindOf(deps.policy, row.kind)?.newest) after.push(() => deps.events?.refresh?.(outcome.status));
|
|
335
|
+
return "cancelled";
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
for (const fire of after) fire();
|
|
339
|
+
return final;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Retention for rows that will not be sent again: they go once the delivery window has passed. */
|
|
343
|
+
export async function purgeOutbox(store: Store, policy: RetentionPolicy, now: bigint): Promise<number> {
|
|
344
|
+
return store.transaction((tx) => {
|
|
345
|
+
let purged = 0;
|
|
346
|
+
for (const [key, row] of tx.list("outbox")) {
|
|
347
|
+
if ((row.status === "cancelled" || row.status === "unknown") && unbig(row.sentAt) + policy.deliveryWindowMs <= now) {
|
|
348
|
+
tx.delete("outbox", key);
|
|
349
|
+
purged++;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// Idempotency keys outlive the delivery window by nothing: a replay later is a new record.
|
|
353
|
+
for (const [key, row] of tx.list("enqueueKeys")) if (unbig(row.sentAt) + policy.deliveryWindowMs <= now) tx.delete("enqueueKeys", key);
|
|
354
|
+
return purged;
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export interface OutboxCounts {
|
|
359
|
+
readonly queued: number;
|
|
360
|
+
readonly inflight: number;
|
|
361
|
+
readonly cancelled: number;
|
|
362
|
+
readonly unknown: number;
|
|
363
|
+
/** Age of the oldest row still to send, when there is one. */
|
|
364
|
+
readonly oldestPendingAgeMs: bigint | undefined;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Rows by state, for diagnostics. */
|
|
368
|
+
export function outboxCounts(tx: Pick<Tx, "list">, now: bigint): OutboxCounts {
|
|
369
|
+
const counts = { queued: 0, inflight: 0, cancelled: 0, unknown: 0 };
|
|
370
|
+
let oldest: bigint | undefined;
|
|
371
|
+
for (const [, r] of tx.list("outbox")) {
|
|
372
|
+
if (r.status === "accepted") continue;
|
|
373
|
+
counts[r.status]++;
|
|
374
|
+
if (r.status === "queued" || r.status === "inflight") {
|
|
375
|
+
const sentAt = unbig(r.sentAt);
|
|
376
|
+
if (oldest === undefined || sentAt < oldest) oldest = sentAt;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return { ...counts, oldestPendingAgeMs: oldest === undefined ? undefined : now - oldest };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Rows still waiting, for state reporting. */
|
|
383
|
+
export async function outboxCount(store: Store): Promise<number> {
|
|
384
|
+
return store.read((tx) => tx.list("outbox").filter(([, r]) => r.status === "queued" || r.status === "inflight").length);
|
|
385
|
+
}
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The versioned retention policy a profile passes to `open()` (protocol section 13): the global
|
|
3
|
+
* windows, and one declaration per record kind the product uses. The SDK treats a kind as an
|
|
4
|
+
* opaque small integer whose behaviour is what the declaration says. No defaults.
|
|
5
|
+
*/
|
|
6
|
+
import { KavachError } from "./errors.js";
|
|
7
|
+
|
|
8
|
+
/** How the SDK treats one record kind; what the kind means is the product's. */
|
|
9
|
+
export interface KindDeclaration {
|
|
10
|
+
/** The kind number carried by the record. Until revision 8 the wire carries 0, 1 or 2. */
|
|
11
|
+
readonly kind: number;
|
|
12
|
+
/** How long a reader keeps records and the newest slot's payload, from `sentAt`. */
|
|
13
|
+
readonly retentionMs: bigint;
|
|
14
|
+
/** `keepAll`: every queued record is sent. `latestWins`: a newer queued record of this kind cancels the older one (`superseded`). */
|
|
15
|
+
readonly queue: "keepAll" | "latestWins";
|
|
16
|
+
/** Whether records are kept in indexed, pageable history. */
|
|
17
|
+
readonly history: boolean;
|
|
18
|
+
/** Whether one newest-per-author slot is kept, from the author's current device, ordered by event time. */
|
|
19
|
+
readonly newest: boolean;
|
|
20
|
+
/** The two header bits the relay may see for this kind (0 to 3, 0 when absent), for routing and notifications without content. */
|
|
21
|
+
readonly headerClass: 0 | 1 | 2 | 3;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RetentionPolicy {
|
|
25
|
+
readonly version: number;
|
|
26
|
+
readonly deliveryWindowMs: bigint;
|
|
27
|
+
readonly appendRetryWindowMs: bigint;
|
|
28
|
+
readonly hygieneRotationMs: bigint;
|
|
29
|
+
readonly maxOutboxRecords: number;
|
|
30
|
+
readonly maxOutboxBytes: number;
|
|
31
|
+
readonly kinds: readonly KindDeclaration[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The largest kind the envelope carries. */
|
|
35
|
+
export const MAX_WIRE_KIND = 255;
|
|
36
|
+
|
|
37
|
+
const FIELDS: readonly (keyof RetentionPolicy)[] = ["version", "deliveryWindowMs", "appendRetryWindowMs", "hygieneRotationMs", "maxOutboxRecords", "maxOutboxBytes", "kinds"];
|
|
38
|
+
|
|
39
|
+
/** A missing or malformed policy is rejected; the SDK never substitutes defaults. */
|
|
40
|
+
export function validatePolicy(policy: unknown): RetentionPolicy {
|
|
41
|
+
const stage = "policy";
|
|
42
|
+
if (typeof policy !== "object" || policy === null) throw new KavachError("MALFORMED", stage, "retention policy missing");
|
|
43
|
+
const p = policy as Record<string, unknown>;
|
|
44
|
+
for (const f of FIELDS) if (!(f in p)) throw new KavachError("MALFORMED", stage, `retention policy lacks ${f}`);
|
|
45
|
+
const big = (f: string, v: unknown): bigint => {
|
|
46
|
+
if (typeof v !== "bigint" || v <= 0n) throw new KavachError("MALFORMED", stage, `${f} must be a positive bigint`);
|
|
47
|
+
return v;
|
|
48
|
+
};
|
|
49
|
+
const int = (f: string, v: unknown): number => {
|
|
50
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v <= 0) throw new KavachError("MALFORMED", stage, `${f} must be a positive integer`);
|
|
51
|
+
return v;
|
|
52
|
+
};
|
|
53
|
+
if (!Array.isArray(p.kinds) || p.kinds.length === 0) throw new KavachError("MALFORMED", stage, "kinds must declare at least one kind");
|
|
54
|
+
const seen = new Set<number>();
|
|
55
|
+
const kinds: KindDeclaration[] = p.kinds.map((raw: unknown, i: number) => {
|
|
56
|
+
if (typeof raw !== "object" || raw === null) throw new KavachError("MALFORMED", stage, `kinds[${i}] must be an object`);
|
|
57
|
+
const k = raw as Record<string, unknown>;
|
|
58
|
+
const kind = k.kind;
|
|
59
|
+
if (typeof kind !== "number" || !Number.isInteger(kind) || kind < 0 || kind > MAX_WIRE_KIND) throw new KavachError("MALFORMED", stage, `kinds[${i}].kind must be an integer from 0 to ${MAX_WIRE_KIND}`);
|
|
60
|
+
if (seen.has(kind)) throw new KavachError("MALFORMED", stage, `kind ${kind} declared twice`);
|
|
61
|
+
seen.add(kind);
|
|
62
|
+
if (k.queue !== "keepAll" && k.queue !== "latestWins") throw new KavachError("MALFORMED", stage, `kinds[${i}].queue must be keepAll or latestWins`);
|
|
63
|
+
if (typeof k.history !== "boolean" || typeof k.newest !== "boolean") throw new KavachError("MALFORMED", stage, `kinds[${i}].history and .newest must be booleans`);
|
|
64
|
+
if (!k.history && !k.newest) throw new KavachError("MALFORMED", stage, `kinds[${i}] keeps nothing: declare history or newest`);
|
|
65
|
+
const headerClass = k.headerClass ?? 0;
|
|
66
|
+
if (headerClass !== 0 && headerClass !== 1 && headerClass !== 2 && headerClass !== 3) throw new KavachError("MALFORMED", stage, `kinds[${i}].headerClass must be 0 to 3`);
|
|
67
|
+
return { kind, retentionMs: big(`kinds[${i}].retentionMs`, k.retentionMs), queue: k.queue, history: k.history, newest: k.newest, headerClass };
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
version: int("version", p.version),
|
|
71
|
+
deliveryWindowMs: big("deliveryWindowMs", p.deliveryWindowMs),
|
|
72
|
+
appendRetryWindowMs: big("appendRetryWindowMs", p.appendRetryWindowMs),
|
|
73
|
+
hygieneRotationMs: big("hygieneRotationMs", p.hygieneRotationMs),
|
|
74
|
+
maxOutboxRecords: int("maxOutboxRecords", p.maxOutboxRecords),
|
|
75
|
+
maxOutboxBytes: int("maxOutboxBytes", p.maxOutboxBytes),
|
|
76
|
+
kinds
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The declaration of a kind, or `undefined` for one the product did not declare. */
|
|
81
|
+
export function kindOf(policy: RetentionPolicy, kind: number): KindDeclaration | undefined {
|
|
82
|
+
return policy.kinds.find((k) => k.kind === kind);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The kinds kept in history, in declaration order. */
|
|
86
|
+
export function historyKinds(policy: RetentionPolicy): readonly number[] {
|
|
87
|
+
return policy.kinds.filter((k) => k.history).map((k) => k.kind);
|
|
88
|
+
}
|
package/src/record.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Records: sealing with the active epoch, opening with certificate and manifest resolution
|
|
3
|
+
* (protocol section 9).
|
|
4
|
+
*/
|
|
5
|
+
import { equalBytes, toHex } from "./bytes.js";
|
|
6
|
+
import {
|
|
7
|
+
DocumentType,
|
|
8
|
+
buildRecordWire,
|
|
9
|
+
decodeEnvelope,
|
|
10
|
+
expectType,
|
|
11
|
+
openRecord as openRecordBody,
|
|
12
|
+
parseDocument,
|
|
13
|
+
verifyDocument,
|
|
14
|
+
type EncryptedRecord,
|
|
15
|
+
type HeaderClass,
|
|
16
|
+
type Parsed,
|
|
17
|
+
type RecordKind
|
|
18
|
+
} from "./codec.js";
|
|
19
|
+
import { NONCE_BYTES, randomBytes, recordId } from "./crypto.js";
|
|
20
|
+
import type { AuthorView } from "./epoch.js";
|
|
21
|
+
import { MAX_COUNTER } from "./epoch.js";
|
|
22
|
+
import { KavachError } from "./errors.js";
|
|
23
|
+
|
|
24
|
+
export interface SealedRecord {
|
|
25
|
+
readonly wire: Uint8Array;
|
|
26
|
+
readonly recordId: Uint8Array;
|
|
27
|
+
readonly counter: number;
|
|
28
|
+
readonly kind: RecordKind;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Seal one record under the active epoch. The caller persists the result and the next counter (T6). */
|
|
32
|
+
export function sealRecord(input: {
|
|
33
|
+
readonly streamId: Uint8Array;
|
|
34
|
+
readonly epoch: number;
|
|
35
|
+
readonly nextCounter: number;
|
|
36
|
+
readonly kind: RecordKind;
|
|
37
|
+
/** The product's event time for the record, carried in the envelope; absent when the kind has none. */
|
|
38
|
+
readonly eventTime?: bigint | undefined;
|
|
39
|
+
/** The declared header class of the kind, the one thing the relay sees of it. */
|
|
40
|
+
readonly headerClass: HeaderClass;
|
|
41
|
+
readonly payload: Uint8Array;
|
|
42
|
+
readonly sentAt: bigint;
|
|
43
|
+
readonly epochKey: Uint8Array;
|
|
44
|
+
readonly signingSecretKey: Uint8Array;
|
|
45
|
+
readonly nonce?: Uint8Array;
|
|
46
|
+
}): SealedRecord {
|
|
47
|
+
if (input.nextCounter > MAX_COUNTER) throw new KavachError("COUNTER_EXHAUSTED", "sealRecord");
|
|
48
|
+
const nonce = input.nonce ?? randomBytes(NONCE_BYTES);
|
|
49
|
+
const wire = buildRecordWire(
|
|
50
|
+
{ streamId: input.streamId, epoch: input.epoch, counter: input.nextCounter, sentAt: input.sentAt, headerClass: input.headerClass, kind: input.kind, eventTime: input.eventTime },
|
|
51
|
+
input.payload,
|
|
52
|
+
input.epochKey,
|
|
53
|
+
nonce,
|
|
54
|
+
input.signingSecretKey
|
|
55
|
+
);
|
|
56
|
+
return { wire, recordId: recordId(input.streamId, input.epoch, input.nextCounter), counter: input.nextCounter, kind: input.kind };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface OpenedRecord {
|
|
60
|
+
readonly record: Parsed<EncryptedRecord>;
|
|
61
|
+
readonly recordId: Uint8Array;
|
|
62
|
+
/** From the envelope: the kind, the event time if the sender gave one, and the payload. */
|
|
63
|
+
readonly kind: RecordKind;
|
|
64
|
+
readonly eventTime: bigint | undefined;
|
|
65
|
+
readonly payload: Uint8Array;
|
|
66
|
+
/** The record comes from a device of the author that is no longer current: history only. */
|
|
67
|
+
readonly superseded: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Verify and open a record. The author view supplies the certificate for the record's stream
|
|
72
|
+
* (the current one, or an archived one for history); `epochKeyFor` returns the stored key for
|
|
73
|
+
* (stream, epoch) or `undefined`, which is `KEY_PENDING`.
|
|
74
|
+
*/
|
|
75
|
+
export function openRecordWire(
|
|
76
|
+
wire: Uint8Array,
|
|
77
|
+
author: AuthorView,
|
|
78
|
+
epochKeyFor: (streamId: Uint8Array, epoch: number) => Uint8Array | undefined
|
|
79
|
+
): OpenedRecord {
|
|
80
|
+
const stage = "openRecord";
|
|
81
|
+
const parsed = expectType<EncryptedRecord>(parseDocument(wire), DocumentType.EncryptedRecord, stage);
|
|
82
|
+
const cert = [...author.certificates.values()].find((c) => equalBytes(c.streamId, parsed.streamId));
|
|
83
|
+
if (!cert) throw new KavachError("NOT_AUTHORIZED", stage, "record stream belongs to no known device of the author");
|
|
84
|
+
const record = expectType<EncryptedRecord>(verifyDocument(wire, cert.signingKey), DocumentType.EncryptedRecord, stage);
|
|
85
|
+
const key = epochKeyFor(record.streamId, record.epoch);
|
|
86
|
+
if (!key) throw new KavachError("KEY_PENDING", stage, `no key for epoch ${record.epoch}`);
|
|
87
|
+
const envelope = decodeEnvelope(openRecordBody(record, key));
|
|
88
|
+
return { record, recordId: recordId(record.streamId, record.epoch, record.counter), kind: envelope.kind, eventTime: envelope.eventTime, payload: envelope.payload, superseded: !cert.current };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** (generation, epoch, counter) ordering for the live pin. */
|
|
92
|
+
export interface PinSlot {
|
|
93
|
+
readonly generation: bigint;
|
|
94
|
+
readonly epoch: number;
|
|
95
|
+
readonly counter: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function compareSlots(a: PinSlot, b: PinSlot): number {
|
|
99
|
+
if (a.generation !== b.generation) return a.generation < b.generation ? -1 : 1;
|
|
100
|
+
if (a.epoch !== b.epoch) return a.epoch - b.epoch;
|
|
101
|
+
return a.counter - b.counter;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function slotKey(streamId: Uint8Array, epoch: number, counter: number): string {
|
|
105
|
+
return `${toHex(streamId)}:${epoch}:${counter}`;
|
|
106
|
+
}
|