From 169be7662e27af53611d563991b309fb38635693 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 5 Oct 2026 20:30:58 -0700 Subject: [PATCH] Index speech receipts and verify account-data cleanup --- system/backend/whistlegraph-speech-billing.mjs | 5 +++++ .../functions/whistlegraph-transcribe.mjs | 3 ++- system/tests/account-deletion.test.mjs | 16 ++++++++++++++++ system/tests/whistlegraph-speech.test.mjs | 3 ++- 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/system/backend/whistlegraph-speech-billing.mjs b/system/backend/whistlegraph-speech-billing.mjs index ea8d439120..a979066a7e 100644 --- a/system/backend/whistlegraph-speech-billing.mjs +++ b/system/backend/whistlegraph-speech-billing.mjs @@ -10,6 +10,11 @@ export const speechRequestKey=(user,id)=>createHash('sha256').update(user+'\0'+i const walletIn=(db,session)=>({ updateOne:(filter,update,options={})=>db.collection('ac-credit-wallets').updateOne(filter,update,{...options,session}), }); +export async function ensureSpeechBillingIndexes(db) { + const receipts=db.collection('whistlegraph-speech-requests'); + await receipts.createIndex({status:1,expiresAt:1},{name:'pending_recovery'}); + await receipts.createIndex({user:1},{name:'account_receipts'}); +} export function speechBilling(db,{now=()=>new Date(),limit=DAILY_TOKEN_BUDGET}={}) { const receipts=db.collection('whistlegraph-speech-requests'),usage=db.collection('ai-usage'); async function transaction(fn) { diff --git a/system/netlify/functions/whistlegraph-transcribe.mjs b/system/netlify/functions/whistlegraph-transcribe.mjs index 5db863258d..11a26ec65e 100644 --- a/system/netlify/functions/whistlegraph-transcribe.mjs +++ b/system/netlify/functions/whistlegraph-transcribe.mjs @@ -1,6 +1,6 @@ import {createHash} from 'node:crypto'; import {wavInput,createTranscriber} from '../../backend/whistlegraph-whisper.mjs'; -import {speechBilling,speechRequestKey} from '../../backend/whistlegraph-speech-billing.mjs'; +import {speechBilling,speechRequestKey,ensureSpeechBillingIndexes} from '../../backend/whistlegraph-speech-billing.mjs'; const reply=(statusCode,value)=>({statusCode,headers:{'Content-Type':'application/json','Cache-Control':'private, no-store','Access-Control-Allow-Origin':'*','Access-Control-Allow-Methods':'POST, OPTIONS','Access-Control-Allow-Headers':'Authorization,Content-Type'},body:JSON.stringify(value)}); export function createHandler({authorize,getHandleOrEmail,billing,transcribe,enabled=true,now=Date.now}) { // Retry results live only in memory for one minute. Durable receipts contain @@ -46,6 +46,7 @@ export async function handler(event) { if(!live)live=(async()=>{ const [{authorize,getHandleOrEmail},{connect}]=await Promise.all([import('../../backend/authorization.mjs'),import('../../backend/database.mjs')]); const {db}=await connect(),key=process.env.WHISTLEGRAPH_TRANSCRIPTION_KEY; + await ensureSpeechBillingIndexes(db); return createHandler({authorize,getHandleOrEmail,billing:speechBilling(db),transcribe:createTranscriber({apiKey:key}),enabled:!!key}); })().catch(error=>{live=null;throw error;}); try{return await(await live)(event);}catch{return reply(503,{error:'OpenAI speech unavailable'});} diff --git a/system/tests/account-deletion.test.mjs b/system/tests/account-deletion.test.mjs index d9974931bc..4e98daa623 100644 --- a/system/tests/account-deletion.test.mjs +++ b/system/tests/account-deletion.test.mjs @@ -718,3 +718,19 @@ test("the export is a signed-in download", async () => { assert.match(response.headers["Content-Disposition"], /attachment/); assert.deepEqual(e.calls, ["export", "disconnect"]); }); + + +test("speech billing receipts export without audio digests and disappear with the account", async () => { + const seed = world(); + seed["whistlegraph-speech-requests"] = [ + {_id:"speech-mine",user:SUB,braincells:320,charged:320,status:"complete",hash:"private-audio-digest",startedAt:T0}, + {_id:"speech-other",user:OTHER,braincells:40,status:"complete"}, + ]; + const db=fakeDb(seed),deps=fakeDeps(db); + const copy=await exportAccount(deps,{user,now:T0}); + assert.deepEqual(copy.records["whistlegraph-speech-requests"],[{_id:"speech-mine",braincells:320,charged:320,status:"complete",startedAt:T0}]); + assert.ok(!JSON.stringify(copy).includes("private-audio-digest")); + await requestDeletion(deps,{user,now:T0}); + await runDueDeletions(deps,{now:later(GRACE_MS)}); + assert.deepEqual(db.all("whistlegraph-speech-requests"),[seed["whistlegraph-speech-requests"][1]]); +}); diff --git a/system/tests/whistlegraph-speech.test.mjs b/system/tests/whistlegraph-speech.test.mjs index d74494fceb..96adaaaa1f 100644 --- a/system/tests/whistlegraph-speech.test.mjs +++ b/system/tests/whistlegraph-speech.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {randomUUID} from 'node:crypto'; -import {speechCost,speechBilling} from '../backend/whistlegraph-speech-billing.mjs'; +import {speechCost,speechBilling,ensureSpeechBillingIndexes} from '../backend/whistlegraph-speech-billing.mjs'; import {wavInput,createTranscriber} from '../backend/whistlegraph-whisper.mjs'; import {createHandler} from '../netlify/functions/whistlegraph-transcribe.mjs'; function wav(ms=1000) { @@ -45,6 +45,7 @@ test('Mongo transactions fence concurrent spending, replay, failure and crash re const request=(user='test-user')=>({user,handle:user,requestId:randomUUID(),audio:wav(),durationMs:1000}); try { // Materialize the three isolated collections before transactional DDL. + await ensureSpeechBillingIndexes(isolated); for(const name of ['ai-usage','ac-credit-wallets','whistlegraph-speech-requests'])await isolated.collection(name).insertOne({_id:'fixture'}); const wallets=isolated.collection('ac-credit-wallets'),usage=isolated.collection('ai-usage'),receipts=isolated.collection('whistlegraph-speech-requests'); await wallets.insertOne({_id:'test-user',balance:100}); -- 2.51.2