// GET /api/offline/status - size, freshness and retention policy, for Settings. // PUT /api/offline/status - update the retention policy. // DELETE /api/offline/status - purge the replica. // // The POLICY LIVES IN THE ENCRYPTED STORE, not in renderer localStorage. The // design review's H1 was that a server-side engine cannot read a renderer-only // setting; keeping the policy server-side means the retention pass always has the // value it needs, while the DECISION TO SYNC AT ALL stays with the renderer, so // nothing is ever materialised for an account that never opted in. // // Like every read here, GET makes no network call: an offline user must still be // able to see what they have and free the space. import { NextRequest, NextResponse } from 'next/server'; import { clampPolicy, POLICY_LIMITS, type RetentionPolicy } from '@/lib/offline-replica/store'; import { resolveIndexSession, resolveReadAccountId, withReplica } from '@/lib/offline-replica/engine'; import { gateReplicaRoute, NO_STORE, replicaErrorResponse } from '@/lib/offline-replica/route-gate'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; export async function GET(request: NextRequest) { const gated = gateReplicaRoute(); if (gated) return gated; try { const session = await resolveIndexSession(request); const payload = await withReplica(session.accountId, (store) => { const jmapAccountId = resolveReadAccountId(store, null); const policy = store.getPolicy(); const flags = store.getFlags(Date.now()); if (!jmapAccountId) { return { policy, limits: POLICY_LIMITS, synced: false, stats: null, coveragePhase: 'never-run', resyncRequired: flags.resyncRequired, lastCycleAt: flags.lastCycleAt ?? null, lastCycleOk: flags.lastCycleOk ?? null, }; } return { policy, limits: POLICY_LIMITS, synced: true, stats: store.stats(jmapAccountId), coveragePhase: store.getCoverage(jmapAccountId)?.phase ?? 'never-run', coveredFrom: store.getCoverage(jmapAccountId)?.coveredFrom ?? null, resyncRequired: flags.resyncRequired, lastCycleAt: flags.lastCycleAt ?? null, lastCycleOk: flags.lastCycleOk ?? null, lastCycleError: flags.lastCycleError ?? null, }; }); return NextResponse.json({ ok: true, ...payload }, { headers: NO_STORE }); } catch (error) { return replicaErrorResponse(error, 'offline status'); } } export async function PUT(request: NextRequest) { const gated = gateReplicaRoute(); if (gated) return gated; let body: Record = {}; try { const text = await request.text(); if (text.trim()) body = JSON.parse(text) as Record; } catch { return NextResponse.json({ error: 'Malformed JSON body' }, { status: 400 }); } const policy = clampPolicy(body as Partial); try { const session = await resolveIndexSession(request); await withReplica(session.accountId, (store) => { store.transaction(() => { store.setPolicy(policy); }); }); // The cycle applies it: a widen re-enters coverage scanning, a narrow evicts, // and the clock guard is told this was INTENT rather than a glitch by the // `lastEnvelopeDays` it compares against. return NextResponse.json({ ok: true, policy }, { headers: NO_STORE }); } catch (error) { return replicaErrorResponse(error, 'offline policy update'); } } export async function DELETE(request: NextRequest) { const gated = gateReplicaRoute(); if (gated) return gated; try { const session = await resolveIndexSession(request); await withReplica(session.accountId, (store) => { // ALL OF IT, cursors included. A record wipe that leaves cursors behind is // the one state no amount of syncing repairs: `/changes` structurally cannot // re-deliver mail that already existed when the cursor was captured, so the // next cycle would advance a live cursor over an empty store forever. store.transaction(() => { store.purgeAll(); }); }); return NextResponse.json({ ok: true, purged: true }, { headers: NO_STORE }); } catch (error) { return replicaErrorResponse(error, 'offline purge'); } }