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.
Brings nanodrop into parity with ~/inventory/.github/workflows/deploy.yml,
the cross-project canonical:
- Rename .github/workflows/deploy-homelab.yml -> deploy.yml
- Update workflow name to "Deploy to birb co. production"
- Add validate-secrets gate (SSH_PRIVATE_KEY, JWT_SECRET) using
${VAR:?msg} no-op expansion (does not echo secret values)
- Switch deploy heredoc from << 'EOF' (quoted) to << EOF (unquoted)
to match canonical; functional no-op since the body contains no
bash $VAR refs, only GitHub Actions ${{ ... }} interpolations
- Single-quote the right-hand side of interpolated export values to
prevent shell-metacharacter re-interpretation server-side
- Reorder exports: secret first, then hardcoded literals, then vars
- Rename docker-compose.yml -> compose.yaml (pure rename) and update
the workflow's compose invocations to reference compose.yaml
- Update one README example to match the new compose filename
The env-var block remains nanodrop-specific (JWT_SECRET +
TRUST_PROXY/COOKIE_SECURE literals + PORT/BASE_URL/MAX_FILE_SIZE);
that delta is allowed by the bug spec.
No app-code changes. Build and tests green.
Manual deploy verification (push to main / "Run workflow" -> hit the
deployed instance, log in, upload a test file, confirm share link)
is the user's job post-merge.
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.
Adds src/constants.ts exporting the family-wide session policy
(SESSION_TTL_DAYS=30, SESSION_TTL_SECONDS=2_592_000,
SESSION_RENEW_THRESHOLD_SECONDS=3600, LOGOUT_PATHS) so every
bchen.dev app shares the same persistence window.
Introduces issueSessionCookie as the single mint site for
fastify-jwt sign + setCookie, replacing inlined jwt.sign +
setCookie calls in pages.ts and api/v1/auth.ts. The cookie now
carries Max-Age=SESSION_TTL_SECONDS so it persists across browser
restarts.
Converts requireAuth into a makeRequireAuth(config) factory; route
plugins build their own preHandler at registration time. Threads
through pages.ts, api/v1/auth.ts, and api/v1/files.ts.
SESSION_COOKIE_NAME stays 'token' in this commit so existing tests
remain green; the rename to 'nanodrop_session' lands in a follow-up.
JWT_EXPIRY env var is still read; its removal also lands in a
follow-up so each commit builds cleanly.
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.
Standing authorization so any Claude Code instance commits work after
each logical change (build+tests green) and pushes to the upstream
branch, without waiting to be asked. Force-push and pushes to main from
a feature branch still require explicit approval.
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>
- docker-compose.yml: replace env_file with explicit environment block
so all variables are passable directly (JWT_SECRET required, rest have defaults)
- Confirmed Docker image builds successfully
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- IBM Plex Mono font from Google Fonts for the monospace character
- Pure black/white/gray palette — no colors except red for errors/danger
- 1px solid black borders system throughout (no shadows, no rounding)
- Header nav uses border-left separators with hover backgrounds
- Primary button: solid black; copy button: white outline (visually distinct)
- Share box: readonly input + copy button flush with no gap
- Table: outer border + gray header row + subtle row hover
- File view: centered with border around video/audio players
- Danger button: red outline, fills on hover
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>