@staix/agent-hub 0.30.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/README.md +13 -7
- package/docs/agent-notes/README.md +1 -0
- package/docs/agent-notes/adapters.md +5 -2
- package/docs/agent-notes/budget.md +1 -0
- package/docs/agent-notes/bus.md +11 -4
- package/docs/agent-notes/daemon.md +16 -8
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +7 -3
- package/docs/agent-notes/tests.md +6 -4
- package/docs/design/adapters.md +52 -0
- package/docs/design/agent-hub-architecture.md +46 -0
- package/docs/design/bus.md +178 -1
- package/docs/design/task-board.md +82 -0
- package/docs/dogfood.md +23 -4
- package/docs/events.md +3 -0
- package/docs/memory.md +17 -1
- package/docs/operations.md +355 -78
- package/docs/profiles.md +35 -0
- package/docs/quickstart.md +24 -5
- package/docs/security.md +178 -60
- package/docs/specs/2026-09-19-agent-hub-design.md +127 -14
- package/docs/specs/2026-09-19-multi-project-design.md +7 -2
- package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
- package/docs/verification/2026-11-05-0.31.0.md +182 -0
- package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
- package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +311 -0
- package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
- package/docs/verification/2026-11-06-0.32.0.md +149 -0
- package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verified.json +14 -14
- package/package.json +3 -4
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +4393 -3609
- package/src/adapters/acp.ts +5 -4
- package/src/adapters/channel-peer.ts +72 -3
- package/src/adapters/claude-channel-evidence.ts +52 -0
- package/src/adapters/claude-channel.ts +61 -18
- package/src/adapters/claude-implementer.ts +29 -7
- package/src/adapters/claude-print-child.ts +12 -2
- package/src/adapters/codex-appserver.ts +13 -12
- package/src/adapters/compat.ts +3 -3
- package/src/adapters/definitions/builtin-metadata.ts +13 -13
- package/src/adapters/definitions/qwen.ts +1 -0
- package/src/adapters/local-worker.ts +61 -18
- package/src/adapters/pi.ts +48 -6
- package/src/adapters/registry-types.ts +12 -0
- package/src/adapters/registry.ts +2 -0
- package/src/adapters/reviewer-print.ts +50 -11
- package/src/broker/server.ts +44 -13
- package/src/broker/store.ts +38 -8
- package/src/cli/answer-args.ts +50 -0
- package/src/cli/attention.ts +17 -2
- package/src/cli/backlog-return-args.ts +37 -0
- package/src/cli/console-question.ts +110 -0
- package/src/cli/console-state.ts +78 -17
- package/src/cli/console.ts +54 -1
- package/src/cli/handoff-args.ts +15 -5
- package/src/cli/handoff-output.ts +15 -2
- package/src/cli/help.ts +8 -5
- package/src/cli/identity.ts +13 -3
- package/src/cli/main.ts +128 -16
- package/src/cli/memory.ts +107 -3
- package/src/cli/output.ts +29 -6
- package/src/cli/pet.ts +4 -4
- package/src/cli/results-args.ts +30 -0
- package/src/cli/room.ts +20 -10
- package/src/cli/runtime-version-warning.ts +50 -0
- package/src/cli/status-lines.ts +41 -1
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/upgrade-interactive.ts +14 -2
- package/src/hub/action-class.ts +9 -1
- package/src/hub/board.ts +369 -18
- package/src/hub/bus.ts +511 -53
- package/src/hub/completion-detail.ts +50 -0
- package/src/hub/completions-store.ts +168 -0
- package/src/hub/completions.ts +120 -0
- package/src/hub/config-apply.ts +7 -5
- package/src/hub/config-reload.ts +10 -1
- package/src/hub/control-client.ts +40 -4
- package/src/hub/daemon.ts +1576 -139
- package/src/hub/delivery-failure.ts +14 -0
- package/src/hub/delivery-hints.ts +14 -4
- package/src/hub/delivery-journal.ts +751 -42
- package/src/hub/delivery-superseded.ts +4 -1
- package/src/hub/envelope.ts +3 -3
- package/src/hub/events.ts +6 -0
- package/src/hub/handoff.ts +145 -6
- package/src/hub/hub-tools.ts +56 -7
- package/src/hub/manager.ts +59 -3
- package/src/hub/memory-policy.ts +44 -1
- package/src/hub/memory-sync-policy.ts +109 -0
- package/src/hub/memory-sync-state.ts +145 -0
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/operational-settings.ts +11 -2
- package/src/hub/operational-view.ts +12 -1
- package/src/hub/peers.ts +25 -2
- package/src/hub/planning.ts +6 -1
- package/src/hub/question-desk.ts +201 -0
- package/src/hub/question-detach.ts +6 -0
- package/src/hub/questions.ts +260 -0
- package/src/hub/research-evidence.ts +106 -0
- package/src/hub/restart.ts +14 -0
- package/src/hub/review-anchor.ts +181 -0
- package/src/hub/review-brief.ts +13 -0
- package/src/hub/review-comments.ts +115 -0
- package/src/hub/review-findings.ts +7 -2
- package/src/hub/review-material.ts +76 -0
- package/src/hub/routing.ts +3 -1
- package/src/hub/task-sweep.ts +2 -0
- package/src/hub/tasks.ts +577 -63
- package/src/memory/capture.ts +5 -2
- package/src/pet/config.ts +25 -13
- package/src/rooms/commands.ts +61 -16
- package/src/rooms/link.ts +338 -36
- package/src/rooms/liveness.ts +11 -0
- package/src/rooms/remote-peer.ts +186 -19
- package/src/rooms/service.ts +475 -35
- package/src/rooms/status.ts +8 -1
- package/src/rooms/store.ts +447 -65
- package/src/rooms/wire.ts +24 -6
- package/src/ui/index.html +142 -9
- package/src/ui/needs.ts +10 -4
- package/src/ui/plugin-questions.ts +56 -0
- package/src/ui/plugin-results.ts +81 -0
- package/src/ui/plugin.ts +207 -27
- package/src/pet/window.html +0 -114
- package/src/pet/window.jxa.js +0 -122
- package/src/pet/window.ts +0 -154
package/src/rooms/store.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { Database } from "bun:sqlite";
|
|
|
5
5
|
import { realPath } from "../hub/project.ts";
|
|
6
6
|
import { MAX_HOP, USER } from "../hub/envelope.ts";
|
|
7
7
|
import { assertRoomCryptoReady, exportHubIdentityForStorage, generateHubIdentity, importHubIdentity, decodeBase64url, encodeBase64url, sha256, type HubIdentity, type LocalIdentityRecord } from "./crypto.ts";
|
|
8
|
-
import { acceptJoin, acceptRoomEpoch, brokerUrl, createRoomEpoch, decodeFrame, decodeInvite, decodeJoin, decodeRoster, positiveU64, roomMembers, type ReplayCommit, type ReplayPort, type SequenceBinding, type SequencePort, type VerifiedEpoch, type RoomMember, type VerifiedPublicRoster, verifyPublicRoster, verifyPublicFrame } from "./wire.ts";
|
|
8
|
+
import { acceptJoin, acceptRoomEpoch, brokerUrl, createRoomEpoch, decodeFrame, decodeInvite, decodeJoin, decodeRoster, openOwnFrame, positiveU64, roomMembers, type ReplayCommit, type ReplayPort, type SequenceBinding, type SequencePort, type VerifiedEpoch, type RoomMember, type VerifiedPublicRoster, verifyPublicRoster, verifyPublicFrame } from "./wire.ts";
|
|
9
9
|
import { decodeRoomMessage, encodeRoomMessage, type RoomChatEnvelope, type RoomMessage, type RoomReceiptReason, parseRemotePeerId } from "./application.ts";
|
|
10
10
|
|
|
11
11
|
export const ROOM_STORE_LIMITS = Object.freeze({ rooms: 64, invites: 128, pendingJoins: 64, inboxPerRoom: 200, inboxBytes: 16 * 1024 * 1024, receiptsPerRoom: 1000, outboxPerRoom: 200, outboxBytes: 16 * 1024 * 1024, floorsPerRoom: 256, refusedPerRoom: 1000, receiptReservePerRoom: 8, receiptReserveBytes: 1024 });
|
|
@@ -28,7 +28,7 @@ export class RoomApplicationLimitError extends RoomStoreError {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
function fail(code: RoomStoreCode): never { throw new RoomStoreError(code); }
|
|
31
|
-
const DB_NAME = "rooms.db", SCHEMA =
|
|
31
|
+
const DB_NAME = "rooms.db", SCHEMA = 5, GRACE_MS = 60_000;
|
|
32
32
|
const ROOM_DB_BUSY_TIMEOUT_MS = 1;
|
|
33
33
|
// ponytail: keep at most 256 historical sender floors per room; refuse new senders at the ceiling until versioned archival preserves replay proofs.
|
|
34
34
|
const NAME = /^[a-z][a-z0-9-]{0,31}$/;
|
|
@@ -52,11 +52,16 @@ export const ROOM_PERSON_INBOX_LIMITS = Object.freeze({ unreadPerRoom: 100, body
|
|
|
52
52
|
export interface RoomOutgoingInput { deliveryId: string; application: string; remotePeer: string; recipient: string; peer: string; ackTimeoutMs: number; ttlMs: number }
|
|
53
53
|
export interface RoomOutgoing {
|
|
54
54
|
digest: string; deliveryId: string; application: string; remotePeer: string; recipient: string; peer: string;
|
|
55
|
-
|
|
55
|
+
frameDigest?: string;
|
|
56
|
+
createdAt: number; expiresAt: number; ackTimeoutMs: number; ackDeadline?: number;
|
|
56
57
|
state: "dispatching" | "accepted" | "completed" | "failed_safe" | "needs_review";
|
|
57
58
|
disposition?: "completed" | "retry" | "discard";
|
|
58
59
|
reason?: RoomReceiptReason; lateReceipt?: { outcome: "completed" | "failed_safe"; reason: RoomReceiptReason };
|
|
59
60
|
}
|
|
61
|
+
export interface RoomOutboxEntry { digest: string; wire: Uint8Array; state: "dispatching" | "accepted"; kind: "generic" | "application" | "receipt"; receiptFor?: string; expiresAt?: number }
|
|
62
|
+
export type RoomStoredReceipt =
|
|
63
|
+
| { digest: string; wire: Uint8Array; state: "dispatching"; kind: "receipt"; receiptFor: string; expiresAt: number }
|
|
64
|
+
| { digest: string; state: "accepted"; kind: "receipt"; receiptFor: string; expiresAt: number };
|
|
60
65
|
type ApplicationState = "pending" | "dispatching" | "completed" | "failed_safe" | "needs_review" | "disposed";
|
|
61
66
|
interface ApplicationRow { room: string; sender: string; application: string; content_hash: string; primary_digest: string; peer: string; trace: string; hop: number; state: ApplicationState; reason: RoomReceiptReason | null; created_at: number; settled_at: number | null; disposition: "accept" | "discard" | null; disposed_at: number | null; disposed_by: "user" | null }
|
|
62
67
|
export interface RoomIncomingApplication { sender: string; application: string; peer: string; state: ApplicationState; digest: string; disposition?: "accept" | "discard"; disposedAt?: number; disposedBy?: "user" }
|
|
@@ -64,9 +69,10 @@ interface OutgoingRow {
|
|
|
64
69
|
room: string; digest: string; delivery_id: string; application: string; remote_peer: string; recipient: string; peer: string;
|
|
65
70
|
created_at: number; expires_at: number; ack_timeout: number; ack_deadline: number | null; state: RoomOutgoing["state"];
|
|
66
71
|
reason: RoomReceiptReason | null; late_outcome: "completed" | "failed_safe" | null; late_reason: RoomReceiptReason | null; receipt_digest: string | null;
|
|
67
|
-
disposition: "completed" | "retry" | "discard" | null;
|
|
72
|
+
disposition: "completed" | "retry" | "discard" | null; content_hash: string | null;
|
|
68
73
|
}
|
|
69
74
|
const APPLICATION_REASONS: readonly RoomReceiptReason[] = ["admitted", "not_exposed", "boundary_refused", "invalid_message", "rate_limited", "hop", "person_inbox_unsupported", "inbox_admitted", "inbox_full", "inbox_oversize", "inbox_expired", "inbox_age_unavailable"];
|
|
75
|
+
const PERMANENT_OUTGOING_REFUSALS: readonly RoomReceiptReason[] = ["not_exposed", "boundary_refused", "invalid_message"];
|
|
70
76
|
const APPLICATION_HISTORY_CAP = 1000;
|
|
71
77
|
export const ROOM_APPLICATION_RETENTION = Object.freeze({ identitiesPerRoom: 10_000, aliasesPerRoom: 10_000, settledMs: 30 * 24 * 60 * 60 * 1000 });
|
|
72
78
|
const isTerminal = (state: string): boolean => state === "completed" || state === "failed_safe";
|
|
@@ -81,7 +87,7 @@ function boundedId(value: unknown): value is string { return typeof value === "s
|
|
|
81
87
|
function validUuid(value: unknown): value is string { return typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value); }
|
|
82
88
|
function outgoing(row: OutgoingRow): RoomOutgoing {
|
|
83
89
|
return { digest: row.digest, deliveryId: row.delivery_id, application: row.application, remotePeer: row.remote_peer, recipient: row.recipient, peer: row.peer,
|
|
84
|
-
createdAt: row.created_at, expiresAt: row.expires_at, ...(row.ack_deadline !== null ? { ackDeadline: row.ack_deadline } : {}), state: row.state,
|
|
90
|
+
createdAt: row.created_at, expiresAt: row.expires_at, ackTimeoutMs: row.ack_timeout, ...(row.ack_deadline !== null ? { ackDeadline: row.ack_deadline } : {}), state: row.state,
|
|
85
91
|
...(row.reason !== null ? { reason: row.reason } : {}), ...(row.disposition !== null ? { disposition: row.disposition } : {}), ...(row.late_outcome !== null ? { lateReceipt: { outcome: row.late_outcome, reason: row.late_reason! } } : {}) };
|
|
86
92
|
}
|
|
87
93
|
function incomingMetadata(row: ApplicationRow, digestValue = row.primary_digest): RoomIncomingApplication {
|
|
@@ -158,7 +164,7 @@ export class RoomStore {
|
|
|
158
164
|
db.run(`PRAGMA busy_timeout = ${ROOM_DB_BUSY_TIMEOUT_MS}`);
|
|
159
165
|
db.exec("PRAGMA journal_mode=DELETE; PRAGMA synchronous=EXTRA; PRAGMA locking_mode=EXCLUSIVE; BEGIN EXCLUSIVE;");
|
|
160
166
|
const version = db.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version;
|
|
161
|
-
if (version !== 0 && version !== 1 && version !== 2 && version !== 3 && version !== SCHEMA) fail("unsupported");
|
|
167
|
+
if (version !== 0 && version !== 1 && version !== 2 && version !== 3 && version !== 4 && version !== SCHEMA) fail("unsupported");
|
|
162
168
|
if (version === 0) {
|
|
163
169
|
if (!created) fail("corrupt");
|
|
164
170
|
db.exec(`
|
|
@@ -175,14 +181,14 @@ export class RoomStore {
|
|
|
175
181
|
PRAGMA user_version=1;
|
|
176
182
|
`);
|
|
177
183
|
}
|
|
178
|
-
const tables = db.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name LIMIT
|
|
184
|
+
const tables = db.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name LIMIT 16").all().map(x => x.name).join(",");
|
|
179
185
|
const legacyTables = "counters,floors,inbox,invites,meta,outbox,pending_joins,refusals,rooms";
|
|
180
|
-
if (tables !== (version === 4 ? "application_frames,application_outbox,applications,counters,floors,inbox,invites,meta,outbox,pending_joins,person_inbox,refusals,rooms" : version! >= 2 ? "application_frames,application_outbox,applications," + legacyTables : legacyTables)) fail("unsupported");
|
|
186
|
+
if (tables !== (version === 5 ? "application_attempts,application_frames,application_outbox,applications,counters,floors,inbox,invites,meta,outbox,pending_joins,person_inbox,receipt_outbox,refusals,rooms" : version === 4 ? "application_frames,application_outbox,applications,counters,floors,inbox,invites,meta,outbox,pending_joins,person_inbox,refusals,rooms" : version! >= 2 ? "application_frames,application_outbox,applications," + legacyTables : legacyTables)) fail("unsupported");
|
|
181
187
|
const columns: Record<string, string[]> = {
|
|
182
188
|
meta: ["key", "value"],
|
|
183
189
|
rooms: ["name", "room", "broker", "owner", "epoch", "digest", "wire", "previous_wire", "previous_digest", "previous_epoch", "previous_expires", "left"],
|
|
184
190
|
counters: ["room", "seq"], floors: ["room", "sender", "seq", "digest"],
|
|
185
|
-
inbox: ["room", "sender", "seq", "digest", "plaintext", "gap", ...(version! >= 3 ? ["receipt_reserve"] : []), ...(version
|
|
191
|
+
inbox: ["room", "sender", "seq", "digest", "plaintext", "gap", ...(version! >= 3 ? ["receipt_reserve"] : []), ...(version! >= 4 ? ["received_at"] : [])],
|
|
186
192
|
invites: ["name", "id", "wire", "digest", "expires", "request_digest"],
|
|
187
193
|
outbox: ["room", "digest", "wire", "roster", "state"], pending_joins: ["name", "invite", "request", "selection"], refusals: ["room", "digest", "reason", "at"],
|
|
188
194
|
};
|
|
@@ -202,7 +208,7 @@ export class RoomStore {
|
|
|
202
208
|
PRAGMA user_version=2;
|
|
203
209
|
`);
|
|
204
210
|
const applicationColumns: Record<string, string[]> = {
|
|
205
|
-
application_outbox: ["room", "digest", "delivery_id", "application", "remote_peer", "recipient", "peer", "created_at", "expires_at", "ack_timeout", "ack_deadline", "state", "reason", "late_outcome", "late_reason", "receipt_digest", "disposition"],
|
|
211
|
+
application_outbox: ["room", "digest", "delivery_id", "application", "remote_peer", "recipient", "peer", "created_at", "expires_at", "ack_timeout", "ack_deadline", "state", "reason", "late_outcome", "late_reason", "receipt_digest", "disposition", ...(version === 5 ? ["content_hash"] : [])],
|
|
206
212
|
applications: ["room", "sender", "application", "content_hash", "primary_digest", "peer", "trace", "hop", "state", "reason", "created_at", "settled_at", "disposition", "disposed_at", "disposed_by"],
|
|
207
213
|
application_frames: ["room", "digest", "sender", "application"],
|
|
208
214
|
};
|
|
@@ -227,6 +233,26 @@ export class RoomStore {
|
|
|
227
233
|
CREATE INDEX person_inbox_retention ON person_inbox(state,settled_at);
|
|
228
234
|
PRAGMA user_version=4;
|
|
229
235
|
`);
|
|
236
|
+
if (version! < 5) db.exec(`
|
|
237
|
+
UPDATE application_outbox SET ack_deadline=MIN(ack_deadline,expires_at);
|
|
238
|
+
ALTER TABLE application_outbox ADD COLUMN content_hash TEXT;
|
|
239
|
+
CREATE TABLE application_attempts (room TEXT NOT NULL, digest TEXT NOT NULL, delivery_id TEXT NOT NULL, application TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(room,digest)) WITHOUT ROWID;
|
|
240
|
+
CREATE INDEX application_attempts_delivery ON application_attempts(room,delivery_id);
|
|
241
|
+
CREATE TABLE receipt_outbox (room TEXT NOT NULL, inbound_digest TEXT NOT NULL, receipt_digest TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, state TEXT NOT NULL, recipient TEXT NOT NULL, epoch TEXT NOT NULL, roster_digest TEXT NOT NULL, PRIMARY KEY(room,inbound_digest,receipt_digest)) WITHOUT ROWID;
|
|
242
|
+
CREATE UNIQUE INDEX receipt_outbox_frame ON receipt_outbox(room,receipt_digest);
|
|
243
|
+
INSERT INTO application_attempts(room,digest,delivery_id,application,created_at) SELECT room,digest,delivery_id,application,created_at FROM application_outbox;
|
|
244
|
+
PRAGMA user_version=5;
|
|
245
|
+
`);
|
|
246
|
+
const attemptColumns = ["room", "digest", "delivery_id", "application", "created_at"];
|
|
247
|
+
const outgoingColumns = ["room", "digest", "delivery_id", "application", "remote_peer", "recipient", "peer", "created_at", "expires_at", "ack_timeout", "ack_deadline", "state", "reason", "late_outcome", "late_reason", "receipt_digest", "disposition", "content_hash"];
|
|
248
|
+
if (db.query<{ name: string }, []>("PRAGMA table_info(application_outbox)").all().map(row => row.name).join(",") !== outgoingColumns.join(",")) fail("unsupported");
|
|
249
|
+
for (const table of ["application_attempts", "receipt_outbox"]) {
|
|
250
|
+
if (db.query<{ name: string }, []>(`PRAGMA table_info(${table})`).all().map(row => row.name).join(",") !== (table === "application_attempts" ? attemptColumns.join(",") : "room,inbound_digest,receipt_digest,created_at,expires_at,state,recipient,epoch,roster_digest")) fail("unsupported");
|
|
251
|
+
}
|
|
252
|
+
for (const [index, fields] of Object.entries({ application_attempts_delivery: ["room", "delivery_id"], receipt_outbox_frame: ["room", "receipt_digest"] })) {
|
|
253
|
+
if (db.query<{ name: string }, []>(`PRAGMA index_info(${index})`).all().map(row => row.name).join(",") !== fields.join(",")) fail("unsupported");
|
|
254
|
+
}
|
|
255
|
+
if (db.query<{ name: string; unique: number }, []>("PRAGMA index_list(receipt_outbox)").all().find(row => row.name === "receipt_outbox_frame")?.unique !== 1) fail("unsupported");
|
|
230
256
|
const personColumns = ["id", "room", "sender", "application", "primary_digest", "from_peer", "received_at", "expires_at", "bytes", "body", "state", "settled_at"];
|
|
231
257
|
if (db.query<{ name: string }, []>("PRAGMA table_info(person_inbox)").all().map(row => row.name).join(",") !== personColumns.join(",")) fail("unsupported");
|
|
232
258
|
for (const [index, fields] of Object.entries({ person_inbox_room: ["room", "state", "received_at", "id"], person_inbox_retention: ["state", "settled_at"], person_inbox_identity: ["room", "sender", "application"] })) {
|
|
@@ -288,6 +314,8 @@ export class RoomStore {
|
|
|
288
314
|
cap("counters", limits.rooms); cap("floors", limits.rooms * limits.floorsPerRoom);
|
|
289
315
|
cap("inbox", limits.rooms * (limits.receiptsPerRoom + limits.inboxPerRoom + limits.receiptReservePerRoom));
|
|
290
316
|
cap("outbox", limits.rooms * limits.outboxPerRoom); cap("invites", limits.invites); cap("pending_joins", limits.pendingJoins); cap("refusals", limits.rooms * limits.refusedPerRoom);
|
|
317
|
+
cap("application_attempts", limits.rooms * ROOM_APPLICATION_RETENTION.aliasesPerRoom);
|
|
318
|
+
cap("receipt_outbox", limits.rooms * ROOM_APPLICATION_RETENTION.aliasesPerRoom);
|
|
291
319
|
const groupCap = (table: string, filter: string, maximum: number) => {
|
|
292
320
|
if (this.db.query<{ room: string }, [number]>(`SELECT room FROM ${table} ${filter} GROUP BY room HAVING COUNT(*)>? LIMIT 1`).get(maximum)) fail("corrupt");
|
|
293
321
|
};
|
|
@@ -297,12 +325,16 @@ export class RoomStore {
|
|
|
297
325
|
groupCap("inbox", "WHERE plaintext IS NULL", limits.receiptsPerRoom);
|
|
298
326
|
groupCap("outbox", "", limits.outboxPerRoom);
|
|
299
327
|
groupCap("refusals", "", limits.refusedPerRoom);
|
|
328
|
+
groupCap("application_attempts", "", ROOM_APPLICATION_RETENTION.aliasesPerRoom);
|
|
329
|
+
groupCap("receipt_outbox", "", ROOM_APPLICATION_RETENTION.aliasesPerRoom);
|
|
300
330
|
const metadataBounds: Record<string, string> = {
|
|
301
331
|
counters: "length(room)>43 OR length(seq)>20",
|
|
302
332
|
floors: "length(room)>43 OR length(sender)>64 OR length(seq)>20 OR length(digest)>43",
|
|
303
333
|
inbox: "length(room)>43 OR length(sender)>64 OR length(seq)>20 OR length(digest)>43 OR length(gap)>20",
|
|
304
334
|
invites: "length(name)>32 OR length(id)>43 OR length(digest)>43 OR length(request_digest)>43",
|
|
305
335
|
outbox: "length(room)>43 OR length(digest)>43 OR length(state)>16",
|
|
336
|
+
application_attempts: "length(room)>43 OR length(digest)>43 OR length(delivery_id)>256 OR length(application)>256",
|
|
337
|
+
receipt_outbox: "length(room)>43 OR length(inbound_digest)>43 OR length(receipt_digest)>43 OR length(state)>16 OR length(recipient)>64 OR length(epoch)>20 OR length(roster_digest)>43",
|
|
306
338
|
pending_joins: "length(name)>32",
|
|
307
339
|
refusals: "length(room)>43 OR length(digest)>43 OR length(reason)>32",
|
|
308
340
|
};
|
|
@@ -389,6 +421,7 @@ export class RoomStore {
|
|
|
389
421
|
if (!validSelection(row.selection)) fail("corrupt");
|
|
390
422
|
}
|
|
391
423
|
await this.validateApplications();
|
|
424
|
+
await this.validateReceiptOutbox();
|
|
392
425
|
await this.validatePersonInbox();
|
|
393
426
|
}
|
|
394
427
|
|
|
@@ -404,7 +437,7 @@ export class RoomStore {
|
|
|
404
437
|
}
|
|
405
438
|
if (this.db.query("SELECT room FROM application_outbox WHERE state IN ('completed','failed_safe') OR disposition IS NOT NULL GROUP BY room HAVING COUNT(*)>1000 LIMIT 1").get()
|
|
406
439
|
|| this.db.query("SELECT room FROM applications WHERE state IN ('pending','dispatching') GROUP BY room HAVING COUNT(*)>200 LIMIT 1").get()
|
|
407
|
-
|| this.db.query("SELECT 1 FROM application_outbox WHERE length(room)>43 OR length(digest)>43 OR length(delivery_id)>256 OR length(application)>256 OR length(remote_peer)>65 OR length(recipient)>64 OR length(peer)>32 OR length(state)>16 OR length(reason)>32 OR length(late_outcome)>16 OR length(late_reason)>32 OR length(receipt_digest)>43 OR length(disposition)>16 LIMIT 1").get()
|
|
440
|
+
|| this.db.query("SELECT 1 FROM application_outbox WHERE length(room)>43 OR length(digest)>43 OR length(delivery_id)>256 OR length(application)>256 OR length(remote_peer)>65 OR length(recipient)>64 OR length(peer)>32 OR length(state)>16 OR length(reason)>32 OR length(late_outcome)>16 OR length(late_reason)>32 OR length(receipt_digest)>43 OR length(disposition)>16 OR length(content_hash)>43 LIMIT 1").get()
|
|
408
441
|
|| this.db.query("SELECT 1 FROM applications WHERE length(room)>43 OR length(sender)>64 OR length(application)>256 OR length(content_hash)>43 OR length(primary_digest)>43 OR length(peer)>32 OR length(trace)>36 OR length(state)>16 OR length(reason)>32 OR length(disposition)>8 OR length(disposed_by)>4 LIMIT 1").get()
|
|
409
442
|
|| this.db.query("SELECT 1 FROM application_frames WHERE length(room)>43 OR length(digest)>43 OR length(sender)>64 OR length(application)>256 LIMIT 1").get()) fail("corrupt");
|
|
410
443
|
const applications = new Map<string, ApplicationRow>();
|
|
@@ -444,18 +477,69 @@ export class RoomStore {
|
|
|
444
477
|
|| !["dispatching", "accepted", "completed", "failed_safe", "needs_review"].includes(row.state)
|
|
445
478
|
|| !Number.isSafeInteger(row.created_at) || row.created_at <= 0 || row.created_at > now || !Number.isSafeInteger(row.expires_at)
|
|
446
479
|
|| row.expires_at <= row.created_at || row.expires_at - row.created_at > 604_800_000
|
|
447
|
-
|| !Number.isSafeInteger(row.ack_timeout) || row.ack_timeout < 1 || row.ack_timeout > 86_400_000
|
|
448
|
-
|
|
449
|
-
if (
|
|
450
|
-
if (row.
|
|
451
|
-
|
|
480
|
+
|| !Number.isSafeInteger(row.ack_timeout) || row.ack_timeout < 1 || row.ack_timeout > 86_400_000
|
|
481
|
+
|| row.content_hash !== null && !validDigest(row.content_hash)) fail("corrupt");
|
|
482
|
+
if (!Number.isSafeInteger(row.ack_deadline) || row.ack_deadline! < row.created_at || row.ack_deadline! > row.expires_at) fail("corrupt");
|
|
483
|
+
if (isTerminal(row.state) ? !validOutcome(row.state, row.reason, row.peer)
|
|
484
|
+
: row.state === "needs_review" && row.reason !== null ? !PERMANENT_OUTGOING_REFUSALS.includes(row.reason)
|
|
485
|
+
: row.reason !== null) fail("corrupt");
|
|
486
|
+
if (row.disposition !== null && (row.state !== "needs_review" && !isTerminal(row.state) || !["completed", "retry", "discard"].includes(row.disposition))) fail("corrupt");
|
|
487
|
+
if (row.late_outcome !== null
|
|
488
|
+
? !validOutcome(row.late_outcome, row.late_reason, row.peer)
|
|
489
|
+
|| row.state !== "needs_review" && (row.state !== row.late_outcome || row.reason !== row.late_reason)
|
|
490
|
+
: row.late_reason !== null) fail("corrupt");
|
|
452
491
|
if ((isTerminal(row.state) || row.late_outcome !== null) ? !validDigest(row.receipt_digest) : row.receipt_digest !== null) fail("corrupt");
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
492
|
+
if (row.late_outcome !== null && this.lateReceiptProof(row) === "invalid") fail("corrupt");
|
|
493
|
+
const attempts = this.db.query<{ digest: string }, [string, string]>("SELECT digest FROM application_attempts WHERE room=? AND delivery_id=?").all(row.room, row.delivery_id);
|
|
494
|
+
if (!attempts.some(attempt => attempt.digest === row.digest)) fail("corrupt");
|
|
495
|
+
const sealedAttempts = this.db.query<{ wire: Uint8Array; room: string }, [string, string]>("SELECT o.wire,o.room FROM outbox o JOIN application_attempts a ON a.room=o.room AND a.digest=o.digest WHERE a.room=? AND a.delivery_id=?").all(row.room, row.delivery_id);
|
|
496
|
+
if (isTerminal(row.state) || row.disposition !== null) { if (sealedAttempts.length) fail("corrupt"); }
|
|
497
|
+
else if ((row.state === "dispatching" || row.state === "accepted" || row.state === "needs_review" && row.reason === null && row.disposition === null && row.late_outcome === null && row.expires_at > now) && !sealedAttempts.length) fail("corrupt");
|
|
498
|
+
if (sealedAttempts.some(sealed => {
|
|
457
499
|
const frame = decodeFrame(sealed.wire);
|
|
458
|
-
|
|
500
|
+
return sealed.room !== row.room || frame.recipients.length !== 1 || frame.recipients[0] !== row.recipient;
|
|
501
|
+
})) fail("corrupt");
|
|
502
|
+
}
|
|
503
|
+
const outgoingRows = new Map<string, OutgoingRow>();
|
|
504
|
+
for (const row of this.db.query<OutgoingRow, []>("SELECT * FROM application_outbox").all()) outgoingRows.set(`${row.room}|${row.delivery_id}`, row);
|
|
505
|
+
for (const row of this.db.query<{ room: string; digest: string; delivery_id: string; application: string; created_at: number }, []>("SELECT * FROM application_attempts").all()) {
|
|
506
|
+
const saved = outgoingRows.get(`${row.room}|${row.delivery_id}`);
|
|
507
|
+
if (!saved || saved.application !== row.application || !validDigest(row.digest) || !Number.isSafeInteger(row.created_at) || row.created_at < saved.created_at || row.created_at > now) fail("corrupt");
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private lateReceiptProof(row: OutgoingRow): "missing" | "valid" | "invalid" {
|
|
512
|
+
if (!row.receipt_digest || !row.late_outcome) return "missing";
|
|
513
|
+
const entry = this.db.query<{ sender: string; plaintext: Uint8Array | null }, [string, string]>("SELECT sender,plaintext FROM inbox WHERE room=? AND digest=?").get(row.room, row.receipt_digest);
|
|
514
|
+
if (!entry) return "missing";
|
|
515
|
+
if (entry.sender !== row.recipient) return "invalid";
|
|
516
|
+
if (entry.plaintext === null) return "valid";
|
|
517
|
+
let message: RoomMessage;
|
|
518
|
+
try { message = decodeRoomMessage(entry.plaintext); } catch { return "invalid"; }
|
|
519
|
+
if (message.type !== "receipt" || message.application !== row.application || message.peer !== row.peer
|
|
520
|
+
|| message.outcome !== row.late_outcome || message.reason !== row.late_reason) return "invalid";
|
|
521
|
+
const attempt = this.db.query<{ delivery_id: string }, [string, string]>("SELECT delivery_id FROM application_attempts WHERE room=? AND digest=?").get(row.room, message.frame);
|
|
522
|
+
return attempt?.delivery_id === row.delivery_id ? "valid" : "invalid";
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private async validateReceiptOutbox(): Promise<void> {
|
|
526
|
+
const maximum = ROOM_STORE_LIMITS.rooms * ROOM_APPLICATION_RETENTION.aliasesPerRoom;
|
|
527
|
+
if (this.db.query("SELECT room,inbound_digest FROM receipt_outbox GROUP BY room,inbound_digest HAVING SUM(state='dispatching')>1 LIMIT 1").get()) fail("corrupt");
|
|
528
|
+
for (const row of this.db.query<{ room: string; inbound_digest: string; receipt_digest: string; created_at: number; expires_at: number; state: string; recipient: string; epoch: string; roster_digest: string }, []>(`SELECT * FROM receipt_outbox LIMIT ${maximum + 1}`).all()) {
|
|
529
|
+
if (!this.byRoom(row.room) || !validDigest(row.inbound_digest) || !validDigest(row.receipt_digest) || !Number.isSafeInteger(row.created_at) || row.created_at <= 0 || row.created_at > this.now()
|
|
530
|
+
|| !Number.isSafeInteger(row.expires_at) || row.expires_at <= row.created_at || row.expires_at - row.created_at > 604_800_000 || !["dispatching", "accepted"].includes(row.state)
|
|
531
|
+
|| !/^[a-f0-9]{64}$/.test(row.recipient) || positiveU64(row.epoch) !== row.epoch || !validDigest(row.roster_digest)) fail("corrupt");
|
|
532
|
+
const incoming = this.applicationFor(row.room, row.inbound_digest);
|
|
533
|
+
const outgoing = this.db.query<{ wire: Uint8Array; roster: Uint8Array; room: string; state: string }, [string, string]>("SELECT wire,roster,room,state FROM outbox WHERE room=? AND digest=?").get(row.room, row.receipt_digest);
|
|
534
|
+
if (!incoming || !isTerminal(incoming.state) || incoming.sender !== row.recipient) fail("corrupt");
|
|
535
|
+
if (row.state === "dispatching" && (!outgoing || outgoing.room !== row.room || outgoing.state !== "dispatching")) fail("corrupt");
|
|
536
|
+
if (row.state === "accepted" && outgoing && (outgoing.room !== row.room || outgoing.state !== "accepted")) fail("corrupt");
|
|
537
|
+
if (outgoing) {
|
|
538
|
+
const installed = this.byRoom(row.room)!, owner = decodeRoster(installed.wire).owner;
|
|
539
|
+
const roster = await verifyPublicRoster({ wire: outgoing.roster, pin: { room: row.room, owner, minEpoch: "0" } });
|
|
540
|
+
const frame = await verifyPublicFrame({ wire: outgoing.wire, roster });
|
|
541
|
+
if (frame.digest !== row.receipt_digest || frame.room !== row.room || frame.epoch !== row.epoch || frame.rosterDigest !== row.roster_digest
|
|
542
|
+
|| frame.sender !== this.identity.public.fingerprint || frame.recipients.length !== 1 || frame.recipients[0] !== incoming.sender) fail("corrupt");
|
|
459
543
|
}
|
|
460
544
|
}
|
|
461
545
|
}
|
|
@@ -589,6 +673,12 @@ export class RoomStore {
|
|
|
589
673
|
ON CONFLICT(name) DO UPDATE SET epoch=excluded.epoch,digest=excluded.digest,wire=excluded.wire,previous_wire=excluded.previous_wire,previous_digest=excluded.previous_digest,previous_epoch=excluded.previous_epoch,previous_expires=excluded.previous_expires,left=0`)
|
|
590
674
|
.run(name, current.room, broker, decodeRoster(wire).owner, current.epoch, current.digest, wire, prior ? priorWire! : null, prior?.epoch.digest ?? null, prior?.epoch.epoch ?? null, prior?.expiresAt ?? null);
|
|
591
675
|
this.db.query("DELETE FROM pending_joins WHERE name=?").run(name);
|
|
676
|
+
if (previous && !same) {
|
|
677
|
+
// Epoch adoption and the old sealed intents' send fence commit together.
|
|
678
|
+
// The local Bus names the rotation; no new signed receipt reason is introduced.
|
|
679
|
+
this.db.query("UPDATE application_outbox SET state='needs_review',reason='boundary_refused' WHERE room=? AND state IN ('dispatching','accepted') AND disposition IS NULL").run(current.room);
|
|
680
|
+
this.db.query("DELETE FROM outbox WHERE room=? AND digest IN (SELECT t.digest FROM application_attempts t JOIN application_outbox a ON a.room=t.room AND a.delivery_id=t.delivery_id WHERE t.room=? AND a.state='needs_review' AND a.reason='boundary_refused')").run(current.room, current.room);
|
|
681
|
+
}
|
|
592
682
|
return next;
|
|
593
683
|
}
|
|
594
684
|
async install(input: { name: string; broker: string; wire: Uint8Array; expectedDigest: string | null; current?: () => boolean; reactivate?: boolean }): Promise<RoomState> {
|
|
@@ -735,6 +825,32 @@ export class RoomStore {
|
|
|
735
825
|
const publicRoster = await verifyPublicRoster({ wire: state.wire, pin: { room: state.current.room, owner: roster.owner, minEpoch: state.current.epoch, digest: state.current.digest } });
|
|
736
826
|
const frame = await verifyPublicFrame({ wire, roster: publicRoster });
|
|
737
827
|
if (frame.sender !== this.identity.public.fingerprint) fail("untrusted");
|
|
828
|
+
let contentHash: string | undefined;
|
|
829
|
+
let legacyContentHash: string | undefined;
|
|
830
|
+
let legacyDeliveryId: string | undefined;
|
|
831
|
+
let legacyAttemptDigest: string | undefined;
|
|
832
|
+
if (application) {
|
|
833
|
+
const plaintext = await openOwnFrame({ wire, identity: this.identity, epoch: state.current });
|
|
834
|
+
let message: RoomMessage; try { message = decodeRoomMessage(plaintext); } catch { fail("invalid"); }
|
|
835
|
+
if (message.type !== "chat" || message.envelope.id !== application.application || message.envelope.to !== application.peer) fail("state_changed");
|
|
836
|
+
contentHash = await digest(encodeRoomMessage(message));
|
|
837
|
+
const saved = this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND application=? ORDER BY rowid").all(frame.room, application.application);
|
|
838
|
+
const legacy = saved.filter(row => row.content_hash === null);
|
|
839
|
+
if (legacy.length) {
|
|
840
|
+
if (saved.length !== 1 || legacy[0]!.delivery_id !== application.deliveryId
|
|
841
|
+
|| legacy[0]!.state !== "dispatching" && legacy[0]!.state !== "accepted" || legacy[0]!.disposition !== null
|
|
842
|
+
|| legacy[0]!.reason !== null || legacy[0]!.expires_at <= this.now()) fail("state_changed");
|
|
843
|
+
legacyDeliveryId = legacy[0]!.delivery_id;
|
|
844
|
+
legacyAttemptDigest = this.db.query<{ digest: string }, [string, string]>("SELECT t.digest FROM application_attempts t JOIN outbox o ON o.room=t.room AND o.digest=t.digest WHERE t.room=? AND t.delivery_id=? ORDER BY t.created_at DESC,o.rowid DESC LIMIT 1").get(frame.room, legacyDeliveryId)?.digest;
|
|
845
|
+
if (!legacyAttemptDigest) fail("state_changed");
|
|
846
|
+
const previous = await this.outgoingMessage(name, application.application, current);
|
|
847
|
+
if (!previous || previous.type !== "chat") fail("state_changed");
|
|
848
|
+
legacyContentHash = await digest(encodeRoomMessage(previous));
|
|
849
|
+
if (legacyContentHash !== contentHash) fail("state_changed");
|
|
850
|
+
const afterReadDigest = this.db.query<{ digest: string }, [string, string]>("SELECT t.digest FROM application_attempts t JOIN outbox o ON o.room=t.room AND o.digest=t.digest WHERE t.room=? AND t.delivery_id=? ORDER BY t.created_at DESC,o.rowid DESC LIMIT 1").get(frame.room, legacyDeliveryId)?.digest;
|
|
851
|
+
if (afterReadDigest !== legacyAttemptDigest) fail("state_changed");
|
|
852
|
+
}
|
|
853
|
+
}
|
|
738
854
|
return this.transaction(now => {
|
|
739
855
|
assertCurrent(current);
|
|
740
856
|
if (!this.isCurrent({ room: frame.room, sender: frame.sender, epoch: frame.epoch, rosterDigest: frame.rosterDigest })) fail("state_changed");
|
|
@@ -743,69 +859,313 @@ export class RoomStore {
|
|
|
743
859
|
if (!recipient || application.recipient === this.identity.public.fingerprint || frame.recipients.length !== 1
|
|
744
860
|
|| frame.recipients[0] !== application.recipient || application.remotePeer !== `${application.peer}@${recipient.label}`) fail("untrusted");
|
|
745
861
|
}
|
|
862
|
+
const applicationRows = application ? this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND application=? ORDER BY created_at,rowid").all(frame.room, application.application) : [];
|
|
863
|
+
if (application) {
|
|
864
|
+
if (contentHash === undefined) fail("corrupt");
|
|
865
|
+
for (const prior of applicationRows) {
|
|
866
|
+
if (prior.remote_peer !== application.remotePeer || prior.recipient !== application.recipient || prior.peer !== application.peer
|
|
867
|
+
|| prior.ack_timeout !== application.ackTimeoutMs || prior.expires_at - prior.created_at !== application.ttlMs
|
|
868
|
+
|| prior.expires_at <= now || prior.disposition !== null || prior.state === "needs_review"
|
|
869
|
+
|| prior.reason !== null && PERMANENT_OUTGOING_REFUSALS.includes(prior.reason)) fail("state_changed");
|
|
870
|
+
if (prior.content_hash !== null && prior.content_hash !== contentHash) fail("state_changed");
|
|
871
|
+
if (prior.content_hash === null && (legacyContentHash !== contentHash || prior.delivery_id !== legacyDeliveryId)) fail("state_changed");
|
|
872
|
+
}
|
|
873
|
+
if (legacyContentHash === contentHash && legacyDeliveryId) {
|
|
874
|
+
const attempt = this.db.query<{ digest: string }, [string, string]>("SELECT t.digest FROM application_attempts t JOIN outbox o ON o.room=t.room AND o.digest=t.digest WHERE t.room=? AND t.delivery_id=? ORDER BY t.created_at DESC,o.rowid DESC LIMIT 1").get(frame.room, legacyDeliveryId);
|
|
875
|
+
if (attempt?.digest !== legacyAttemptDigest) fail("state_changed");
|
|
876
|
+
const update = this.db.query("UPDATE application_outbox SET content_hash=? WHERE room=? AND delivery_id=? AND content_hash IS NULL").run(contentHash, frame.room, legacyDeliveryId);
|
|
877
|
+
if (update.changes !== 1) {
|
|
878
|
+
const currentHash = this.db.query<{ content_hash: string | null }, [string, string]>("SELECT content_hash FROM application_outbox WHERE room=? AND delivery_id=?").get(frame.room, legacyDeliveryId)?.content_hash;
|
|
879
|
+
if (currentHash !== contentHash) fail("state_changed");
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
746
883
|
const existing = this.db.query<{ wire: Uint8Array }, [string]>("SELECT wire FROM outbox WHERE digest=?").get(frame.digest);
|
|
747
884
|
const saved = this.db.query<OutgoingRow, [string]>("SELECT * FROM application_outbox WHERE digest=?").get(frame.digest);
|
|
748
885
|
if (existing || saved) {
|
|
886
|
+
if (!existing) fail("state_changed");
|
|
749
887
|
if (existing && Buffer.compare(Buffer.from(wire), Buffer.from(existing.wire))) fail("state_changed");
|
|
750
888
|
if (!application) { if (saved) fail("state_changed"); return undefined; }
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
889
|
+
const alias = this.db.query<{ delivery_id: string }, [string, string]>("SELECT delivery_id FROM application_attempts WHERE room=? AND digest=?").get(frame.room, frame.digest);
|
|
890
|
+
const row = saved ?? (alias ? this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND delivery_id=?").get(frame.room, alias.delivery_id) : undefined);
|
|
891
|
+
if (!row || row.delivery_id !== application.deliveryId || row.application !== application.application
|
|
892
|
+
|| row.remote_peer !== application.remotePeer || row.recipient !== application.recipient || row.peer !== application.peer
|
|
893
|
+
|| row.ack_timeout !== application.ackTimeoutMs || row.expires_at - row.created_at !== application.ttlMs || row.content_hash !== contentHash) fail("state_changed");
|
|
894
|
+
return { ...outgoing(row), frameDigest: frame.digest };
|
|
755
895
|
}
|
|
756
|
-
|
|
896
|
+
const row = application ? this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND delivery_id=?").get(frame.room, application.deliveryId) : undefined;
|
|
897
|
+
if (application && this.db.query("SELECT 1 FROM application_attempts WHERE room=? AND digest=?").get(frame.room, frame.digest)) fail("state_changed");
|
|
898
|
+
if (row && (row.application !== application!.application || row.remote_peer !== application!.remotePeer || row.recipient !== application!.recipient || row.peer !== application!.peer
|
|
899
|
+
|| row.ack_timeout !== application!.ackTimeoutMs || row.expires_at - row.created_at !== application!.ttlMs || row.disposition !== null
|
|
900
|
+
|| row.state !== "dispatching" && row.state !== "accepted" || now >= row.expires_at || row.content_hash !== contentHash)) fail("state_changed");
|
|
901
|
+
const aliasCount = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM application_attempts WHERE room=?").get(frame.room)?.n ?? 0;
|
|
902
|
+
if (application && aliasCount >= ROOM_APPLICATION_RETENTION.aliasesPerRoom) fail("limit");
|
|
903
|
+
if (row) this.db.query("DELETE FROM outbox WHERE room=? AND digest IN (SELECT digest FROM application_attempts WHERE room=? AND delivery_id=?)").run(frame.room, frame.room, row.delivery_id);
|
|
757
904
|
const count = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM outbox WHERE room=?").get(frame.room)?.n ?? 0;
|
|
758
905
|
const bytes = this.db.query<{ n: number }, []>("SELECT COALESCE(SUM(length(wire)+length(roster)),0) AS n FROM outbox").get()?.n ?? 0;
|
|
759
906
|
if (count >= ROOM_STORE_LIMITS.outboxPerRoom || bytes + wire.length + state.wire.length > ROOM_STORE_LIMITS.outboxBytes) fail("limit");
|
|
760
907
|
this.db.query("INSERT INTO outbox(room,digest,wire,roster,state) VALUES(?,?,?,?,'dispatching')").run(frame.room, frame.digest, wire, state.wire);
|
|
761
908
|
if (!application) return undefined;
|
|
762
|
-
|
|
909
|
+
if (row) {
|
|
910
|
+
const deadline = Math.min(now + application.ackTimeoutMs, row.expires_at);
|
|
911
|
+
if (!Number.isSafeInteger(deadline)) fail("invalid");
|
|
912
|
+
this.db.query("INSERT INTO application_attempts(room,digest,delivery_id,application,created_at) VALUES(?,?,?,?,?)").run(frame.room, frame.digest, row.delivery_id, row.application, now);
|
|
913
|
+
this.db.query("UPDATE application_outbox SET ack_deadline=?,state='dispatching' WHERE room=? AND delivery_id=?").run(deadline, frame.room, row.delivery_id);
|
|
914
|
+
const updated = this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND delivery_id=?").get(frame.room, row.delivery_id)!;
|
|
915
|
+
return { ...outgoing(updated), frameDigest: frame.digest };
|
|
916
|
+
}
|
|
917
|
+
const canonical = applicationRows[0];
|
|
918
|
+
const expires = canonical?.expires_at ?? now + application.ttlMs, deadline = Math.min(now + application.ackTimeoutMs, expires);
|
|
763
919
|
if (!Number.isSafeInteger(expires) || !Number.isSafeInteger(deadline)) fail("invalid");
|
|
764
920
|
this.db.query("INSERT INTO application_outbox(room,digest,delivery_id,application,remote_peer,recipient,peer,created_at,expires_at,ack_timeout,ack_deadline,state) VALUES(?,?,?,?,?,?,?,?,?,?,?,'dispatching')")
|
|
765
921
|
.run(frame.room, frame.digest, application.deliveryId, application.application, application.remotePeer, application.recipient, application.peer, now, expires, application.ackTimeoutMs, deadline);
|
|
766
|
-
|
|
922
|
+
this.db.query("UPDATE application_outbox SET created_at=?,content_hash=? WHERE room=? AND delivery_id=?").run(canonical?.created_at ?? now, contentHash!, frame.room, application.deliveryId);
|
|
923
|
+
this.db.query("INSERT INTO application_attempts(room,digest,delivery_id,application,created_at) VALUES(?,?,?,?,?)").run(frame.room, frame.digest, application.deliveryId, application.application, now);
|
|
924
|
+
return { ...outgoing(this.db.query<OutgoingRow, [string]>("SELECT * FROM application_outbox WHERE digest=?").get(frame.digest)!), frameDigest: frame.digest };
|
|
767
925
|
});
|
|
768
926
|
}
|
|
769
|
-
outbox(name: string):
|
|
927
|
+
outbox(name: string): RoomOutboxEntry[] {
|
|
770
928
|
const room = this.get(name); if (!room || room.left) return [];
|
|
771
|
-
|
|
929
|
+
const now = this.now();
|
|
930
|
+
return this.db.query<{ digest: string; wire: Uint8Array; state: "dispatching" | "accepted"; kind: RoomOutboxEntry["kind"]; receiptFor: string | null; expiresAt: number | null }, [string, number]>("SELECT o.digest,o.wire,o.state,CASE WHEN r.receipt_digest IS NOT NULL THEN 'receipt' WHEN a.digest IS NOT NULL THEN 'application' ELSE 'generic' END AS kind,r.inbound_digest AS receiptFor,COALESCE(r.expires_at,a.expires_at) AS expiresAt FROM outbox o LEFT JOIN application_attempts t ON t.room=o.room AND t.digest=o.digest LEFT JOIN application_outbox a ON a.room=t.room AND a.delivery_id=t.delivery_id LEFT JOIN receipt_outbox r ON r.room=o.room AND r.receipt_digest=o.digest WHERE o.room=? AND (t.digest IS NULL OR a.state IN ('dispatching','accepted')) AND (r.receipt_digest IS NULL OR r.state='dispatching' AND r.expires_at>?) ORDER BY CASE WHEN r.receipt_digest IS NOT NULL THEN 0 ELSE 1 END,o.rowid").all(room.current.room, now).map(row => ({ digest: row.digest, wire: copy(row.wire), state: row.state, kind: row.kind, ...(row.receiptFor ? { receiptFor: row.receiptFor } : {}), ...(row.expiresAt !== null ? { expiresAt: row.expiresAt } : {}) }))
|
|
931
|
+
.filter(row => row.kind !== "receipt" || this.receiptSendable(name, row.digest));
|
|
772
932
|
}
|
|
773
|
-
markStored(name: string, digestValue: string): void {
|
|
933
|
+
markStored(name: string, digestValue: string, seq?: string): void {
|
|
774
934
|
const room = this.get(name); if (!room || room.left) fail("untrusted");
|
|
775
|
-
if (this.db.query("SELECT 1 FROM
|
|
776
|
-
this.transaction(
|
|
935
|
+
if (this.db.query("SELECT 1 FROM application_attempts WHERE room=? AND digest=?").get(room.current.room, digestValue)) fail("invalid");
|
|
936
|
+
this.transaction(now => {
|
|
937
|
+
if (seq !== undefined) {
|
|
938
|
+
if (!/^[1-9][0-9]{0,19}$/.test(seq)) fail("invalid");
|
|
939
|
+
const sealed = this.db.query<{ wire: Uint8Array }, [string, string]>("SELECT wire FROM outbox WHERE room=? AND digest=?").get(room.current.room, digestValue);
|
|
940
|
+
if (!sealed || decodeFrame(sealed.wire).seq !== seq) return;
|
|
941
|
+
}
|
|
942
|
+
const receipt = this.db.query<{ expires_at: number }, [string, string]>("SELECT expires_at FROM receipt_outbox WHERE room=? AND receipt_digest=?").get(room.current.room, digestValue);
|
|
943
|
+
if (receipt && receipt.expires_at <= now) return;
|
|
944
|
+
this.db.query("UPDATE outbox SET state='accepted' WHERE room=? AND digest=?").run(room.current.room, digestValue);
|
|
945
|
+
if (receipt) {
|
|
946
|
+
this.db.query("UPDATE receipt_outbox SET state='accepted' WHERE room=? AND receipt_digest=?").run(room.current.room, digestValue);
|
|
947
|
+
this.db.query("DELETE FROM outbox WHERE room=? AND digest=?").run(room.current.room, digestValue);
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
receiptStored(name: string, inboundDigest: string): RoomStoredReceipt | undefined {
|
|
952
|
+
const state = this.get(name); if (!state || state.left) return undefined;
|
|
953
|
+
if (!validDigest(inboundDigest)) fail("invalid");
|
|
954
|
+
const row = this.db.query<{ receipt_digest: string; expires_at: number; state: "dispatching" | "accepted"; wire: Uint8Array | null }, [string, string]>("SELECT r.receipt_digest,r.expires_at,r.state,o.wire FROM receipt_outbox r LEFT JOIN outbox o ON o.room=r.room AND o.digest=r.receipt_digest WHERE r.room=? AND r.inbound_digest=? ORDER BY CASE WHEN r.state='accepted' THEN 0 ELSE 1 END,r.created_at DESC LIMIT 1").get(state.current.room, inboundDigest);
|
|
955
|
+
if (!row) return undefined;
|
|
956
|
+
if (row.state === "dispatching") {
|
|
957
|
+
if (!row.wire) fail("corrupt");
|
|
958
|
+
return { digest: row.receipt_digest, wire: copy(row.wire), state: "dispatching", kind: "receipt", receiptFor: inboundDigest, expiresAt: row.expires_at };
|
|
959
|
+
}
|
|
960
|
+
return { digest: row.receipt_digest, state: "accepted", kind: "receipt", receiptFor: inboundDigest, expiresAt: row.expires_at };
|
|
961
|
+
}
|
|
962
|
+
receiptSendable(name: string, receiptDigest: string): boolean {
|
|
963
|
+
const state = this.get(name); if (!state || state.left || !validDigest(receiptDigest)) return false;
|
|
964
|
+
const row = this.db.query<{ wire: Uint8Array; expires_at: number; epoch: string; roster_digest: string }, [string, string, number]>("SELECT o.wire,r.expires_at,r.epoch,r.roster_digest FROM receipt_outbox r JOIN outbox o ON o.room=r.room AND o.digest=r.receipt_digest WHERE r.room=? AND r.receipt_digest=? AND r.state='dispatching' AND r.expires_at>? AND o.state='dispatching'")
|
|
965
|
+
.get(state.current.room, receiptDigest, this.now());
|
|
966
|
+
if (!row) return false;
|
|
967
|
+
const frame = decodeFrame(row.wire), recipient = frame.recipients[0], now = this.now();
|
|
968
|
+
const members = roomMembers(state.current);
|
|
969
|
+
const currentMember = recipient && members.some(member => member.fingerprint === recipient);
|
|
970
|
+
const senderCurrent = members.some(member => member.fingerprint === this.identity.public.fingerprint);
|
|
971
|
+
const currentEpoch = row.epoch === state.current.epoch && row.roster_digest === state.current.digest;
|
|
972
|
+
const previousEpoch = state.previous && now < state.previous.expiresAt && row.epoch === state.previous.epoch.epoch && row.roster_digest === state.previous.epoch.digest;
|
|
973
|
+
return row.epoch === frame.epoch && validDigest(row.roster_digest) && frame.room === state.current.room && frame.sender === this.identity.public.fingerprint
|
|
974
|
+
&& frame.recipients.length === 1 && currentMember === true && senderCurrent && (currentEpoch || previousEpoch === true);
|
|
975
|
+
}
|
|
976
|
+
async queueReceipt(name: string, inboundDigest: string, wire: Uint8Array, options: { ttlMs: number; replaceExisting?: boolean }, current?: () => boolean): Promise<RoomStoredReceipt> {
|
|
977
|
+
if (!validDigest(inboundDigest) || !options || !Number.isSafeInteger(options.ttlMs) || options.ttlMs < 1 || options.ttlMs > 604_800_000) fail("invalid");
|
|
978
|
+
wire = copy(wire);
|
|
979
|
+
const state = this.get(name); if (!state || state.left) fail("untrusted");
|
|
980
|
+
const roster = decodeRoster(state.wire);
|
|
981
|
+
const publicRoster = await verifyPublicRoster({ wire: state.wire, pin: { room: state.current.room, owner: roster.owner, minEpoch: state.current.epoch, digest: state.current.digest } });
|
|
982
|
+
const frame = await verifyPublicFrame({ wire, roster: publicRoster });
|
|
983
|
+
if (frame.sender !== this.identity.public.fingerprint || frame.recipients.length !== 1) fail("untrusted");
|
|
984
|
+
const plaintext = await openOwnFrame({ wire, identity: this.identity, epoch: state.current });
|
|
985
|
+
let decoded: RoomMessage; try { decoded = decodeRoomMessage(plaintext); } catch { fail("untrusted"); }
|
|
986
|
+
const expected = this.applicationReceipt(name, inboundDigest);
|
|
987
|
+
if (!expected || Buffer.compare(Buffer.from(encodeRoomMessage(decoded)), Buffer.from(encodeRoomMessage(expected))) !== 0) fail("untrusted");
|
|
988
|
+
assertCurrent(current);
|
|
989
|
+
if (this.get(name)?.current.digest !== state.current.digest) fail("state_changed");
|
|
990
|
+
return this.transaction(now => {
|
|
991
|
+
assertCurrent(current);
|
|
992
|
+
if (!this.isCurrent({ room: frame.room, sender: frame.sender, epoch: frame.epoch, rosterDigest: frame.rosterDigest })) fail("state_changed");
|
|
993
|
+
const incoming = this.applicationFor(state.current.room, inboundDigest);
|
|
994
|
+
if (!incoming || !isTerminal(incoming.state) || incoming.sender !== frame.recipients[0]) fail("untrusted");
|
|
995
|
+
this.db.query("DELETE FROM outbox WHERE EXISTS (SELECT 1 FROM receipt_outbox r WHERE r.room=outbox.room AND r.receipt_digest=outbox.digest AND r.expires_at<=?)").run(now);
|
|
996
|
+
this.db.query("DELETE FROM receipt_outbox WHERE expires_at<=?").run(now);
|
|
997
|
+
const rows = this.db.query<{ receipt_digest: string; expires_at: number; state: "dispatching" | "accepted"; wire: Uint8Array | null }, [string, string]>("SELECT r.receipt_digest,r.expires_at,r.state,o.wire FROM receipt_outbox r LEFT JOIN outbox o ON o.room=r.room AND o.digest=r.receipt_digest WHERE r.room=? AND r.inbound_digest=? ORDER BY r.created_at,o.rowid").all(state.current.room, inboundDigest);
|
|
998
|
+
const first = rows[0];
|
|
999
|
+
if (first && rows.some(row => row.expires_at !== first.expires_at)) fail("corrupt");
|
|
1000
|
+
const acceptedProof = [...rows].reverse().find(row => row.state === "accepted");
|
|
1001
|
+
const same = rows.find(row => row.receipt_digest === frame.digest);
|
|
1002
|
+
if (same) {
|
|
1003
|
+
if (same.state === "accepted") return { digest: same.receipt_digest, state: "accepted", kind: "receipt", receiptFor: inboundDigest, expiresAt: same.expires_at };
|
|
1004
|
+
if (!same.wire || Buffer.compare(Buffer.from(wire), Buffer.from(same.wire))) fail("state_changed");
|
|
1005
|
+
return { digest: same.receipt_digest, wire: copy(same.wire), state: "dispatching", kind: "receipt", receiptFor: inboundDigest, expiresAt: same.expires_at };
|
|
1006
|
+
}
|
|
1007
|
+
const pending = rows.filter(row => row.state === "dispatching" && row.wire);
|
|
1008
|
+
if (rows.length && (!options.replaceExisting || first!.expires_at <= now)) {
|
|
1009
|
+
const selected = pending[pending.length - 1] ?? rows[rows.length - 1]!;
|
|
1010
|
+
if (selected.state === "dispatching") {
|
|
1011
|
+
if (!selected.wire) fail("corrupt");
|
|
1012
|
+
return { digest: selected.receipt_digest, wire: copy(selected.wire), state: "dispatching", kind: "receipt", receiptFor: inboundDigest, expiresAt: selected.expires_at };
|
|
1013
|
+
}
|
|
1014
|
+
return { digest: selected.receipt_digest, state: "accepted", kind: "receipt", receiptFor: inboundDigest, expiresAt: selected.expires_at };
|
|
1015
|
+
}
|
|
1016
|
+
if (options.replaceExisting && pending.length) {
|
|
1017
|
+
for (const row of pending) {
|
|
1018
|
+
this.db.query("DELETE FROM outbox WHERE room=? AND digest=?").run(state.current.room, row.receipt_digest);
|
|
1019
|
+
this.db.query("DELETE FROM receipt_outbox WHERE room=? AND receipt_digest=?").run(state.current.room, row.receipt_digest);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
const proofCount = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM receipt_outbox WHERE room=?").get(state.current.room)?.n ?? 0;
|
|
1023
|
+
if (proofCount >= ROOM_APPLICATION_RETENTION.aliasesPerRoom) {
|
|
1024
|
+
if (acceptedProof) return { digest: acceptedProof.receipt_digest, state: "accepted", kind: "receipt", receiptFor: inboundDigest, expiresAt: acceptedProof.expires_at };
|
|
1025
|
+
fail("limit");
|
|
1026
|
+
}
|
|
1027
|
+
const count = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM outbox WHERE room=?").get(state.current.room)?.n ?? 0;
|
|
1028
|
+
const bytes = this.db.query<{ n: number }, []>("SELECT COALESCE(SUM(length(wire)+length(roster)),0) AS n FROM outbox").get()?.n ?? 0;
|
|
1029
|
+
if (count >= ROOM_STORE_LIMITS.outboxPerRoom || bytes + wire.length + state.wire.length > ROOM_STORE_LIMITS.outboxBytes) {
|
|
1030
|
+
if (acceptedProof) return { digest: acceptedProof.receipt_digest, state: "accepted", kind: "receipt", receiptFor: inboundDigest, expiresAt: acceptedProof.expires_at };
|
|
1031
|
+
fail("limit");
|
|
1032
|
+
}
|
|
1033
|
+
const expires = first?.expires_at ?? now + options.ttlMs;
|
|
1034
|
+
if (!Number.isSafeInteger(expires)) fail("invalid");
|
|
1035
|
+
this.db.query("INSERT INTO outbox(room,digest,wire,roster,state) VALUES(?,?,?,?,'dispatching')").run(frame.room, frame.digest, wire, state.wire);
|
|
1036
|
+
this.db.query("INSERT INTO receipt_outbox(room,inbound_digest,receipt_digest,created_at,expires_at,state,recipient,epoch,roster_digest) VALUES(?,?,?,?,?,'dispatching',?,?,?)").run(frame.room, inboundDigest, frame.digest, now, expires, frame.recipients[0]!, frame.epoch, frame.rosterDigest);
|
|
1037
|
+
return { digest: frame.digest, wire: copy(wire), state: "dispatching", kind: "receipt", receiptFor: inboundDigest, expiresAt: expires };
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
retireReceiptFor(name: string, inboundDigest: string): boolean {
|
|
1041
|
+
const state = this.get(name); if (!state || state.left) return false;
|
|
1042
|
+
if (!validDigest(inboundDigest)) fail("invalid");
|
|
1043
|
+
return this.transaction(() => {
|
|
1044
|
+
const receipt = this.db.query("SELECT 1 FROM receipt_outbox WHERE room=? AND inbound_digest=? LIMIT 1").get(state.current.room, inboundDigest);
|
|
1045
|
+
if (!receipt) return false;
|
|
1046
|
+
this.db.query("DELETE FROM outbox WHERE room=? AND digest IN (SELECT receipt_digest FROM receipt_outbox WHERE room=? AND inbound_digest=?)").run(state.current.room, state.current.room, inboundDigest);
|
|
1047
|
+
this.db.query("DELETE FROM receipt_outbox WHERE room=? AND inbound_digest=?").run(state.current.room, inboundDigest);
|
|
1048
|
+
return true;
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
/** Broker storage ends this receipt attempt's local replay responsibility. */
|
|
1052
|
+
retireStoredReceipt(name: string, receiptDigest: string): boolean {
|
|
1053
|
+
const state = this.get(name); if (!state || state.left) return false;
|
|
1054
|
+
if (!validDigest(receiptDigest)) fail("invalid");
|
|
1055
|
+
return this.transaction(() => {
|
|
1056
|
+
const row = this.db.query("SELECT 1 FROM receipt_outbox WHERE room=? AND receipt_digest=? AND state='accepted'").get(state.current.room, receiptDigest);
|
|
1057
|
+
if (!row) return false;
|
|
1058
|
+
this.db.query("DELETE FROM outbox WHERE room=? AND digest=?").run(state.current.room, receiptDigest);
|
|
1059
|
+
this.db.query("DELETE FROM receipt_outbox WHERE room=? AND receipt_digest=?").run(state.current.room, receiptDigest);
|
|
1060
|
+
return true;
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
expireReceipts(name: string): string[] {
|
|
1064
|
+
const state = this.get(name); if (!state || state.left) return [];
|
|
1065
|
+
return this.transaction(now => {
|
|
1066
|
+
const rows = this.db.query<{ inbound_digest: string }, [string, number]>("SELECT DISTINCT inbound_digest FROM receipt_outbox WHERE room=? AND expires_at<=? ORDER BY inbound_digest").all(state.current.room, now);
|
|
1067
|
+
this.db.query("DELETE FROM outbox WHERE room=? AND digest IN (SELECT receipt_digest FROM receipt_outbox WHERE room=? AND expires_at<=?)").run(state.current.room, state.current.room, now);
|
|
1068
|
+
this.db.query("DELETE FROM receipt_outbox WHERE room=? AND expires_at<=?").run(state.current.room, now);
|
|
1069
|
+
return rows.map(row => row.inbound_digest);
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
/** Recheck retained intent immediately before a delayed transport send. */
|
|
1073
|
+
applicationSendable(name: string, frameDigest: string): boolean {
|
|
1074
|
+
const state = this.get(name); if (!state || state.left || !validDigest(frameDigest)) return false;
|
|
1075
|
+
const now = this.now();
|
|
1076
|
+
return !!this.db.query("SELECT 1 FROM application_attempts t JOIN application_outbox a ON a.room=t.room AND a.delivery_id=t.delivery_id JOIN outbox o ON o.room=t.room AND o.digest=t.digest WHERE t.room=? AND t.digest=? AND a.state IN ('dispatching','accepted') AND a.disposition IS NULL AND a.expires_at>?")
|
|
1077
|
+
.get(state.current.room, frameDigest, now);
|
|
1078
|
+
}
|
|
1079
|
+
async outgoingMessage(name: string, application: string, current?: () => boolean): Promise<RoomMessage | undefined> {
|
|
1080
|
+
if (!validUuid(application)) fail("invalid");
|
|
1081
|
+
assertCurrent(current);
|
|
1082
|
+
const state = this.applicationRoom(name, current), now = this.now();
|
|
1083
|
+
const row = this.db.query<OutgoingRow, [string, string, number]>("SELECT * FROM application_outbox WHERE room=? AND application=? AND state IN ('dispatching','accepted') AND disposition IS NULL AND expires_at>? ORDER BY rowid DESC LIMIT 1").get(state.current.room, application, now);
|
|
1084
|
+
if (!row || row.disposition !== null || row.state !== "dispatching" && row.state !== "accepted" || now >= row.expires_at) return undefined;
|
|
1085
|
+
const sealed = this.db.query<{ digest: string; wire: Uint8Array; roster: Uint8Array }, [string, string]>("SELECT a.digest,o.wire,o.roster FROM application_attempts a JOIN outbox o ON o.room=a.room AND o.digest=a.digest WHERE a.room=? AND a.delivery_id=? ORDER BY a.created_at DESC,o.rowid DESC LIMIT 1").get(row.room, row.delivery_id);
|
|
1086
|
+
if (!sealed) return undefined;
|
|
1087
|
+
const frame = decodeFrame(sealed.wire), installed = decodeRoster(state.wire);
|
|
1088
|
+
if (await digest(sealed.wire) !== sealed.digest || frame.room !== state.current.room || frame.sender !== this.identity.public.fingerprint
|
|
1089
|
+
|| frame.recipients.length !== 1 || frame.recipients[0] !== row.recipient) fail("corrupt");
|
|
1090
|
+
const selected = frame.epoch === state.current.epoch ? state.current
|
|
1091
|
+
: state.previous && now < state.previous.expiresAt && frame.epoch === state.previous.epoch.epoch ? state.previous.epoch : undefined;
|
|
1092
|
+
if (!selected) fail("state_changed");
|
|
1093
|
+
const epoch = await acceptRoomEpoch({ wire: sealed.roster, identity: this.identity, pin: { room: state.current.room, owner: installed.owner, minEpoch: selected.epoch, digest: selected.digest } });
|
|
1094
|
+
if (epoch.epoch !== selected.epoch || epoch.digest !== selected.digest) fail("state_changed");
|
|
1095
|
+
const plaintext = await openOwnFrame({ wire: sealed.wire, identity: this.identity, epoch });
|
|
1096
|
+
let message: RoomMessage; try { message = decodeRoomMessage(plaintext); } catch { fail("corrupt"); }
|
|
1097
|
+
if (message.type !== "chat" || message.envelope.id !== row.application || message.envelope.to !== row.peer) fail("corrupt");
|
|
1098
|
+
if (row.content_hash !== null && await digest(encodeRoomMessage(message)) !== row.content_hash) fail("corrupt");
|
|
1099
|
+
assertCurrent(current);
|
|
1100
|
+
const latest = this.get(name), latestRow = this.db.query<{ state: RoomOutgoing["state"]; disposition: string | null; expires_at: number }, [string, string, string]>("SELECT state,disposition,expires_at FROM application_outbox WHERE room=? AND delivery_id=? AND application=?").get(row.room, row.delivery_id, application);
|
|
1101
|
+
if (!latest || latest.left || latest.current.digest !== state.current.digest || !latestRow
|
|
1102
|
+
|| latestRow.disposition !== null || latestRow.state !== "dispatching" && latestRow.state !== "accepted" || this.now() >= latestRow.expires_at
|
|
1103
|
+
|| !this.db.query("SELECT 1 FROM outbox WHERE room=? AND digest=?").get(row.room, sealed.digest)) return undefined;
|
|
1104
|
+
return message;
|
|
777
1105
|
}
|
|
778
1106
|
applicationOutbox(name: string, activeOnly = false): RoomOutgoing[] {
|
|
779
1107
|
const state = this.get(name); if (!state) return [];
|
|
780
1108
|
const filter = activeOnly ? " AND state IN ('dispatching','accepted','needs_review') AND disposition IS NULL" : "";
|
|
781
1109
|
return this.db.query<OutgoingRow, [string]>(`SELECT * FROM application_outbox WHERE room=?${filter} ORDER BY rowid`).all(state.current.room).map(outgoing);
|
|
782
1110
|
}
|
|
783
|
-
/**
|
|
1111
|
+
/** Sealed, unresolved intents that a person must see before changing the epoch. */
|
|
1112
|
+
rotationPending(name: string): RoomOutgoing[] {
|
|
1113
|
+
return this.applicationOutbox(name, true).filter(row => row.state === "dispatching" || row.state === "accepted");
|
|
1114
|
+
}
|
|
1115
|
+
/** Compatibility no-op for retryable rows; only original TTL expiry creates needs_review. */
|
|
784
1116
|
quarantineOutgoing(name: string, deliveryId: string): RoomOutgoing | undefined {
|
|
785
1117
|
const state = this.get(name); if (!state) return undefined;
|
|
786
1118
|
if (!boundedId(deliveryId)) fail("invalid");
|
|
787
|
-
return this.transaction(
|
|
1119
|
+
return this.transaction(now => {
|
|
788
1120
|
const row = this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND delivery_id=?").get(state.current.room, deliveryId);
|
|
789
1121
|
if (!row) return undefined;
|
|
790
1122
|
if (row.state !== "dispatching" && row.state !== "accepted") return outgoing(row);
|
|
791
|
-
|
|
1123
|
+
if (now < row.expires_at) return outgoing(row);
|
|
1124
|
+
this.db.query("UPDATE application_outbox SET state='needs_review' WHERE room=? AND delivery_id=?").run(row.room, row.delivery_id);
|
|
792
1125
|
return outgoing({ ...row, state: "needs_review" });
|
|
793
1126
|
});
|
|
794
1127
|
}
|
|
1128
|
+
quarantineOutgoingPermanent(name: string, deliveryId: string, reason: (typeof PERMANENT_OUTGOING_REFUSALS)[number] = "boundary_refused", current?: () => boolean): RoomOutgoing | undefined {
|
|
1129
|
+
assertCurrent(current);
|
|
1130
|
+
const state = this.get(name); if (!state) return undefined;
|
|
1131
|
+
if (!boundedId(deliveryId) || !PERMANENT_OUTGOING_REFUSALS.includes(reason)) fail("invalid");
|
|
1132
|
+
return this.transaction(() => {
|
|
1133
|
+
assertCurrent(current);
|
|
1134
|
+
const row = this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND delivery_id=?").get(state.current.room, deliveryId);
|
|
1135
|
+
if (!row) return undefined;
|
|
1136
|
+
if (row.state !== "dispatching" && row.state !== "accepted") return outgoing(row);
|
|
1137
|
+
this.db.query("UPDATE application_outbox SET state='needs_review',reason=? WHERE room=? AND delivery_id=?").run(reason, row.room, row.delivery_id);
|
|
1138
|
+
this.db.query("DELETE FROM outbox WHERE room=? AND digest IN (SELECT digest FROM application_attempts WHERE room=? AND delivery_id=?)").run(row.room, row.room, row.delivery_id);
|
|
1139
|
+
return outgoing({ ...row, state: "needs_review", reason });
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
dueApplicationRetries(name: string): RoomOutgoing[] {
|
|
1143
|
+
const state = this.applicationRoom(name);
|
|
1144
|
+
return this.transaction(now => {
|
|
1145
|
+
const rows = this.db.query<OutgoingRow, [string, number, number]>("SELECT * FROM application_outbox WHERE room=? AND state IN ('dispatching','accepted') AND disposition IS NULL AND ack_deadline<=? AND expires_at>? ORDER BY created_at,rowid")
|
|
1146
|
+
.all(state.current.room, now, now);
|
|
1147
|
+
for (const row of rows) {
|
|
1148
|
+
const next = Math.min(now + row.ack_timeout, row.expires_at);
|
|
1149
|
+
this.db.query("UPDATE application_outbox SET ack_deadline=? WHERE room=? AND delivery_id=?").run(next, row.room, row.delivery_id);
|
|
1150
|
+
}
|
|
1151
|
+
return rows.map(row => outgoing({ ...row, ack_deadline: Math.min(now + row.ack_timeout, row.expires_at) }));
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
795
1154
|
markApplicationStored(name: string, digestValue: string, seq: string): RoomOutgoing | undefined {
|
|
796
1155
|
const state = this.applicationRoom(name);
|
|
797
1156
|
return this.transaction(now => {
|
|
798
|
-
const
|
|
1157
|
+
const attempt = this.db.query<{ delivery_id: string }, [string, string]>("SELECT delivery_id FROM application_attempts WHERE room=? AND digest=?").get(state.current.room, digestValue);
|
|
1158
|
+
const row = attempt ? this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND delivery_id=?").get(state.current.room, attempt.delivery_id) : undefined;
|
|
799
1159
|
const sealed = this.db.query<{ wire: Uint8Array }, [string]>("SELECT wire FROM outbox WHERE digest=?").get(digestValue);
|
|
800
1160
|
if (!row || !sealed || decodeFrame(sealed.wire).seq !== seq) return undefined;
|
|
801
1161
|
if (row.state !== "dispatching" && row.state !== "accepted") return outgoing(row);
|
|
802
1162
|
if (!Number.isSafeInteger(row.ack_deadline)) fail("corrupt");
|
|
803
|
-
if (now >= row.
|
|
804
|
-
this.db.query("UPDATE application_outbox SET state='needs_review' WHERE
|
|
1163
|
+
if (now >= row.expires_at) {
|
|
1164
|
+
this.db.query("UPDATE application_outbox SET state='needs_review' WHERE room=? AND delivery_id=?").run(row.room, row.delivery_id);
|
|
805
1165
|
return outgoing({ ...row, state: "needs_review" });
|
|
806
1166
|
}
|
|
807
1167
|
if (row.state === "accepted") return outgoing(row);
|
|
808
|
-
this.db.query("UPDATE application_outbox SET state='accepted' WHERE
|
|
1168
|
+
this.db.query("UPDATE application_outbox SET state='accepted' WHERE room=? AND delivery_id=?").run(row.room, row.delivery_id);
|
|
809
1169
|
this.db.query("UPDATE outbox SET state='accepted' WHERE digest=?").run(digestValue);
|
|
810
1170
|
return outgoing({ ...row, state: "accepted" });
|
|
811
1171
|
});
|
|
@@ -826,18 +1186,22 @@ export class RoomStore {
|
|
|
826
1186
|
if (!entry) return undefined;
|
|
827
1187
|
let message: RoomMessage; try { message = decodeRoomMessage(entry.plaintext); } catch { fail("invalid"); }
|
|
828
1188
|
if (message.type !== "receipt") return undefined;
|
|
829
|
-
const
|
|
1189
|
+
const attempt = this.db.query<{ delivery_id: string }, [string, string]>("SELECT delivery_id FROM application_attempts WHERE room=? AND digest=?").get(state.current.room, message.frame);
|
|
1190
|
+
const row = attempt ? this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND delivery_id=?").get(state.current.room, attempt.delivery_id) : undefined;
|
|
830
1191
|
if (!row || row.application !== message.application || row.peer !== message.peer || row.recipient !== entry.sender
|
|
831
1192
|
|| !roomMembers(state.current).some(member => member.fingerprint === entry.sender) || !validOutcome(message.outcome, message.reason, row.peer)) return undefined;
|
|
832
1193
|
if (isTerminal(row.state) || row.state === "needs_review" && row.late_outcome !== null) return outgoing(row);
|
|
833
|
-
const expired = now >= row.expires_at
|
|
1194
|
+
const expired = now >= row.expires_at;
|
|
834
1195
|
if (row.state === "needs_review" || expired) {
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
1196
|
+
const settledLate = row.disposition === null && row.reason === null;
|
|
1197
|
+
this.db.query("UPDATE application_outbox SET state=CASE WHEN ? THEN ? ELSE state END,reason=CASE WHEN ? THEN ? ELSE reason END,late_outcome=?,late_reason=?,receipt_digest=? WHERE room=? AND delivery_id=?")
|
|
1198
|
+
.run(settledLate, message.outcome, settledLate, message.reason, message.outcome, message.reason, receiptFrameDigest, row.room, row.delivery_id);
|
|
1199
|
+
this.db.query("DELETE FROM outbox WHERE room=? AND digest IN (SELECT digest FROM application_attempts WHERE room=? AND delivery_id=?)").run(row.room, row.room, row.delivery_id);
|
|
1200
|
+
this.pruneOutgoingHistory(state.current.room);
|
|
1201
|
+
return outgoing({ ...row, state: settledLate ? message.outcome : row.state, reason: settledLate ? message.reason : row.reason, late_outcome: message.outcome, late_reason: message.reason, receipt_digest: receiptFrameDigest });
|
|
838
1202
|
}
|
|
839
|
-
this.db.query("UPDATE application_outbox SET state=?,reason=?,receipt_digest=? WHERE
|
|
840
|
-
this.db.query("DELETE FROM outbox WHERE digest=?").run(row.
|
|
1203
|
+
this.db.query("UPDATE application_outbox SET state=?,reason=?,receipt_digest=? WHERE room=? AND delivery_id=?").run(message.outcome, message.reason, receiptFrameDigest, row.room, row.delivery_id);
|
|
1204
|
+
this.db.query("DELETE FROM outbox WHERE room=? AND digest IN (SELECT digest FROM application_attempts WHERE room=? AND delivery_id=?)").run(row.room, row.room, row.delivery_id);
|
|
841
1205
|
this.pruneOutgoingHistory(state.current.room);
|
|
842
1206
|
return outgoing({ ...row, state: message.outcome, reason: message.reason, receipt_digest: receiptFrameDigest });
|
|
843
1207
|
});
|
|
@@ -845,39 +1209,50 @@ export class RoomStore {
|
|
|
845
1209
|
private pruneOutgoingHistory(room: string): void {
|
|
846
1210
|
this.db.query("DELETE FROM application_outbox WHERE room=? AND (state IN ('completed','failed_safe') OR disposition IS NOT NULL) AND rowid NOT IN (SELECT rowid FROM application_outbox WHERE room=? AND (state IN ('completed','failed_safe') OR disposition IS NOT NULL) ORDER BY rowid DESC LIMIT ?)")
|
|
847
1211
|
.run(room, room, APPLICATION_HISTORY_CAP);
|
|
1212
|
+
this.db.query("DELETE FROM application_attempts WHERE room=? AND delivery_id NOT IN (SELECT delivery_id FROM application_outbox WHERE room=?)").run(room, room);
|
|
848
1213
|
}
|
|
849
1214
|
/** Local disposition follows a successful person Bus resolution; it is never remote completion proof. */
|
|
850
|
-
disposeApplication(name: string, deliveryId: string, action: "completed" | "retry" | "discard", current
|
|
1215
|
+
disposeApplication(name: string, deliveryId: string, action: "completed" | "retry" | "discard", authoritative: true, current: () => boolean): RoomOutgoing | undefined {
|
|
851
1216
|
assertCurrent(current);
|
|
852
1217
|
const state = this.get(name); if (!state) return undefined;
|
|
1218
|
+
if (authoritative !== true) fail("untrusted");
|
|
853
1219
|
if (!boundedId(deliveryId) || !["completed", "retry", "discard"].includes(action)) fail("invalid");
|
|
854
1220
|
return this.transaction(() => {
|
|
855
1221
|
assertCurrent(current);
|
|
856
1222
|
const row = this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND delivery_id=?").get(state.current.room, deliveryId);
|
|
857
|
-
if (!row ||
|
|
1223
|
+
if (!row || !["dispatching", "accepted", "needs_review", "completed", "failed_safe"].includes(row.state)) return undefined;
|
|
858
1224
|
if (row.disposition !== null) { if (row.disposition !== action) fail("state_changed"); return outgoing(row); }
|
|
859
|
-
|
|
860
|
-
this.db.query("
|
|
1225
|
+
const nextState = row.state === "dispatching" || row.state === "accepted" ? "needs_review" : row.state;
|
|
1226
|
+
this.db.query("UPDATE application_outbox SET state=?,disposition=? WHERE room=? AND delivery_id=?").run(nextState, action, row.room, row.delivery_id);
|
|
1227
|
+
this.db.query("DELETE FROM outbox WHERE room=? AND digest IN (SELECT digest FROM application_attempts WHERE room=? AND delivery_id=?)").run(row.room, row.room, row.delivery_id);
|
|
861
1228
|
this.pruneOutgoingHistory(state.current.room);
|
|
862
|
-
return outgoing({ ...row, disposition: action });
|
|
1229
|
+
return outgoing({ ...row, state: nextState, disposition: action });
|
|
863
1230
|
});
|
|
864
1231
|
}
|
|
865
1232
|
expireApplications(name: string): RoomOutgoing[] {
|
|
866
1233
|
const state = this.applicationRoom(name);
|
|
867
1234
|
return this.transaction(now => {
|
|
868
|
-
const rows = this.db.query<OutgoingRow, [string, number
|
|
869
|
-
for (const row of rows)
|
|
1235
|
+
const rows = this.db.query<OutgoingRow, [string, number]>("SELECT * FROM application_outbox WHERE room=? AND state IN ('dispatching','accepted') AND expires_at<=? ORDER BY rowid").all(state.current.room, now);
|
|
1236
|
+
for (const row of rows) {
|
|
1237
|
+
this.db.query("UPDATE application_outbox SET state='needs_review' WHERE room=? AND delivery_id=?").run(row.room, row.delivery_id);
|
|
1238
|
+
}
|
|
870
1239
|
return rows.map(row => outgoing({ ...row, state: "needs_review" }));
|
|
871
1240
|
});
|
|
872
1241
|
}
|
|
873
1242
|
private applicationFor(room: string, digestValue: string): ApplicationRow | undefined {
|
|
874
1243
|
return this.db.query<ApplicationRow, [string, string]>("SELECT a.* FROM applications a JOIN application_frames f ON f.room=a.room AND f.sender=a.sender AND f.application=a.application WHERE f.room=? AND f.digest=?").get(room, digestValue) ?? undefined;
|
|
875
1244
|
}
|
|
876
|
-
private personIdentityRetained(row: Pick<ApplicationRow, "room" | "sender" | "application"
|
|
877
|
-
return !!this.db.query("SELECT 1 FROM person_inbox WHERE room=? AND sender=? AND application=?").get(row.room, row.sender, row.application)
|
|
1245
|
+
private personIdentityRetained(row: Pick<ApplicationRow, "room" | "sender" | "application">, now = this.now()): boolean {
|
|
1246
|
+
return !!this.db.query("SELECT 1 FROM person_inbox WHERE room=? AND sender=? AND application=?").get(row.room, row.sender, row.application)
|
|
1247
|
+
|| !!this.db.query("SELECT 1 FROM receipt_outbox r JOIN application_frames f ON f.room=r.room AND f.digest=r.inbound_digest WHERE f.room=? AND f.sender=? AND f.application=? AND r.state='dispatching' AND r.expires_at>?").get(row.room, row.sender, row.application, now);
|
|
878
1248
|
}
|
|
879
|
-
private deleteSettledApplication(row: ApplicationRow): void {
|
|
880
|
-
if (this.db.query("SELECT 1 FROM person_inbox WHERE room=? AND sender=? AND application=? AND state='unread'").get(row.room, row.sender, row.application)
|
|
1249
|
+
private deleteSettledApplication(row: ApplicationRow, now = this.now()): void {
|
|
1250
|
+
if (this.db.query("SELECT 1 FROM person_inbox WHERE room=? AND sender=? AND application=? AND state='unread'").get(row.room, row.sender, row.application)
|
|
1251
|
+
|| this.db.query("SELECT 1 FROM receipt_outbox r JOIN application_frames f ON f.room=r.room AND f.digest=r.inbound_digest WHERE f.room=? AND f.sender=? AND f.application=? AND r.state='dispatching' AND r.expires_at>?").get(row.room, row.sender, row.application, now)) return;
|
|
1252
|
+
this.db.query("DELETE FROM outbox WHERE room=? AND digest IN (SELECT r.receipt_digest FROM receipt_outbox r JOIN application_frames f ON f.room=r.room AND f.digest=r.inbound_digest WHERE f.room=? AND f.sender=? AND f.application=?)")
|
|
1253
|
+
.run(row.room, row.room, row.sender, row.application);
|
|
1254
|
+
this.db.query("DELETE FROM receipt_outbox WHERE room=? AND inbound_digest IN (SELECT digest FROM application_frames WHERE room=? AND sender=? AND application=?)")
|
|
1255
|
+
.run(row.room, row.room, row.sender, row.application);
|
|
881
1256
|
this.db.query("DELETE FROM person_inbox WHERE room=? AND sender=? AND application=?").run(row.room, row.sender, row.application);
|
|
882
1257
|
this.db.query("DELETE FROM inbox WHERE room=? AND plaintext IS NULL AND digest IN (SELECT digest FROM application_frames WHERE room=? AND sender=? AND application=?)")
|
|
883
1258
|
.run(row.room, row.room, row.sender, row.application);
|
|
@@ -888,16 +1263,17 @@ export class RoomStore {
|
|
|
888
1263
|
this.prunePersonInbox(now);
|
|
889
1264
|
// Person stubs have their own disposition/expiry horizon. Only the cap walk
|
|
890
1265
|
// below may evict a settled stub early; unread identities are never candidates.
|
|
891
|
-
const expired = this.db.query<ApplicationRow, [string, number, string, string]>("SELECT * FROM applications WHERE room=? AND state IN ('completed','failed_safe','disposed') AND settled_at<=? AND NOT (sender=? AND application=?) AND NOT EXISTS (SELECT 1 FROM person_inbox p WHERE p.room=applications.room AND p.sender=applications.sender AND p.application=applications.application)").all(room, now - ROOM_APPLICATION_RETENTION.settledMs, retained?.sender ?? "", retained?.application ?? "");
|
|
892
|
-
for (const row of expired) this.deleteSettledApplication(row);
|
|
1266
|
+
const expired = this.db.query<ApplicationRow, [string, number, string, string, number]>("SELECT * FROM applications WHERE room=? AND state IN ('completed','failed_safe','disposed') AND settled_at<=? AND NOT (sender=? AND application=?) AND NOT EXISTS (SELECT 1 FROM person_inbox p WHERE p.room=applications.room AND p.sender=applications.sender AND p.application=applications.application) AND NOT EXISTS (SELECT 1 FROM receipt_outbox r JOIN application_frames f ON f.room=r.room AND f.digest=r.inbound_digest WHERE f.room=applications.room AND f.sender=applications.sender AND f.application=applications.application AND r.state='dispatching' AND r.expires_at>?)").all(room, now - ROOM_APPLICATION_RETENTION.settledMs, retained?.sender ?? "", retained?.application ?? "", now);
|
|
1267
|
+
for (const row of expired) this.deleteSettledApplication(row, now);
|
|
893
1268
|
for (;;) {
|
|
894
1269
|
const identities = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM applications WHERE room=?").get(room)?.n ?? 0;
|
|
895
1270
|
const aliases = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM application_frames WHERE room=?").get(room)?.n ?? 0;
|
|
896
1271
|
if ((!freshIdentity || identities < ROOM_APPLICATION_RETENTION.identitiesPerRoom) && aliases < ROOM_APPLICATION_RETENTION.aliasesPerRoom) return;
|
|
1272
|
+
// ponytail: automatic resend can reach this settled-identity eviction and admit an old application again. Pin retryable identities or add #737 receipt confirmation before claiming dedupe beyond this ceiling.
|
|
897
1273
|
// A person identity becomes settled at done/discard/expiry, not at its earlier admission.
|
|
898
|
-
const settled = this.db.query<ApplicationRow, [string, string, string]>("SELECT * FROM applications WHERE room=? AND state IN ('completed','failed_safe','disposed') AND NOT (sender=? AND application=?) AND NOT EXISTS (SELECT 1 FROM person_inbox p WHERE p.room=applications.room AND p.sender=applications.sender AND p.application=applications.application AND p.state='unread') ORDER BY COALESCE((SELECT p.settled_at FROM person_inbox p WHERE p.room=applications.room AND p.sender=applications.sender AND p.application=applications.application),settled_at),created_at,sender,application LIMIT 1").get(room, retained?.sender ?? "", retained?.application ?? "");
|
|
1274
|
+
const settled = this.db.query<ApplicationRow, [string, string, string, number]>("SELECT * FROM applications WHERE room=? AND state IN ('completed','failed_safe','disposed') AND NOT (sender=? AND application=?) AND NOT EXISTS (SELECT 1 FROM person_inbox p WHERE p.room=applications.room AND p.sender=applications.sender AND p.application=applications.application AND p.state='unread') AND NOT EXISTS (SELECT 1 FROM receipt_outbox r JOIN application_frames f ON f.room=r.room AND f.digest=r.inbound_digest WHERE f.room=applications.room AND f.sender=applications.sender AND f.application=applications.application AND r.state='dispatching' AND r.expires_at>?) ORDER BY COALESCE((SELECT p.settled_at FROM person_inbox p WHERE p.room=applications.room AND p.sender=applications.sender AND p.application=applications.application),settled_at),created_at,sender,application LIMIT 1").get(room, retained?.sender ?? "", retained?.application ?? "", now);
|
|
899
1275
|
if (!settled) throw new RoomApplicationLimitError(room, Math.max(identities, aliases));
|
|
900
|
-
this.deleteSettledApplication(settled);
|
|
1276
|
+
this.deleteSettledApplication(settled, now);
|
|
901
1277
|
}
|
|
902
1278
|
}
|
|
903
1279
|
async applicationIncoming(name: string, digestValue: string, current?: () => boolean): Promise<{ sender: string; chat: RoomChatEnvelope; contentHash: string; primary: boolean; state: ApplicationState }> {
|
|
@@ -918,7 +1294,7 @@ export class RoomStore {
|
|
|
918
1294
|
let saved = this.db.query<ApplicationRow, [string, string, string]>("SELECT * FROM applications WHERE room=? AND sender=? AND application=?").get(...key);
|
|
919
1295
|
if (saved && saved.settled_at !== null && saved.settled_at <= now - ROOM_APPLICATION_RETENTION.settledMs && isSettledApplication(saved.state)) {
|
|
920
1296
|
this.prunePersonInbox(now);
|
|
921
|
-
if (!this.
|
|
1297
|
+
if (!this.personIdentityRetained(saved)) { this.deleteSettledApplication(saved); saved = null; }
|
|
922
1298
|
}
|
|
923
1299
|
if (saved && (saved.content_hash !== contentHash || saved.peer !== chat.to)) fail("invalid");
|
|
924
1300
|
const alias = this.applicationFor(state.current.room, digestValue);
|
|
@@ -1152,8 +1528,14 @@ export class RoomStore {
|
|
|
1152
1528
|
return row.hop!;
|
|
1153
1529
|
}
|
|
1154
1530
|
private recoverApplications(): void {
|
|
1155
|
-
this.transaction(
|
|
1156
|
-
this.db.query("UPDATE application_outbox SET state='
|
|
1531
|
+
this.transaction(now => {
|
|
1532
|
+
this.db.query("UPDATE application_outbox SET state='dispatching',ack_deadline=MIN(?+ack_timeout,expires_at) WHERE state='needs_review' AND disposition IS NULL AND late_outcome IS NULL AND reason IS NULL AND expires_at>?").run(now, now);
|
|
1533
|
+
this.db.query("UPDATE application_outbox SET state='needs_review' WHERE state IN ('dispatching','accepted') AND expires_at<=?").run(now);
|
|
1534
|
+
const lateRows = this.db.query<OutgoingRow, []>("SELECT * FROM application_outbox WHERE state='needs_review' AND late_outcome IS NOT NULL AND disposition IS NULL AND reason IS NULL").all();
|
|
1535
|
+
for (const row of lateRows) if (this.lateReceiptProof(row) === "valid") {
|
|
1536
|
+
this.db.query("UPDATE application_outbox SET state=late_outcome,reason=late_reason WHERE room=? AND delivery_id=? AND disposition IS NULL AND reason IS NULL").run(row.room, row.delivery_id);
|
|
1537
|
+
}
|
|
1538
|
+
this.db.query("DELETE FROM outbox WHERE EXISTS (SELECT 1 FROM application_attempts t JOIN application_outbox a ON a.room=t.room AND a.delivery_id=t.delivery_id WHERE t.room=outbox.room AND t.digest=outbox.digest AND (a.state IN ('completed','failed_safe') OR a.state='needs_review' AND (a.reason IS NOT NULL OR a.disposition IS NOT NULL OR a.late_outcome IS NOT NULL)))").run();
|
|
1157
1539
|
this.db.query("UPDATE applications SET state='needs_review' WHERE state='dispatching'").run();
|
|
1158
1540
|
this.db.query("UPDATE inbox SET plaintext=NULL WHERE EXISTS (SELECT 1 FROM application_frames f JOIN applications a ON a.room=f.room AND a.sender=f.sender AND a.application=f.application WHERE f.room=inbox.room AND f.digest=inbox.digest AND a.state='needs_review')").run();
|
|
1159
1541
|
for (const state of this.rooms.values()) this.pruneInbox(state.current.room);
|