Phase 3 of the cross-project sqlite-migrate adoption — port nanodrop to
consume bchen-sqlite-migrate@v0.1.0. Replaces the inline db.exec(...)
block in src/db/schema.ts with applyMigrations(db, MIGRATIONS_DIR,
{ genesisProbeTable: 'users' }).
The genesis-probe (table 'users' exists) handles pre-existing prod DBs
automatically — first deploy after merge stamps 0001_init as applied
without re-executing, subsequent boots are no-ops.
Adds three npm scripts (db:migrate, db:status, db:stamp) and a
byte-stability test pinning sha256(0001_init.sql) so the migration is
treated as immutable history.
Refactor public/style.css to semantic CSS custom properties under :root
(light palette by default) and add a @media (prefers-color-scheme: dark)
override block that flips background/foreground/accent/danger tokens to
a soft near-black palette. The original --black/--white/--gray-*/--red
literal tokens and the lone #fff5f5 floating literal are gone — every
color now derives from a semantic token, so the dark override is the
only place that re-declares them.
Add <meta name="color-scheme" content="light dark"> to the shared
layout <head> so the browser renders its UA backdrop, native scrollbars,
and form-control popups in the user's preferred scheme before the
stylesheet parses — this is what avoids a white flash on initial paint
in dark mode.
No toggle, no JS, no preference persistence — the page follows the OS
setting directly via the media query. The 16px font-size rule on
text-entry inputs is preserved (still asserted by tests/integration
/style.test.ts).
Final retrofit in the cross-project iOS focus-zoom chore (after authd PR
#11, buchinese PR #5, inventory PR #18, movement PR #15). Enforces the
standing rule in ~/.claude/CLAUDE.md: text-entry inputs must compute to
font-size >= 16px so iOS Safari does not auto-zoom on focus.
JwtPayload already declares iat?: number, so the & { iat?: number } in
makeRequireAuth and slideSessionIfNeeded was a no-op. The unit test had a
local TestPayload duplicating the same shape — replaced with the canonical
import.
The JWT lifetime is now pinned to SESSION_TTL_SECONDS (30 days)
inside issueSessionCookie, so JWT_EXPIRY had no effect after the
prior commits. Removing the field from Config and the env read in
loadConfig lets the TypeScript compiler verify no caller was still
relying on it.
Per the family invariant ('coherence across apps is a feature'),
the per-app override is intentionally gone — a deploy with stale
JWT_EXPIRY in .env will now silently use the 30-day family default
regardless of the value.
Test fixtures (Config literals in setup.ts, login-handler.test.ts,
lockout-service.test.ts) and config.test.ts assertions updated to
match the new shape.
Adds src/middleware/session-renewal.ts with slideSessionIfNeeded —
a pure function that re-mints the session cookie iff the JWT is
older than SESSION_RENEW_THRESHOLD_SECONDS (1 hour). Wired into
makeRequireAuth so every authenticated request bumps the cookie's
expiration window forward, giving the 'stay signed in unless you
clear cookies' UX.
Logout paths are explicitly guarded via LOGOUT_PATHS so the
renewer never resurrects a session the user is actively
terminating. Query-string strip prevents /logout?next=foo bypass.
Opportunistic-auth blocks (GET /, GET /f/:id) verify JWT directly
without going through makeRequireAuth, so they don't slide — by
design, a public file view shouldn't extend the owner's session.
Tests cover threshold semantics, both logout paths, query-string
handling, missing iat (legacy token forces refresh), and a full
integration suite simulating 25-day and 31-day jumps via
vi.setSystemTime.
Flips SESSION_COOKIE_NAME from 'token' to 'nanodrop_session' per the
family per-app naming convention (<app>_session). fastify-jwt's
cookieName in server.ts is now sourced from the constant so a future
rename only needs to touch constants.ts.
Hard-cut migration with no dual-cookie shim: the existing 'token'
cookie has no Max-Age so it dies on browser close anyway, and this
is a single-user deployment per CLAUDE.md. Users re-log in once
after deploy.
Test files updated mechanically: cookies: { token } → cookies: {
nanodrop_session: token } (variable name 'token' kept locally),
clearCookie regex updated, login response now also asserts
Max-Age=2592000 from the family TTL.
The table at /files was wider than the viewport on iPhone-class widths
(~375 px) — the rightmost column with the Copy and Delete buttons clipped
off-screen with no scroll affordance. Wrapping the table in a
.table-wrap div with overflow-x: auto lets the table scroll within
itself; moving the outer border to the wrapper preserves the desktop
visual unchanged.
- public/style.css: add .table-wrap rule, move border off table, add
min-width: 100% so the table still fills wide viewports.
- src/views/file-list.ts: wrap <table> in <div class="table-wrap">.
- tests/integration/pages.test.ts: assert rendered HTML contains
class="table-wrap".
Removes the IBM Plex Mono Google Fonts @import, replaces the --font CSS
variable with a cross-platform system sans-serif stack, and adds a
--font-mono variable for the share-URL readonly input so copy/share text
stays monospace. Also adds line-height: 1.5 to body to compensate for
the system fonts' tighter default leading.
No font asset files exist in /public; layout.ts has no font <link> tags
to remove. Acceptance check: build + 112 tests pass; new
tests/integration/style.test.ts asserts no @import / IBM Plex / external
font URL remains and that the system stack is wired up.
Both POST /login (HTML form) and POST /api/v1/auth/login now flow through
the shared attemptLogin() handler. Locked accounts respond with 401 +
Retry-After (generic body "Invalid credentials" / "Invalid username or
password") so attackers can't use lockout state as a username-existence
oracle.
@fastify/rate-limit registered with global=false; only the two login
routes opt in via per-route rateLimit config. File uploads and downloads
keep full throughput. Custom errorResponseBuilder logs AUTH_RATE_LIMITED
fire-and-forget so fail2ban can pick it up.
createTestApp now accepts Partial<Config> overrides so integration tests
can dial thresholds down without env-var mutation.
Single attemptLogin() orchestrates lockout check, bcrypt verify (against
real or dummy hash), success/failure logging, and a configurable minimum
response-time clamp. Both login routes will share this — no duplication.
Adds three logger events for the operator's escalation pipeline:
AUTH_LOCKOUT_TRIGGERED, AUTH_LOCKED_ATTEMPT, and AUTH_RATE_LIMITED.
fail2ban filters can pick these up to escalate persistent attackers from
in-app lockout to IP ban.
Constant-time defense: unknown users still pay bcrypt cost (via dummy hash)
and the clamp ensures locked-vs-unknown-vs-wrong-password aren't
distinguishable by response time. Username is canonicalized (lowercase + trim)
before lookup so attackers can't bypass lockout via case variation.
Persists per-username failed-attempt counts and computed locked_until
timestamps. Lockout service computes exponential-backoff durations
(min(base * 2^(count-threshold), max)) with auto-unlock once locked_until
passes. Successful login deletes the row, resetting the counter.
Pure DB-keyed lockout — survives server restarts and shares state across
both login routes (HTML and JSON) when wired in a later step.
Lays the foundation for brute-force defense: per-username attempt tracking
table, configurable lockout/rate-limit thresholds, and a memoized dummy
bcrypt hash so unknown-user paths can be timed identically to wrong-password
paths in a later step.
Adds @fastify/rate-limit dependency for upcoming per-IP rate-limit on
login routes.
CreateFileParams, UserRow, CreateUserParams, and MultipartFile were
exported but never imported outside their own modules. Narrowed visibility
to module-local to keep the public surface minimal.
Confirmed with knip (zero findings) and all 61 tests passing.
Safari and other browsers require Accept-Ranges: bytes and 206 Partial
Content responses to play video. Without this, large videos fail to load
(especially in Safari) because the entire file had to buffer in memory
before sending.
- Replace readFile + Buffer with createReadStream for efficient streaming
- Parse Range header (start-end, start-, and suffix -N forms)
- Return 206 Partial Content with Content-Range for range requests
- Return 416 Range Not Satisfiable for out-of-bounds ranges
- Add Accept-Ranges: bytes to all raw file responses
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Set up package.json (ESM, scripts), tsconfig.json, vitest.config.ts
- Install runtime and dev dependencies
- Add CLAUDE.md with architecture notes and code quality rules
- Config module with env var parsing and JWT_SECRET validation
- DB schema: users + files tables with FK cascade
- DB queries: createUser, getUserBy*, createFile, getFileById, getFilesByUserId, deleteFile
- Tests for config, db/users, db/files (15 tests passing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>