Files
MikrotikManager/backend/src/services/server-poll-archive-accounting.ts
T

122 lines
9.1 KiB
TypeScript

/** Verify the COMPLETE retained allocation ledger under the caller's existing
* global poll-storage transaction lock. No payload hydration or new pool. */
import type { ProbeStorageSql } from "./server-probe-journal-maintenance.js"
import { SERVER_POLL_STORAGE_POLICY as policy, PollStorageRefused } from "./server-poll-storage-model.js"
import { createServerPollArchiveChargeVerifier, type verifyServerPollArchiveCharge } from "./server-poll-terminal-archive.js"
import type { createServerPollRetiredArchiveChargeVerifier } from "./server-poll-retired-archive.js"
const BATCH = 128
const MAX_ENTRIES = Math.floor(policy.globalBytes / policy.admissionBytes)
const fail: () => never = () => { throw new PollStorageRefused() }
const uuid = /^[a-f\d]{8}-[a-f\d]{4}-[1-8][a-f\d]{3}-[89ab][a-f\d]{3}-[a-f\d]{12}$/i
function integer(value: unknown): number {
if (typeof value === "string" && /^(?:0|[1-9]\d{0,15})$/.test(value)) value = Number(value)
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || Object.is(value, -0)) return fail()
return value
}
interface Total { heavy: number; compact: number; archiveBytes: number; rows: number; bytes: number }
const zero = (): Total => ({ heavy: 0, compact: 0, archiveBytes: 0, rows: 0, bytes: 0 })
const createRetiredChargeVerifier: typeof createServerPollRetiredArchiveChargeVerifier = async () =>
(await import("./server-poll-retired-archive.js")).createServerPollRetiredArchiveChargeVerifier()
const QUERY = `SELECT a.receipt_id AS admission_receipt_id,a.identity_kind,a.execution_id AS admission_execution_id,
a.connection_id AS admission_connection_id,a.owner_id AS admission_owner_id,a.binding_proof AS admission_binding_proof,
a.context_proof AS admission_context_proof,a.storage_state,a.compact_at,a.archive_bytes AS admission_archive_bytes,
to_char(a.original_activity_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS admission_activity_at,
to_char(a.authority_expires_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS admission_expires_at,
a.retention_days,a.safety_ms,r.receipt_id,r.execution_id,r.connection_id,r.owner_id,r.version,r.binding_proof,r.context_proof,
r.source_digest,r.source_sha256,r.terminal_digest,r.decoded_bytes,r.compressed_bytes,r.archive_proof,
to_char(r.original_activity_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS original_activity_at,
to_char(r.original_expires_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS original_expires_at,
r.ciphertext_bytes AS asserted_ciphertext_bytes,octet_length(r.ciphertext) AS ciphertext_bytes,
pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(r.ciphertext,'UTF8')),'hex') AS ciphertext_sha256,
(EXISTS(SELECT 1 FROM server_poll_executions e WHERE e.receipt_id=a.receipt_id)
OR EXISTS(SELECT 1 FROM server_poll_intents i WHERE i.receipt_id=a.receipt_id)
OR EXISTS(SELECT 1 FROM server_poll_acks k WHERE k.receipt_id=a.receipt_id)
OR EXISTS(SELECT 1 FROM server_poll_receipts t WHERE t.receipt_id=a.receipt_id)
OR EXISTS(SELECT 1 FROM server_poll_quarantines q WHERE q.receipt_id=a.receipt_id)) AS has_children
FROM server_poll_storage_admissions a LEFT JOIN server_poll_archives r ON r.receipt_id=a.receipt_id
WHERE ($1::uuid IS NULL OR a.receipt_id>$1::uuid) ORDER BY a.receipt_id LIMIT 128`
async function verify(sql: ProbeStorageSql, factory: () => Promise<typeof verifyServerPollArchiveCharge>,
retiredFactory: typeof createServerPollRetiredArchiveChargeVerifier) {
const global = zero(), owners = new Map<string, Total>()
const seenReceipts = new Set<string>()
let cursor: string | null = null, count = 0, charge: typeof verifyServerPollArchiveCharge | undefined
let retiredCharge: Awaited<ReturnType<typeof retiredFactory>> | undefined
// All queries are fixed. Only metadata, hashes and actual lengths cross the
// driver boundary; even a full global history never becomes a JS blob.
for (;;) {
const rows: Record<string, unknown>[] = (await sql.query(QUERY, [cursor])).rows
if (!Array.isArray(rows) || rows.length > BATCH) fail()
for (const raw of rows) {
const row: Record<string, unknown> = structuredClone(raw)
if (++count > MAX_ENTRIES || typeof row.admission_receipt_id !== "string" || !uuid.test(row.admission_receipt_id) ||
seenReceipts.has(row.admission_receipt_id) || typeof row.admission_owner_id !== "string" || !row.admission_owner_id.length ||
row.admission_owner_id.length > 200 || row.admission_owner_id.trim() !== row.admission_owner_id || /[\x00-\x1f\x7f]/.test(row.admission_owner_id)) fail()
cursor = row.admission_receipt_id
seenReceipts.add(cursor)
let addition: Total
if (row.storage_state === "full") {
if (row.receipt_id !== null || row.compact_at !== null || row.admission_archive_bytes !== null || row.admission_activity_at !== null) fail()
addition = { heavy: 1, compact: 0, archiveBytes: 0, rows: policy.attemptRows, bytes: policy.attemptBytes + policy.admissionBytes }
} else if (row.storage_state === "compacted") {
if (row.identity_kind !== "mcp" || row.has_children !== false || !(typeof row.compact_at === "string" || row.compact_at instanceof Date) ||
!Number.isFinite(new Date(row.compact_at).getTime()) || row.receipt_id !== row.admission_receipt_id ||
row.execution_id !== row.admission_execution_id || row.connection_id !== row.admission_connection_id || row.owner_id !== row.admission_owner_id ||
row.binding_proof !== row.admission_binding_proof || row.context_proof !== row.admission_context_proof ||
row.original_activity_at !== row.admission_activity_at || row.original_expires_at !== row.admission_expires_at ||
integer(row.ciphertext_bytes) !== integer(row.asserted_ciphertext_bytes) || integer(row.ciphertext_bytes) !== integer(row.admission_archive_bytes)) fail()
let verified
if (row.version === 1) {
charge ??= await factory()
verified = await charge(row)
} else if (row.version === 2) {
retiredCharge ??= await retiredFactory()
verified = await retiredCharge(row)
} else return fail()
addition = { heavy: 0, compact: 1, archiveBytes: verified.metadata.ciphertext_bytes, rows: verified.rows, bytes: verified.bytes }
} else return fail()
const owned = owners.get(row.admission_owner_id) ?? zero()
owners.set(row.admission_owner_id, owned)
for (const total of [global, owned]) for (const key of Object.keys(total) as (keyof Total)[]) total[key] += addition[key]
if (global.heavy > policy.globalAttempts || global.bytes > policy.globalBytes || global.rows > policy.globalRows ||
owned.heavy > policy.ownerAttempts || owned.bytes > policy.ownerBytes || owned.rows > policy.ownerRows) fail()
}
if (rows.length < BATCH) break
}
// Orphan archives cannot disappear from the allocation scan and manufacture
// credit. Admission identity is retained until the original retention GC.
if ((await sql.query("SELECT r.receipt_id FROM server_poll_archives r LEFT JOIN server_poll_storage_admissions a USING(receipt_id) WHERE a.receipt_id IS NULL LIMIT 1")).rows.length) fail()
let scopeCursor: string | null = null, seenGlobal = false, usageCount = 0
const seenOwners = new Set<string>()
for (;;) {
const rows: Record<string, unknown>[] = (await sql.query("SELECT scope,admissions,compacted_admissions,archive_bytes,reserved_rows,reserved_bytes FROM server_poll_storage_usage WHERE ($1::text IS NULL OR scope>$1) ORDER BY scope LIMIT 128 FOR SHARE", [scopeCursor])).rows
if (!Array.isArray(rows) || rows.length > BATCH) fail()
for (const row of rows) {
if (++usageCount > MAX_ENTRIES + 1 || typeof row.scope !== "string" || row.scope === scopeCursor) fail()
scopeCursor = row.scope
let expected: Total
if (row.scope === "global") { if (seenGlobal) fail(); seenGlobal = true; expected = global }
else {
if (!row.scope.startsWith("owner:") || row.scope.length < 7 || row.scope.length > 206) fail()
const id = row.scope.slice(6)
if (seenOwners.has(id)) fail()
seenOwners.add(id); expected = owners.get(id) ?? zero()
}
if (integer(row.admissions) !== expected.heavy || integer(row.compacted_admissions) !== expected.compact || integer(row.archive_bytes) !== expected.archiveBytes ||
integer(row.reserved_rows) !== expected.rows || integer(row.reserved_bytes) !== expected.bytes) fail()
}
if (rows.length < BATCH) break
}
if (!seenGlobal || [...owners.keys()].some(id => !seenOwners.has(id))) fail()
}
/** Caller must already own server-poll-storage-v1. Any malformed/missing MAC or
* counter refuses admission before an INSERT, credential read or native I/O. */
export async function assertServerPollArchiveAccounting(sql: ProbeStorageSql): Promise<void> {
try { await verify(sql, createServerPollArchiveChargeVerifier, createRetiredChargeVerifier) } catch { return fail() }
}
export async function assertServerPollArchiveAccountingForTests(sql: ProbeStorageSql, factory: () => Promise<typeof verifyServerPollArchiveCharge>,
retiredFactory = createRetiredChargeVerifier): Promise<void> {
try { await verify(sql, factory, retiredFactory) } catch { return fail() }
}