realtimeReceipts.ts 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
  1. import { createHash } from 'node:crypto'
  2. import { chmodSync, mkdirSync } from 'node:fs'
  3. import { join } from 'node:path'
  4. import { DatabaseSync } from 'node:sqlite'
  5. import { HttpError } from './errors.js'
  6. export interface CommandReceipt {
  7. requestId: string
  8. state: 'pending' | 'confirmed' | 'rejected' | 'unknown'
  9. response?: Record<string, unknown>
  10. }
  11. /** Write-ahead admission, not an event/transcript store. Never stores command bodies. */
  12. export class RealtimeReceipts {
  13. private db: DatabaseSync
  14. constructor(home: string) {
  15. mkdirSync(home, { recursive: true, mode: 0o700 })
  16. const path = join(home, 'realtime-receipts.sqlite3')
  17. this.db = new DatabaseSync(path)
  18. chmodSync(path, 0o600)
  19. this.db.exec(`PRAGMA journal_mode=WAL;
  20. CREATE TABLE IF NOT EXISTS receipts (
  21. owner TEXT NOT NULL, id TEXT NOT NULL, fingerprint TEXT NOT NULL,
  22. state TEXT NOT NULL, response TEXT, updated INTEGER NOT NULL,
  23. PRIMARY KEY(owner,id));
  24. CREATE INDEX IF NOT EXISTS receipt_retention ON receipts(state,updated);
  25. UPDATE receipts SET state='unknown' WHERE state='pending';`)
  26. }
  27. lookup(owner: string, id: string): CommandReceipt | undefined {
  28. const row = this.db.prepare('SELECT state,response FROM receipts WHERE owner=? AND id=?').get(owner, id)
  29. if (!row) return undefined
  30. return { requestId: id, state: row.state as CommandReceipt['state'],
  31. ...(row.response ? { response: JSON.parse(String(row.response)) as Record<string, unknown> } : {}) }
  32. }
  33. reserve(owner: string, id: string, frame: string): CommandReceipt | undefined {
  34. if (!/^[A-Za-z0-9:_-]{1,200}$/.test(id)) throw new HttpError(400, 'Invalid request ID', 'invalid_request_id')
  35. const fingerprint = createHash('sha256').update(frame).digest('hex')
  36. const row = this.db.prepare('SELECT fingerprint FROM receipts WHERE owner=? AND id=?').get(owner, id)
  37. if (row) {
  38. if (row.fingerprint !== fingerprint) throw new HttpError(409, 'Request ID payload conflict', 'idempotency_conflict')
  39. return this.lookup(owner, id)
  40. }
  41. this.db.prepare("DELETE FROM receipts WHERE updated<? AND state IN ('confirmed','rejected')").run(Date.now() - 86_400_000)
  42. if (Number(this.db.prepare('SELECT count(*) AS n FROM receipts').get()!.n) >= 100_000) {
  43. throw new HttpError(429, 'Command receipt capacity reached', 'receipt_capacity')
  44. }
  45. this.db.prepare('INSERT INTO receipts VALUES(?,?,?,?,NULL,?)').run(owner, id, fingerprint, 'pending', Date.now())
  46. return undefined
  47. }
  48. finish(owner: string, id: string, state: CommandReceipt['state'], response?: Record<string, unknown>): void {
  49. // Persist only control-plane acknowledgements, never prompt, attachment or history content.
  50. const result = response?.result as Record<string, unknown> | undefined
  51. const safe: Record<string, unknown> = {}
  52. for (const key of ['status', 'session_id', 'stored_session_id', 'session_key', 'queued', 'running', 'ok']) {
  53. if (result && ['string', 'number', 'boolean'].includes(typeof result[key])) safe[key] = result[key]
  54. }
  55. const saved = response?.error ? { error: { message: 'Upstream rejected command' } }
  56. : response ? { result: safe } : undefined
  57. this.db.prepare('UPDATE receipts SET state=?,response=?,updated=? WHERE owner=? AND id=?')
  58. .run(state, saved ? JSON.stringify(saved) : null, Date.now(), owner, id)
  59. }
  60. close(): void { this.db.close() }
  61. }