Skip to main content

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.

exports.ts · Verify · Muntin Ledger · Muntin