Tech Stack

Security (Vault, API Keys, Rate Limiting)

The API key vault's AES-256-GCM encryption, service API key hashing, and the in-memory rate limiter.

Three distinct security mechanisms live under src/lib/security/, solving three different problems. It's worth not conflating them - they're often confused because they're all "keys":

MechanismFileProblem it solves
Vault encryptionvault-crypto.tsStore third-party credentials so an authorized human can read them back later
Sim access codesim/access-code.tsLet the simulation CLI authenticate to this app's API with one short club-wide code, no accounts and nothing stored on the caller's machine
Rate limitingrate-limit.tsSlow down abuse of unauthenticated/low-friction endpoints

The API Key Vault

The API Keys page is a shared credential store to keep track of third-party API keys and service logins (Resend, Tailscale, etc.) in one place instead of a shared password doc. Any logged-in member can open the page and see entry names/descriptions - reading a specific secret is the part that's gated: it requires a grant in the vaultEntryAccess table, set via that entry's "Manage access" action. There's no bypass - not even the entry's creator can read it back without an explicit grant for themselves (which the creation flow adds automatically).

Each entry is either a login (username + password, revealed in the UI on demand) or an api_key (never rendered in the UI - only retrievable via the API endpoint below, e.g. for pasting into another tool's config).

Encryption at rest - AES-256-GCM

Vault secrets need to be decrypted back to their original value (unlike a password, which only ever needs to be checked, never read), so they're encrypted, not hashed. src/lib/security/vault-crypto.ts:

TypeScript
const ALGORITHM = "aes-256-gcm";
const KEY_BYTES = 32; // AES-256
const IV_BYTES = 12; // standard GCM nonce length

export function encryptSecret(plaintext: string): string {
    const iv = randomBytes(IV_BYTES);
    const cipher = createCipheriv(ALGORITHM, getVaultKey(), iv);
    const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
    const tag = cipher.getAuthTag();
    return `${iv.toString("hex")}.${tag.toString("hex")}.${ciphertext.toString("hex")}`;
}

Each encrypted value is stored as one string - iv.tag.ciphertext, all hex - so it fits in a single database column. A fresh random IV (nonce) is generated per encryption call, which is required for GCM: reusing an IV with the same key breaks the encryption's confidentiality guarantees. The auth tag is GCM's built-in tamper-detection - decryptSecret will throw rather than return corrupted plaintext if the ciphertext was altered.

Key resolution, in order:

  1. VAULT_ENCRYPTION_KEY env var, if set (must be exactly 64 hex characters / 32 bytes - generate with openssl rand -hex 32).
  2. If unset and NODE_ENV === "production" - throws immediately. Production is never allowed to silently fall back to a generated key.
  3. Otherwise (local dev) - generates a random key on first use and persists it to db/vault.key (next to the SQLite file, gitignored), so secrets stay decryptable across dev-server restarts. A console warning is printed the first time this happens.

Caution

Losing VAULT_ENCRYPTION_KEY (or db/vault.key in dev) makes every existing vault entry permanently unrecoverable - there's no way to decrypt without it. Back it up separately from the database, and rotate it only if you're prepared to re-enter every stored secret.

Fetching a secret - the API endpoints

Terminal
$ GET /api/vault/{id}/key      # api_key entries only - returns { name, key }
$ GET /api/vault/{id}/reveal   # login entries only - returns { name, username, password }

Both endpoints are authenticated by the caller's dashboard session cookie (not a separate API key) and both:

  • Call canReadVaultEntry(user, id) (a matching vaultEntryAccess row - no other bypass).
  • Independently re-check isActive, because /api/* isn't covered by middleware.ts (see Architecture Overview).
  • Set Cache-Control: no-store so a decrypted secret is never cached by a browser or intermediary.
Terminal
$ curl -s https://dashboard.trickfirerobotics.com/api/vault/12/key \
  -H "Cookie: better-auth.session_token=<your-session-cookie>"

Sim access code (src/lib/sim/access-code.ts)

Separate from the vault entirely - this authenticates external scripts (the simulation CLI) calling into this app's API, not humans logging into the dashboard. It is one 6-digit code shared by the whole club, held in the single-row simAccessCode table:

TypeScript
export function getAccessCode(now: Date = new Date()): AccessCode {
    const row = db.select().from(simAccessCode).where(eq(simAccessCode.id, ROW_ID)).get();
    if (!row || row.rotatesAt.getTime() <= now.getTime()) {
        return issue(now, null);
    }
    return { code: row.code, rotatesAt: row.rotatesAt };
}

Rotation is lazy rather than scheduled: every read checks whether 3am Pacific has passed and issues a new code if so, which means no cron job and no way for a restart to leave a stale code in place. Any dashboard user can also rotate it immediately from the API Keys page, recorded in rotatedBy.

The code is stored in plaintext on purpose - unlike a hashed API key, a lead has to be able to read it off the screen to share it. What limits the damage is its lifetime (a day at most) and the scope of what it unlocks (CAD exports the club already publishes doc IDs for). Guessing is handled by the rate limiter below: wrong codes spend from a 10-per-hour per-IP budget while correct ones never do.

Rate limiting (src/lib/security/rate-limit.ts)

A simple in-memory token bucket, backed by an lru-cache keyed per caller (typically by IP, via clientIp()):

TypeScript
const CAPACITY = 30;
const REFILL_PER_MS = CAPACITY / 60_000; // refills to full over 60s

export function rateLimit(key: string): RateLimitResult {
    // ...refill tokens proportional to elapsed time, then consume one if available
}

Each key gets a bucket of 30 tokens that refills continuously back to 30 over a minute; every checked request consumes one token, and requests are rejected (with a computed retryAfter in seconds) once the bucket is empty. clientIp() reads x-forwarded-for (falling back to cf-connecting-ip) - both are injected by the Cloudflare Tunnel in front of production, which is what makes per-caller limiting possible despite the app itself only ever seeing traffic from the tunnel.

Being in-memory (an LRUCache, not the database), this state resets whenever the process restarts and doesn't share state across multiple server instances - fine for this app's single-instance deployment, but worth knowing if that ever changes.

On this page