@staix/agent-hub 0.30.0 → 0.31.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 +39 -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 +9 -4
- package/docs/agent-notes/daemon.md +15 -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 +139 -1
- package/docs/design/task-board.md +82 -0
- package/docs/dogfood.md +22 -4
- package/docs/events.md +3 -0
- package/docs/memory.md +17 -1
- package/docs/operations.md +307 -78
- package/docs/profiles.md +35 -0
- package/docs/quickstart.md +18 -5
- package/docs/security.md +154 -60
- package/docs/specs/2026-09-19-agent-hub-design.md +84 -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 +257 -0
- package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -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 +77 -16
- 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/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 +493 -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 +1569 -134
- 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/link.ts +209 -21
- package/src/rooms/remote-peer.ts +186 -19
- package/src/rooms/service.ts +338 -22
- package/src/rooms/store.ts +437 -65
- package/src/rooms/wire.ts +24 -6
- package/src/ui/index.html +138 -7
- 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
|
}
|
|
@@ -735,6 +819,32 @@ export class RoomStore {
|
|
|
735
819
|
const publicRoster = await verifyPublicRoster({ wire: state.wire, pin: { room: state.current.room, owner: roster.owner, minEpoch: state.current.epoch, digest: state.current.digest } });
|
|
736
820
|
const frame = await verifyPublicFrame({ wire, roster: publicRoster });
|
|
737
821
|
if (frame.sender !== this.identity.public.fingerprint) fail("untrusted");
|
|
822
|
+
let contentHash: string | undefined;
|
|
823
|
+
let legacyContentHash: string | undefined;
|
|
824
|
+
let legacyDeliveryId: string | undefined;
|
|
825
|
+
let legacyAttemptDigest: string | undefined;
|
|
826
|
+
if (application) {
|
|
827
|
+
const plaintext = await openOwnFrame({ wire, identity: this.identity, epoch: state.current });
|
|
828
|
+
let message: RoomMessage; try { message = decodeRoomMessage(plaintext); } catch { fail("invalid"); }
|
|
829
|
+
if (message.type !== "chat" || message.envelope.id !== application.application || message.envelope.to !== application.peer) fail("state_changed");
|
|
830
|
+
contentHash = await digest(encodeRoomMessage(message));
|
|
831
|
+
const saved = this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND application=? ORDER BY rowid").all(frame.room, application.application);
|
|
832
|
+
const legacy = saved.filter(row => row.content_hash === null);
|
|
833
|
+
if (legacy.length) {
|
|
834
|
+
if (saved.length !== 1 || legacy[0]!.delivery_id !== application.deliveryId
|
|
835
|
+
|| legacy[0]!.state !== "dispatching" && legacy[0]!.state !== "accepted" || legacy[0]!.disposition !== null
|
|
836
|
+
|| legacy[0]!.reason !== null || legacy[0]!.expires_at <= this.now()) fail("state_changed");
|
|
837
|
+
legacyDeliveryId = legacy[0]!.delivery_id;
|
|
838
|
+
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;
|
|
839
|
+
if (!legacyAttemptDigest) fail("state_changed");
|
|
840
|
+
const previous = await this.outgoingMessage(name, application.application, current);
|
|
841
|
+
if (!previous || previous.type !== "chat") fail("state_changed");
|
|
842
|
+
legacyContentHash = await digest(encodeRoomMessage(previous));
|
|
843
|
+
if (legacyContentHash !== contentHash) fail("state_changed");
|
|
844
|
+
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;
|
|
845
|
+
if (afterReadDigest !== legacyAttemptDigest) fail("state_changed");
|
|
846
|
+
}
|
|
847
|
+
}
|
|
738
848
|
return this.transaction(now => {
|
|
739
849
|
assertCurrent(current);
|
|
740
850
|
if (!this.isCurrent({ room: frame.room, sender: frame.sender, epoch: frame.epoch, rosterDigest: frame.rosterDigest })) fail("state_changed");
|
|
@@ -743,69 +853,309 @@ export class RoomStore {
|
|
|
743
853
|
if (!recipient || application.recipient === this.identity.public.fingerprint || frame.recipients.length !== 1
|
|
744
854
|
|| frame.recipients[0] !== application.recipient || application.remotePeer !== `${application.peer}@${recipient.label}`) fail("untrusted");
|
|
745
855
|
}
|
|
856
|
+
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) : [];
|
|
857
|
+
if (application) {
|
|
858
|
+
if (contentHash === undefined) fail("corrupt");
|
|
859
|
+
for (const prior of applicationRows) {
|
|
860
|
+
if (prior.remote_peer !== application.remotePeer || prior.recipient !== application.recipient || prior.peer !== application.peer
|
|
861
|
+
|| prior.ack_timeout !== application.ackTimeoutMs || prior.expires_at - prior.created_at !== application.ttlMs
|
|
862
|
+
|| prior.expires_at <= now || prior.disposition !== null || prior.state === "needs_review"
|
|
863
|
+
|| prior.reason !== null && PERMANENT_OUTGOING_REFUSALS.includes(prior.reason)) fail("state_changed");
|
|
864
|
+
if (prior.content_hash !== null && prior.content_hash !== contentHash) fail("state_changed");
|
|
865
|
+
if (prior.content_hash === null && (legacyContentHash !== contentHash || prior.delivery_id !== legacyDeliveryId)) fail("state_changed");
|
|
866
|
+
}
|
|
867
|
+
if (legacyContentHash === contentHash && legacyDeliveryId) {
|
|
868
|
+
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);
|
|
869
|
+
if (attempt?.digest !== legacyAttemptDigest) fail("state_changed");
|
|
870
|
+
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);
|
|
871
|
+
if (update.changes !== 1) {
|
|
872
|
+
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;
|
|
873
|
+
if (currentHash !== contentHash) fail("state_changed");
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
746
877
|
const existing = this.db.query<{ wire: Uint8Array }, [string]>("SELECT wire FROM outbox WHERE digest=?").get(frame.digest);
|
|
747
878
|
const saved = this.db.query<OutgoingRow, [string]>("SELECT * FROM application_outbox WHERE digest=?").get(frame.digest);
|
|
748
879
|
if (existing || saved) {
|
|
880
|
+
if (!existing) fail("state_changed");
|
|
749
881
|
if (existing && Buffer.compare(Buffer.from(wire), Buffer.from(existing.wire))) fail("state_changed");
|
|
750
882
|
if (!application) { if (saved) fail("state_changed"); return undefined; }
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
883
|
+
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);
|
|
884
|
+
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);
|
|
885
|
+
if (!row || row.delivery_id !== application.deliveryId || row.application !== application.application
|
|
886
|
+
|| row.remote_peer !== application.remotePeer || row.recipient !== application.recipient || row.peer !== application.peer
|
|
887
|
+
|| row.ack_timeout !== application.ackTimeoutMs || row.expires_at - row.created_at !== application.ttlMs || row.content_hash !== contentHash) fail("state_changed");
|
|
888
|
+
return { ...outgoing(row), frameDigest: frame.digest };
|
|
755
889
|
}
|
|
756
|
-
|
|
890
|
+
const row = application ? this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND delivery_id=?").get(frame.room, application.deliveryId) : undefined;
|
|
891
|
+
if (application && this.db.query("SELECT 1 FROM application_attempts WHERE room=? AND digest=?").get(frame.room, frame.digest)) fail("state_changed");
|
|
892
|
+
if (row && (row.application !== application!.application || 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.disposition !== null
|
|
894
|
+
|| row.state !== "dispatching" && row.state !== "accepted" || now >= row.expires_at || row.content_hash !== contentHash)) fail("state_changed");
|
|
895
|
+
const aliasCount = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM application_attempts WHERE room=?").get(frame.room)?.n ?? 0;
|
|
896
|
+
if (application && aliasCount >= ROOM_APPLICATION_RETENTION.aliasesPerRoom) fail("limit");
|
|
897
|
+
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
898
|
const count = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM outbox WHERE room=?").get(frame.room)?.n ?? 0;
|
|
758
899
|
const bytes = this.db.query<{ n: number }, []>("SELECT COALESCE(SUM(length(wire)+length(roster)),0) AS n FROM outbox").get()?.n ?? 0;
|
|
759
900
|
if (count >= ROOM_STORE_LIMITS.outboxPerRoom || bytes + wire.length + state.wire.length > ROOM_STORE_LIMITS.outboxBytes) fail("limit");
|
|
760
901
|
this.db.query("INSERT INTO outbox(room,digest,wire,roster,state) VALUES(?,?,?,?,'dispatching')").run(frame.room, frame.digest, wire, state.wire);
|
|
761
902
|
if (!application) return undefined;
|
|
762
|
-
|
|
903
|
+
if (row) {
|
|
904
|
+
const deadline = Math.min(now + application.ackTimeoutMs, row.expires_at);
|
|
905
|
+
if (!Number.isSafeInteger(deadline)) fail("invalid");
|
|
906
|
+
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);
|
|
907
|
+
this.db.query("UPDATE application_outbox SET ack_deadline=?,state='dispatching' WHERE room=? AND delivery_id=?").run(deadline, frame.room, row.delivery_id);
|
|
908
|
+
const updated = this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND delivery_id=?").get(frame.room, row.delivery_id)!;
|
|
909
|
+
return { ...outgoing(updated), frameDigest: frame.digest };
|
|
910
|
+
}
|
|
911
|
+
const canonical = applicationRows[0];
|
|
912
|
+
const expires = canonical?.expires_at ?? now + application.ttlMs, deadline = Math.min(now + application.ackTimeoutMs, expires);
|
|
763
913
|
if (!Number.isSafeInteger(expires) || !Number.isSafeInteger(deadline)) fail("invalid");
|
|
764
914
|
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
915
|
.run(frame.room, frame.digest, application.deliveryId, application.application, application.remotePeer, application.recipient, application.peer, now, expires, application.ackTimeoutMs, deadline);
|
|
766
|
-
|
|
916
|
+
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);
|
|
917
|
+
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);
|
|
918
|
+
return { ...outgoing(this.db.query<OutgoingRow, [string]>("SELECT * FROM application_outbox WHERE digest=?").get(frame.digest)!), frameDigest: frame.digest };
|
|
767
919
|
});
|
|
768
920
|
}
|
|
769
|
-
outbox(name: string):
|
|
921
|
+
outbox(name: string): RoomOutboxEntry[] {
|
|
770
922
|
const room = this.get(name); if (!room || room.left) return [];
|
|
771
|
-
|
|
923
|
+
const now = this.now();
|
|
924
|
+
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 } : {}) }))
|
|
925
|
+
.filter(row => row.kind !== "receipt" || this.receiptSendable(name, row.digest));
|
|
772
926
|
}
|
|
773
|
-
markStored(name: string, digestValue: string): void {
|
|
927
|
+
markStored(name: string, digestValue: string, seq?: string): void {
|
|
774
928
|
const room = this.get(name); if (!room || room.left) fail("untrusted");
|
|
775
|
-
if (this.db.query("SELECT 1 FROM
|
|
776
|
-
this.transaction(
|
|
929
|
+
if (this.db.query("SELECT 1 FROM application_attempts WHERE room=? AND digest=?").get(room.current.room, digestValue)) fail("invalid");
|
|
930
|
+
this.transaction(now => {
|
|
931
|
+
if (seq !== undefined) {
|
|
932
|
+
if (!/^[1-9][0-9]{0,19}$/.test(seq)) fail("invalid");
|
|
933
|
+
const sealed = this.db.query<{ wire: Uint8Array }, [string, string]>("SELECT wire FROM outbox WHERE room=? AND digest=?").get(room.current.room, digestValue);
|
|
934
|
+
if (!sealed || decodeFrame(sealed.wire).seq !== seq) return;
|
|
935
|
+
}
|
|
936
|
+
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);
|
|
937
|
+
if (receipt && receipt.expires_at <= now) return;
|
|
938
|
+
this.db.query("UPDATE outbox SET state='accepted' WHERE room=? AND digest=?").run(room.current.room, digestValue);
|
|
939
|
+
if (receipt) {
|
|
940
|
+
this.db.query("UPDATE receipt_outbox SET state='accepted' WHERE room=? AND receipt_digest=?").run(room.current.room, digestValue);
|
|
941
|
+
this.db.query("DELETE FROM outbox WHERE room=? AND digest=?").run(room.current.room, digestValue);
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
receiptStored(name: string, inboundDigest: string): RoomStoredReceipt | undefined {
|
|
946
|
+
const state = this.get(name); if (!state || state.left) return undefined;
|
|
947
|
+
if (!validDigest(inboundDigest)) fail("invalid");
|
|
948
|
+
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);
|
|
949
|
+
if (!row) return undefined;
|
|
950
|
+
if (row.state === "dispatching") {
|
|
951
|
+
if (!row.wire) fail("corrupt");
|
|
952
|
+
return { digest: row.receipt_digest, wire: copy(row.wire), state: "dispatching", kind: "receipt", receiptFor: inboundDigest, expiresAt: row.expires_at };
|
|
953
|
+
}
|
|
954
|
+
return { digest: row.receipt_digest, state: "accepted", kind: "receipt", receiptFor: inboundDigest, expiresAt: row.expires_at };
|
|
955
|
+
}
|
|
956
|
+
receiptSendable(name: string, receiptDigest: string): boolean {
|
|
957
|
+
const state = this.get(name); if (!state || state.left || !validDigest(receiptDigest)) return false;
|
|
958
|
+
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'")
|
|
959
|
+
.get(state.current.room, receiptDigest, this.now());
|
|
960
|
+
if (!row) return false;
|
|
961
|
+
const frame = decodeFrame(row.wire), recipient = frame.recipients[0], now = this.now();
|
|
962
|
+
const members = roomMembers(state.current);
|
|
963
|
+
const currentMember = recipient && members.some(member => member.fingerprint === recipient);
|
|
964
|
+
const senderCurrent = members.some(member => member.fingerprint === this.identity.public.fingerprint);
|
|
965
|
+
const currentEpoch = row.epoch === state.current.epoch && row.roster_digest === state.current.digest;
|
|
966
|
+
const previousEpoch = state.previous && now < state.previous.expiresAt && row.epoch === state.previous.epoch.epoch && row.roster_digest === state.previous.epoch.digest;
|
|
967
|
+
return row.epoch === frame.epoch && validDigest(row.roster_digest) && frame.room === state.current.room && frame.sender === this.identity.public.fingerprint
|
|
968
|
+
&& frame.recipients.length === 1 && currentMember === true && senderCurrent && (currentEpoch || previousEpoch === true);
|
|
969
|
+
}
|
|
970
|
+
async queueReceipt(name: string, inboundDigest: string, wire: Uint8Array, options: { ttlMs: number; replaceExisting?: boolean }, current?: () => boolean): Promise<RoomStoredReceipt> {
|
|
971
|
+
if (!validDigest(inboundDigest) || !options || !Number.isSafeInteger(options.ttlMs) || options.ttlMs < 1 || options.ttlMs > 604_800_000) fail("invalid");
|
|
972
|
+
wire = copy(wire);
|
|
973
|
+
const state = this.get(name); if (!state || state.left) fail("untrusted");
|
|
974
|
+
const roster = decodeRoster(state.wire);
|
|
975
|
+
const publicRoster = await verifyPublicRoster({ wire: state.wire, pin: { room: state.current.room, owner: roster.owner, minEpoch: state.current.epoch, digest: state.current.digest } });
|
|
976
|
+
const frame = await verifyPublicFrame({ wire, roster: publicRoster });
|
|
977
|
+
if (frame.sender !== this.identity.public.fingerprint || frame.recipients.length !== 1) fail("untrusted");
|
|
978
|
+
const plaintext = await openOwnFrame({ wire, identity: this.identity, epoch: state.current });
|
|
979
|
+
let decoded: RoomMessage; try { decoded = decodeRoomMessage(plaintext); } catch { fail("untrusted"); }
|
|
980
|
+
const expected = this.applicationReceipt(name, inboundDigest);
|
|
981
|
+
if (!expected || Buffer.compare(Buffer.from(encodeRoomMessage(decoded)), Buffer.from(encodeRoomMessage(expected))) !== 0) fail("untrusted");
|
|
982
|
+
assertCurrent(current);
|
|
983
|
+
if (this.get(name)?.current.digest !== state.current.digest) fail("state_changed");
|
|
984
|
+
return this.transaction(now => {
|
|
985
|
+
assertCurrent(current);
|
|
986
|
+
if (!this.isCurrent({ room: frame.room, sender: frame.sender, epoch: frame.epoch, rosterDigest: frame.rosterDigest })) fail("state_changed");
|
|
987
|
+
const incoming = this.applicationFor(state.current.room, inboundDigest);
|
|
988
|
+
if (!incoming || !isTerminal(incoming.state) || incoming.sender !== frame.recipients[0]) fail("untrusted");
|
|
989
|
+
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);
|
|
990
|
+
this.db.query("DELETE FROM receipt_outbox WHERE expires_at<=?").run(now);
|
|
991
|
+
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);
|
|
992
|
+
const first = rows[0];
|
|
993
|
+
if (first && rows.some(row => row.expires_at !== first.expires_at)) fail("corrupt");
|
|
994
|
+
const acceptedProof = [...rows].reverse().find(row => row.state === "accepted");
|
|
995
|
+
const same = rows.find(row => row.receipt_digest === frame.digest);
|
|
996
|
+
if (same) {
|
|
997
|
+
if (same.state === "accepted") return { digest: same.receipt_digest, state: "accepted", kind: "receipt", receiptFor: inboundDigest, expiresAt: same.expires_at };
|
|
998
|
+
if (!same.wire || Buffer.compare(Buffer.from(wire), Buffer.from(same.wire))) fail("state_changed");
|
|
999
|
+
return { digest: same.receipt_digest, wire: copy(same.wire), state: "dispatching", kind: "receipt", receiptFor: inboundDigest, expiresAt: same.expires_at };
|
|
1000
|
+
}
|
|
1001
|
+
const pending = rows.filter(row => row.state === "dispatching" && row.wire);
|
|
1002
|
+
if (rows.length && (!options.replaceExisting || first!.expires_at <= now)) {
|
|
1003
|
+
const selected = pending[pending.length - 1] ?? rows[rows.length - 1]!;
|
|
1004
|
+
if (selected.state === "dispatching") {
|
|
1005
|
+
if (!selected.wire) fail("corrupt");
|
|
1006
|
+
return { digest: selected.receipt_digest, wire: copy(selected.wire), state: "dispatching", kind: "receipt", receiptFor: inboundDigest, expiresAt: selected.expires_at };
|
|
1007
|
+
}
|
|
1008
|
+
return { digest: selected.receipt_digest, state: "accepted", kind: "receipt", receiptFor: inboundDigest, expiresAt: selected.expires_at };
|
|
1009
|
+
}
|
|
1010
|
+
if (options.replaceExisting && pending.length) {
|
|
1011
|
+
for (const row of pending) {
|
|
1012
|
+
this.db.query("DELETE FROM outbox WHERE room=? AND digest=?").run(state.current.room, row.receipt_digest);
|
|
1013
|
+
this.db.query("DELETE FROM receipt_outbox WHERE room=? AND receipt_digest=?").run(state.current.room, row.receipt_digest);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
const proofCount = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM receipt_outbox WHERE room=?").get(state.current.room)?.n ?? 0;
|
|
1017
|
+
if (proofCount >= ROOM_APPLICATION_RETENTION.aliasesPerRoom) {
|
|
1018
|
+
if (acceptedProof) return { digest: acceptedProof.receipt_digest, state: "accepted", kind: "receipt", receiptFor: inboundDigest, expiresAt: acceptedProof.expires_at };
|
|
1019
|
+
fail("limit");
|
|
1020
|
+
}
|
|
1021
|
+
const count = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM outbox WHERE room=?").get(state.current.room)?.n ?? 0;
|
|
1022
|
+
const bytes = this.db.query<{ n: number }, []>("SELECT COALESCE(SUM(length(wire)+length(roster)),0) AS n FROM outbox").get()?.n ?? 0;
|
|
1023
|
+
if (count >= ROOM_STORE_LIMITS.outboxPerRoom || bytes + wire.length + state.wire.length > ROOM_STORE_LIMITS.outboxBytes) {
|
|
1024
|
+
if (acceptedProof) return { digest: acceptedProof.receipt_digest, state: "accepted", kind: "receipt", receiptFor: inboundDigest, expiresAt: acceptedProof.expires_at };
|
|
1025
|
+
fail("limit");
|
|
1026
|
+
}
|
|
1027
|
+
const expires = first?.expires_at ?? now + options.ttlMs;
|
|
1028
|
+
if (!Number.isSafeInteger(expires)) fail("invalid");
|
|
1029
|
+
this.db.query("INSERT INTO outbox(room,digest,wire,roster,state) VALUES(?,?,?,?,'dispatching')").run(frame.room, frame.digest, wire, state.wire);
|
|
1030
|
+
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);
|
|
1031
|
+
return { digest: frame.digest, wire: copy(wire), state: "dispatching", kind: "receipt", receiptFor: inboundDigest, expiresAt: expires };
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
retireReceiptFor(name: string, inboundDigest: string): boolean {
|
|
1035
|
+
const state = this.get(name); if (!state || state.left) return false;
|
|
1036
|
+
if (!validDigest(inboundDigest)) fail("invalid");
|
|
1037
|
+
return this.transaction(() => {
|
|
1038
|
+
const receipt = this.db.query("SELECT 1 FROM receipt_outbox WHERE room=? AND inbound_digest=? LIMIT 1").get(state.current.room, inboundDigest);
|
|
1039
|
+
if (!receipt) return false;
|
|
1040
|
+
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);
|
|
1041
|
+
this.db.query("DELETE FROM receipt_outbox WHERE room=? AND inbound_digest=?").run(state.current.room, inboundDigest);
|
|
1042
|
+
return true;
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
/** Broker storage ends this receipt attempt's local replay responsibility. */
|
|
1046
|
+
retireStoredReceipt(name: string, receiptDigest: string): boolean {
|
|
1047
|
+
const state = this.get(name); if (!state || state.left) return false;
|
|
1048
|
+
if (!validDigest(receiptDigest)) fail("invalid");
|
|
1049
|
+
return this.transaction(() => {
|
|
1050
|
+
const row = this.db.query("SELECT 1 FROM receipt_outbox WHERE room=? AND receipt_digest=? AND state='accepted'").get(state.current.room, receiptDigest);
|
|
1051
|
+
if (!row) return false;
|
|
1052
|
+
this.db.query("DELETE FROM outbox WHERE room=? AND digest=?").run(state.current.room, receiptDigest);
|
|
1053
|
+
this.db.query("DELETE FROM receipt_outbox WHERE room=? AND receipt_digest=?").run(state.current.room, receiptDigest);
|
|
1054
|
+
return true;
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
expireReceipts(name: string): string[] {
|
|
1058
|
+
const state = this.get(name); if (!state || state.left) return [];
|
|
1059
|
+
return this.transaction(now => {
|
|
1060
|
+
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);
|
|
1061
|
+
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);
|
|
1062
|
+
this.db.query("DELETE FROM receipt_outbox WHERE room=? AND expires_at<=?").run(state.current.room, now);
|
|
1063
|
+
return rows.map(row => row.inbound_digest);
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
/** Recheck retained intent immediately before a delayed transport send. */
|
|
1067
|
+
applicationSendable(name: string, frameDigest: string): boolean {
|
|
1068
|
+
const state = this.get(name); if (!state || state.left || !validDigest(frameDigest)) return false;
|
|
1069
|
+
const now = this.now();
|
|
1070
|
+
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>?")
|
|
1071
|
+
.get(state.current.room, frameDigest, now);
|
|
1072
|
+
}
|
|
1073
|
+
async outgoingMessage(name: string, application: string, current?: () => boolean): Promise<RoomMessage | undefined> {
|
|
1074
|
+
if (!validUuid(application)) fail("invalid");
|
|
1075
|
+
assertCurrent(current);
|
|
1076
|
+
const state = this.applicationRoom(name, current), now = this.now();
|
|
1077
|
+
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);
|
|
1078
|
+
if (!row || row.disposition !== null || row.state !== "dispatching" && row.state !== "accepted" || now >= row.expires_at) return undefined;
|
|
1079
|
+
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);
|
|
1080
|
+
if (!sealed) return undefined;
|
|
1081
|
+
const frame = decodeFrame(sealed.wire), installed = decodeRoster(state.wire);
|
|
1082
|
+
if (await digest(sealed.wire) !== sealed.digest || frame.room !== state.current.room || frame.sender !== this.identity.public.fingerprint
|
|
1083
|
+
|| frame.recipients.length !== 1 || frame.recipients[0] !== row.recipient) fail("corrupt");
|
|
1084
|
+
const selected = frame.epoch === state.current.epoch ? state.current
|
|
1085
|
+
: state.previous && now < state.previous.expiresAt && frame.epoch === state.previous.epoch.epoch ? state.previous.epoch : undefined;
|
|
1086
|
+
if (!selected) fail("state_changed");
|
|
1087
|
+
const epoch = await acceptRoomEpoch({ wire: sealed.roster, identity: this.identity, pin: { room: state.current.room, owner: installed.owner, minEpoch: selected.epoch, digest: selected.digest } });
|
|
1088
|
+
if (epoch.epoch !== selected.epoch || epoch.digest !== selected.digest) fail("state_changed");
|
|
1089
|
+
const plaintext = await openOwnFrame({ wire: sealed.wire, identity: this.identity, epoch });
|
|
1090
|
+
let message: RoomMessage; try { message = decodeRoomMessage(plaintext); } catch { fail("corrupt"); }
|
|
1091
|
+
if (message.type !== "chat" || message.envelope.id !== row.application || message.envelope.to !== row.peer) fail("corrupt");
|
|
1092
|
+
if (row.content_hash !== null && await digest(encodeRoomMessage(message)) !== row.content_hash) fail("corrupt");
|
|
1093
|
+
assertCurrent(current);
|
|
1094
|
+
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);
|
|
1095
|
+
if (!latest || latest.left || latest.current.digest !== state.current.digest || !latestRow
|
|
1096
|
+
|| latestRow.disposition !== null || latestRow.state !== "dispatching" && latestRow.state !== "accepted" || this.now() >= latestRow.expires_at
|
|
1097
|
+
|| !this.db.query("SELECT 1 FROM outbox WHERE room=? AND digest=?").get(row.room, sealed.digest)) return undefined;
|
|
1098
|
+
return message;
|
|
777
1099
|
}
|
|
778
1100
|
applicationOutbox(name: string, activeOnly = false): RoomOutgoing[] {
|
|
779
1101
|
const state = this.get(name); if (!state) return [];
|
|
780
1102
|
const filter = activeOnly ? " AND state IN ('dispatching','accepted','needs_review') AND disposition IS NULL" : "";
|
|
781
1103
|
return this.db.query<OutgoingRow, [string]>(`SELECT * FROM application_outbox WHERE room=?${filter} ORDER BY rowid`).all(state.current.room).map(outgoing);
|
|
782
1104
|
}
|
|
783
|
-
/**
|
|
1105
|
+
/** Compatibility no-op for retryable rows; only original TTL expiry creates needs_review. */
|
|
784
1106
|
quarantineOutgoing(name: string, deliveryId: string): RoomOutgoing | undefined {
|
|
785
1107
|
const state = this.get(name); if (!state) return undefined;
|
|
786
1108
|
if (!boundedId(deliveryId)) fail("invalid");
|
|
787
|
-
return this.transaction(
|
|
1109
|
+
return this.transaction(now => {
|
|
788
1110
|
const row = this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND delivery_id=?").get(state.current.room, deliveryId);
|
|
789
1111
|
if (!row) return undefined;
|
|
790
1112
|
if (row.state !== "dispatching" && row.state !== "accepted") return outgoing(row);
|
|
791
|
-
|
|
1113
|
+
if (now < row.expires_at) return outgoing(row);
|
|
1114
|
+
this.db.query("UPDATE application_outbox SET state='needs_review' WHERE room=? AND delivery_id=?").run(row.room, row.delivery_id);
|
|
792
1115
|
return outgoing({ ...row, state: "needs_review" });
|
|
793
1116
|
});
|
|
794
1117
|
}
|
|
1118
|
+
quarantineOutgoingPermanent(name: string, deliveryId: string, reason: (typeof PERMANENT_OUTGOING_REFUSALS)[number] = "boundary_refused", current?: () => boolean): RoomOutgoing | undefined {
|
|
1119
|
+
assertCurrent(current);
|
|
1120
|
+
const state = this.get(name); if (!state) return undefined;
|
|
1121
|
+
if (!boundedId(deliveryId) || !PERMANENT_OUTGOING_REFUSALS.includes(reason)) fail("invalid");
|
|
1122
|
+
return this.transaction(() => {
|
|
1123
|
+
assertCurrent(current);
|
|
1124
|
+
const row = this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND delivery_id=?").get(state.current.room, deliveryId);
|
|
1125
|
+
if (!row) return undefined;
|
|
1126
|
+
if (row.state !== "dispatching" && row.state !== "accepted") return outgoing(row);
|
|
1127
|
+
this.db.query("UPDATE application_outbox SET state='needs_review',reason=? WHERE room=? AND delivery_id=?").run(reason, row.room, row.delivery_id);
|
|
1128
|
+
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);
|
|
1129
|
+
return outgoing({ ...row, state: "needs_review", reason });
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
dueApplicationRetries(name: string): RoomOutgoing[] {
|
|
1133
|
+
const state = this.applicationRoom(name);
|
|
1134
|
+
return this.transaction(now => {
|
|
1135
|
+
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")
|
|
1136
|
+
.all(state.current.room, now, now);
|
|
1137
|
+
for (const row of rows) {
|
|
1138
|
+
const next = Math.min(now + row.ack_timeout, row.expires_at);
|
|
1139
|
+
this.db.query("UPDATE application_outbox SET ack_deadline=? WHERE room=? AND delivery_id=?").run(next, row.room, row.delivery_id);
|
|
1140
|
+
}
|
|
1141
|
+
return rows.map(row => outgoing({ ...row, ack_deadline: Math.min(now + row.ack_timeout, row.expires_at) }));
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
795
1144
|
markApplicationStored(name: string, digestValue: string, seq: string): RoomOutgoing | undefined {
|
|
796
1145
|
const state = this.applicationRoom(name);
|
|
797
1146
|
return this.transaction(now => {
|
|
798
|
-
const
|
|
1147
|
+
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);
|
|
1148
|
+
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
1149
|
const sealed = this.db.query<{ wire: Uint8Array }, [string]>("SELECT wire FROM outbox WHERE digest=?").get(digestValue);
|
|
800
1150
|
if (!row || !sealed || decodeFrame(sealed.wire).seq !== seq) return undefined;
|
|
801
1151
|
if (row.state !== "dispatching" && row.state !== "accepted") return outgoing(row);
|
|
802
1152
|
if (!Number.isSafeInteger(row.ack_deadline)) fail("corrupt");
|
|
803
|
-
if (now >= row.
|
|
804
|
-
this.db.query("UPDATE application_outbox SET state='needs_review' WHERE
|
|
1153
|
+
if (now >= row.expires_at) {
|
|
1154
|
+
this.db.query("UPDATE application_outbox SET state='needs_review' WHERE room=? AND delivery_id=?").run(row.room, row.delivery_id);
|
|
805
1155
|
return outgoing({ ...row, state: "needs_review" });
|
|
806
1156
|
}
|
|
807
1157
|
if (row.state === "accepted") return outgoing(row);
|
|
808
|
-
this.db.query("UPDATE application_outbox SET state='accepted' WHERE
|
|
1158
|
+
this.db.query("UPDATE application_outbox SET state='accepted' WHERE room=? AND delivery_id=?").run(row.room, row.delivery_id);
|
|
809
1159
|
this.db.query("UPDATE outbox SET state='accepted' WHERE digest=?").run(digestValue);
|
|
810
1160
|
return outgoing({ ...row, state: "accepted" });
|
|
811
1161
|
});
|
|
@@ -826,18 +1176,22 @@ export class RoomStore {
|
|
|
826
1176
|
if (!entry) return undefined;
|
|
827
1177
|
let message: RoomMessage; try { message = decodeRoomMessage(entry.plaintext); } catch { fail("invalid"); }
|
|
828
1178
|
if (message.type !== "receipt") return undefined;
|
|
829
|
-
const
|
|
1179
|
+
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);
|
|
1180
|
+
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
1181
|
if (!row || row.application !== message.application || row.peer !== message.peer || row.recipient !== entry.sender
|
|
831
1182
|
|| !roomMembers(state.current).some(member => member.fingerprint === entry.sender) || !validOutcome(message.outcome, message.reason, row.peer)) return undefined;
|
|
832
1183
|
if (isTerminal(row.state) || row.state === "needs_review" && row.late_outcome !== null) return outgoing(row);
|
|
833
|
-
const expired = now >= row.expires_at
|
|
1184
|
+
const expired = now >= row.expires_at;
|
|
834
1185
|
if (row.state === "needs_review" || expired) {
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
1186
|
+
const settledLate = row.disposition === null && row.reason === null;
|
|
1187
|
+
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=?")
|
|
1188
|
+
.run(settledLate, message.outcome, settledLate, message.reason, message.outcome, message.reason, receiptFrameDigest, row.room, row.delivery_id);
|
|
1189
|
+
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);
|
|
1190
|
+
this.pruneOutgoingHistory(state.current.room);
|
|
1191
|
+
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
1192
|
}
|
|
839
|
-
this.db.query("UPDATE application_outbox SET state=?,reason=?,receipt_digest=? WHERE
|
|
840
|
-
this.db.query("DELETE FROM outbox WHERE digest=?").run(row.
|
|
1193
|
+
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);
|
|
1194
|
+
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
1195
|
this.pruneOutgoingHistory(state.current.room);
|
|
842
1196
|
return outgoing({ ...row, state: message.outcome, reason: message.reason, receipt_digest: receiptFrameDigest });
|
|
843
1197
|
});
|
|
@@ -845,39 +1199,50 @@ export class RoomStore {
|
|
|
845
1199
|
private pruneOutgoingHistory(room: string): void {
|
|
846
1200
|
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
1201
|
.run(room, room, APPLICATION_HISTORY_CAP);
|
|
1202
|
+
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
1203
|
}
|
|
849
1204
|
/** 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
|
|
1205
|
+
disposeApplication(name: string, deliveryId: string, action: "completed" | "retry" | "discard", authoritative: true, current: () => boolean): RoomOutgoing | undefined {
|
|
851
1206
|
assertCurrent(current);
|
|
852
1207
|
const state = this.get(name); if (!state) return undefined;
|
|
1208
|
+
if (authoritative !== true) fail("untrusted");
|
|
853
1209
|
if (!boundedId(deliveryId) || !["completed", "retry", "discard"].includes(action)) fail("invalid");
|
|
854
1210
|
return this.transaction(() => {
|
|
855
1211
|
assertCurrent(current);
|
|
856
1212
|
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 ||
|
|
1213
|
+
if (!row || !["dispatching", "accepted", "needs_review", "completed", "failed_safe"].includes(row.state)) return undefined;
|
|
858
1214
|
if (row.disposition !== null) { if (row.disposition !== action) fail("state_changed"); return outgoing(row); }
|
|
859
|
-
|
|
860
|
-
this.db.query("
|
|
1215
|
+
const nextState = row.state === "dispatching" || row.state === "accepted" ? "needs_review" : row.state;
|
|
1216
|
+
this.db.query("UPDATE application_outbox SET state=?,disposition=? WHERE room=? AND delivery_id=?").run(nextState, action, row.room, row.delivery_id);
|
|
1217
|
+
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
1218
|
this.pruneOutgoingHistory(state.current.room);
|
|
862
|
-
return outgoing({ ...row, disposition: action });
|
|
1219
|
+
return outgoing({ ...row, state: nextState, disposition: action });
|
|
863
1220
|
});
|
|
864
1221
|
}
|
|
865
1222
|
expireApplications(name: string): RoomOutgoing[] {
|
|
866
1223
|
const state = this.applicationRoom(name);
|
|
867
1224
|
return this.transaction(now => {
|
|
868
|
-
const rows = this.db.query<OutgoingRow, [string, number
|
|
869
|
-
for (const row of rows)
|
|
1225
|
+
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);
|
|
1226
|
+
for (const row of rows) {
|
|
1227
|
+
this.db.query("UPDATE application_outbox SET state='needs_review' WHERE room=? AND delivery_id=?").run(row.room, row.delivery_id);
|
|
1228
|
+
}
|
|
870
1229
|
return rows.map(row => outgoing({ ...row, state: "needs_review" }));
|
|
871
1230
|
});
|
|
872
1231
|
}
|
|
873
1232
|
private applicationFor(room: string, digestValue: string): ApplicationRow | undefined {
|
|
874
1233
|
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
1234
|
}
|
|
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)
|
|
1235
|
+
private personIdentityRetained(row: Pick<ApplicationRow, "room" | "sender" | "application">, now = this.now()): boolean {
|
|
1236
|
+
return !!this.db.query("SELECT 1 FROM person_inbox WHERE room=? AND sender=? AND application=?").get(row.room, row.sender, row.application)
|
|
1237
|
+
|| !!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
1238
|
}
|
|
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)
|
|
1239
|
+
private deleteSettledApplication(row: ApplicationRow, now = this.now()): void {
|
|
1240
|
+
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)
|
|
1241
|
+
|| 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;
|
|
1242
|
+
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=?)")
|
|
1243
|
+
.run(row.room, row.room, row.sender, row.application);
|
|
1244
|
+
this.db.query("DELETE FROM receipt_outbox WHERE room=? AND inbound_digest IN (SELECT digest FROM application_frames WHERE room=? AND sender=? AND application=?)")
|
|
1245
|
+
.run(row.room, row.room, row.sender, row.application);
|
|
881
1246
|
this.db.query("DELETE FROM person_inbox WHERE room=? AND sender=? AND application=?").run(row.room, row.sender, row.application);
|
|
882
1247
|
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
1248
|
.run(row.room, row.room, row.sender, row.application);
|
|
@@ -888,16 +1253,17 @@ export class RoomStore {
|
|
|
888
1253
|
this.prunePersonInbox(now);
|
|
889
1254
|
// Person stubs have their own disposition/expiry horizon. Only the cap walk
|
|
890
1255
|
// 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);
|
|
1256
|
+
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);
|
|
1257
|
+
for (const row of expired) this.deleteSettledApplication(row, now);
|
|
893
1258
|
for (;;) {
|
|
894
1259
|
const identities = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM applications WHERE room=?").get(room)?.n ?? 0;
|
|
895
1260
|
const aliases = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM application_frames WHERE room=?").get(room)?.n ?? 0;
|
|
896
1261
|
if ((!freshIdentity || identities < ROOM_APPLICATION_RETENTION.identitiesPerRoom) && aliases < ROOM_APPLICATION_RETENTION.aliasesPerRoom) return;
|
|
1262
|
+
// 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
1263
|
// 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 ?? "");
|
|
1264
|
+
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
1265
|
if (!settled) throw new RoomApplicationLimitError(room, Math.max(identities, aliases));
|
|
900
|
-
this.deleteSettledApplication(settled);
|
|
1266
|
+
this.deleteSettledApplication(settled, now);
|
|
901
1267
|
}
|
|
902
1268
|
}
|
|
903
1269
|
async applicationIncoming(name: string, digestValue: string, current?: () => boolean): Promise<{ sender: string; chat: RoomChatEnvelope; contentHash: string; primary: boolean; state: ApplicationState }> {
|
|
@@ -918,7 +1284,7 @@ export class RoomStore {
|
|
|
918
1284
|
let saved = this.db.query<ApplicationRow, [string, string, string]>("SELECT * FROM applications WHERE room=? AND sender=? AND application=?").get(...key);
|
|
919
1285
|
if (saved && saved.settled_at !== null && saved.settled_at <= now - ROOM_APPLICATION_RETENTION.settledMs && isSettledApplication(saved.state)) {
|
|
920
1286
|
this.prunePersonInbox(now);
|
|
921
|
-
if (!this.
|
|
1287
|
+
if (!this.personIdentityRetained(saved)) { this.deleteSettledApplication(saved); saved = null; }
|
|
922
1288
|
}
|
|
923
1289
|
if (saved && (saved.content_hash !== contentHash || saved.peer !== chat.to)) fail("invalid");
|
|
924
1290
|
const alias = this.applicationFor(state.current.room, digestValue);
|
|
@@ -1152,8 +1518,14 @@ export class RoomStore {
|
|
|
1152
1518
|
return row.hop!;
|
|
1153
1519
|
}
|
|
1154
1520
|
private recoverApplications(): void {
|
|
1155
|
-
this.transaction(
|
|
1156
|
-
this.db.query("UPDATE application_outbox SET state='
|
|
1521
|
+
this.transaction(now => {
|
|
1522
|
+
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);
|
|
1523
|
+
this.db.query("UPDATE application_outbox SET state='needs_review' WHERE state IN ('dispatching','accepted') AND expires_at<=?").run(now);
|
|
1524
|
+
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();
|
|
1525
|
+
for (const row of lateRows) if (this.lateReceiptProof(row) === "valid") {
|
|
1526
|
+
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);
|
|
1527
|
+
}
|
|
1528
|
+
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
1529
|
this.db.query("UPDATE applications SET state='needs_review' WHERE state='dispatching'").run();
|
|
1158
1530
|
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
1531
|
for (const state of this.rooms.values()) this.pruneInbox(state.current.room);
|