Three layers: a short note at the top, the key lines with our take in the middle, the full source at the bottom.
API route
exports.ts
The data-export route. Pull everything we hold about you in machine-readable form.
Repo path apps/api/src/routes/exports.tsLanguage TypeScript
Short note — more on the way
What this is
The data-export route. Pull everything we hold about you in machine-readable form.
What it proves
This file backs one or more of the privacy promises. It is a HTTP API route that lives versioned in the repository. Read the promise →
What to look for in the source below
- Comments and headers that name what each section does.
- File edges: imports at the top, exports or run-blocks at the bottom.
- Any list, configuration, or assertion that looks load-bearing.
Show the full file (124 lines)
123 lines
import type { Context } from "hono";
import { Hono } from "hono";
import { buildCsvFilename, buildInvoiceCsv } from "@muntin/schema";
import type { Env } from "../env";
import { recordAuditEvent } from "../lib/audit";
import { D1AuditStore } from "../lib/audit-d1";
import { defaultExtractionsStore } from "../lib/extractions-store";
import { requireAuth, type AuthContext } from "../middleware/require-auth";
/**
* H-perf-2 helper: schedule a fire-and-forget promise via the
* Worker's ExecutionContext when one is available, fall back to
* awaiting in test environments. Mirrors the templates.ts helper.
*/
function scheduleOrAwait(
c: Context<{ Bindings: Env; Variables: { auth: AuthContext } }>,
p: Promise<unknown>,
): Promise<void> {
try {
c.executionCtx.waitUntil(p);
return Promise.resolve();
} catch {
return p.then(
() => undefined,
() => undefined,
);
}
}
/**
* /v1/exports/* routes.
*
* Sprint-0 ships /csv backed by a stub extractions store (returns
* empty). Real data wiring lands when Neon Postgres is provisioned
* and `defaultExtractionsStore` returns a NeonExtractionsStore.
*
* The contract (CSV bytes, filename, auth, rate limit, audit event)
* is final at Sprint-0; only the data source changes later.
*/
export const exportsRoutes = new Hono<{
Bindings: Env;
Variables: { auth: AuthContext };
}>();
const CSV_RATE_LIMIT_PER_DAY = 10;
const KV_RATE_PREFIX = "rate:csv:";
function utcDayStamp(d: Date = new Date()): string {
return d.toISOString().slice(0, 10);
}
exportsRoutes.use("*", requireAuth);
exportsRoutes.get("/csv", async (c) => {
const ctx = c.get("auth");
// Rate limit: per-org per-UTC-day, KV-backed with TTL = 1 day.
// The cap is intentionally generous; the goal is to stop runaway
// automation, not to friction-meter a real customer's bookkeeper.
const rateKey = `${KV_RATE_PREFIX}${ctx.org_id}:${utcDayStamp()}`;
const currentRaw = await c.env.AUTH_KV.get(rateKey);
const current = currentRaw ? Number.parseInt(currentRaw, 10) : 0;
if (Number.isFinite(current) && current >= CSV_RATE_LIMIT_PER_DAY) {
c.header("Retry-After", "86400");
return c.json(
{
error: "rate_limited",
detail: `CSV export limit of ${CSV_RATE_LIMIT_PER_DAY} per day reached; try tomorrow`,
},
429,
);
}
await c.env.AUTH_KV.put(rateKey, String((current || 0) + 1), {
expirationTtl: 86_400,
});
// Optional filters (validated minimally; bad inputs are silently
// ignored rather than returning 422 so a stray query string
// doesn't break the bookkeeper's download).
const startDate = c.req.query("start_date");
const endDate = c.req.query("end_date");
const vendor = c.req.query("vendor");
const limitRaw = c.req.query("limit");
const limit = limitRaw
? Math.min(50_000, Math.max(1, Number.parseInt(limitRaw, 10) || 5000))
: 5000;
const store = defaultExtractionsStore(c.env);
const records = await store.listForOrg(ctx.org_id, {
startDate,
endDate,
vendor,
limit,
});
const csv = buildInvoiceCsv(records);
// H-perf-2: audit move to waitUntil so the CSV download doesn't
// block on D1. A CSV export is operator-driven and idempotent (the
// operator can re-run the export if the audit didn't land), and
// a failed audit lands in audit_dlq for replay. The latency win
// matters here because CSV builds can be tens of MB and the audit
// append sitting on the request path makes the user-perceived
// download start later for no functional benefit -- the audit row
// lands either way, just after the response stream starts.
await scheduleOrAwait(
c,
recordAuditEvent(new D1AuditStore(c.env.DB), {
org_id: ctx.org_id,
actor_id: ctx.user_id,
action: "data.exported",
target_kind: "export",
target_ref: `csv:${records.length}`,
}),
);
c.header("Content-Type", "text/csv; charset=utf-8");
c.header(
"Content-Disposition",
`attachment; filename="${buildCsvFilename(ctx.org_id)}"`,
);
return c.body(csv);
});This is the file as it lives at the moment of this build. The canonical history lives in git. If you want the full history or a specific commit, write to hello@muntin.digital.