Skip to content

feat: accounts, server sessions, and gated writes (auth PR A) - #18

Merged
deckyfx merged 3 commits into
mainfrom
feat/auth-sessions
Jul 1, 2026
Merged

feat: accounts, server sessions, and gated writes (auth PR A)#18
deckyfx merged 3 commits into
mainfrom
feat/auth-sessions

Conversation

@deckyfx

@deckyfx deckyfx commented Jul 1, 2026

Copy link
Copy Markdown
Owner

Summary

PR A of the auth + superadmin milestone: accounts, opaque server sessions, and login/register/logout/me, then gate every existing write route on a valid session and attribute uploads to the user. (PR B — superadmin powers + runtime settings — follows.)

Auth lives in Core (CLAUDE.md: Core owns Users/Auth/Permissions); createCore already encapsulates the db → repo → service wiring, so no boundary edges change.

Design

  • Server sessions, not JWTs: an opaque 256-bit token; the DB stores only its sha256 hash, so a DB leak can't be replayed as a live session. Expired rows read as logged-out immediately (the lookup filters on expires_at); a bounded periodic sweep reclaims them.
  • Dual transport (API-mode friendly): the token is accepted via Authorization: Bearer <token> OR the httpOnly bunbooru_session cookie. Login/register set the cookie and return the token in the JSON body (so scripts/mobile can capture it). A present-but-malformed Authorization header does not fall back to the cookie.
  • Open self-serve registration; the first account is admin (assigned atomically under a Postgres advisory lock so two concurrent first-registrations can't both win admin), the rest member. Register auto-logs-in.
  • Any authenticated user may edit tags/rating/source in PR A (booru-collaborative); isOwnerOrAdmin is built but not yet enforced.
  • Passwords hashed with Argon2id (Bun.password); PublicUser serialization never leaks the hash. Login is timing-safe against username enumeration.
  • Cookie: HttpOnly, SameSite=Lax, Secure in production, Path=/, Max-Age = session lifetime.

Schema (migrations 0007 + 0008)

  • New sessions table (token_hash unique, user_id FK ON DELETE CASCADE, expires_at, indexes on user + expiry).
  • users.email made nullable (registration needs only username + password).
  • Username uniqueness moved onto the canonical lower(username) form via a functional unique index — Alice/alice can never become two accounts.

API

  • /auth/register (201, sets cookie, returns { user, token }, no hash), /auth/login, /auth/logout (204, clears cookie), /auth/me ({ user | null }).
  • requireUser gates POST /assets, PATCH /assets/:id, PATCH /assets/:id/tags, and all /uploads routes; uploads thread uploaderId.
  • New env: SESSION_EXPIRY_MS (30d, ≥ 1s) and SESSION_GC_INTERVAL_MS (1h) + a session-GC sweep.

Web

  • useCurrentUser / useLogin / useRegister / useLogout (auth rides the cookie; /auth/me is the source of truth). Login + signup pages (errors in a live region), header account state, and write UIs (upload, post-edit, tag-edit) gated on login — editors close + reset if auth drops mid-edit.

Testing

  • typecheck ✅, lint:boundaries ✅, full suite 222 pass / 0 fail including DB-integration tests against real Postgres (session validity/expiry, revocation, bounded GC, FK cascade, case-insensitive uniqueness, atomic admin bootstrap), core-unit, and API-route tests (Bearer + cookie transports, 401 gating, uploaderId attribution, 409 on duplicate).
  • Verified end-to-end via app.handle against Postgres, and the advisory-locked bootstrap proven under 8-way concurrency (exactly one admin).

Notes / caught during review

  • Bun's native SQL driver surfaces the unique-violation SQLSTATE as cause.errno (not .code, which is ERR_POSTGRES_SERVER_ERROR); isUniqueViolation walks the cause chain and checks both, so a duplicate registration returns 409 not 500.

Deferred to PR B

  • Binding upload-session ops (offset/append/cancel) and post edits to the owner via isOwnerOrAdmin.
  • Per-IP/user rate-limiting on login/registration (cross-cutting; no limiter infra yet).
  • Superadmin tag-category route + admin-editable runtime settings.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added authentication endpoints (register/login/logout/me) using httpOnly session cookies, with session-based “current user” support (including Bearer token auth).
    • Added configurable session expiry and automatic cleanup of expired sessions; session cookies use Secure in production.
    • Secured write operations (uploads/assets/tag updates) so actions require a signed-in user, and new uploads are attributed to the authenticated account.
    • Added login/signup pages and React auth hooks; editing/upload UI is gated by login state.
  • Bug Fixes

    • Improved HTTP error mapping for authentication/authorization/registration conflicts.
    • Prevented tag/editor drafts from staying active when the user becomes logged out mid-edit.

Add the auth foundation (PR A of the auth + superadmin milestone): accounts,
opaque server sessions, and login/register/logout/me, then gate every existing
write route on a valid session and attribute uploads to the user.

Core (Users/Auth per CLAUDE.md):
- `sessions` table + nullable `users.email` (0007); username uniqueness enforced
  on the canonical `lower(username)` form via a functional unique index (0008),
  so `Alice`/`alice` can never become two accounts. User + session repositories
  are the sole SQL layer.
- `createAuthService`: register (first account → admin via an ATOMIC,
  advisory-locked bootstrap so concurrent first-registrations can't both win
  admin; Argon2id hashing; auto-login), timing-safe login, currentUser, logout,
  and bounded expired-session GC. Sessions are opaque 256-bit tokens; the DB
  stores only their sha256 hash. Typed auth errors + permission predicates
  (canWrite now; ownership/role reserved for PR B).

API (dual transport for API-mode clients):
- Session token accepted via `Authorization: Bearer` OR the httpOnly
  `bunbooru_session` cookie; login/register set the cookie AND return the token.
  A present-but-malformed Authorization header does NOT fall back to the cookie.
- `/auth/register|login|logout|me`; `requireUser` gates POST/PATCH /assets,
  PATCH /assets/:id/tags, and all /uploads routes; uploads carry `uploaderId`.
- `SESSION_EXPIRY_MS` (30d, >= 1s) + `SESSION_GC_INTERVAL_MS` (1h) env + a session
  GC sweep. `PublicUser` serialization never leaks the password hash.

Web:
- `useCurrentUser`/`useLogin`/`useRegister`/`useLogout` (auth rides the cookie;
  `/auth/me` is the source of truth), login + signup pages (errors in a live
  region), header account state, and write UIs (upload, post edit, tag edit)
  gated on login — the editors also close + reset if auth drops mid-edit.

Unique-violation detection walks the error cause chain and accepts the SQLSTATE
on either `.code` (node-postgres) or `.errno` (Bun native SQL), so a duplicate
registration returns 409 rather than 500. Covered by db-integration (incl. the
atomic bootstrap + case-insensitive uniqueness), core-unit, and API-route tests
(Bearer + cookie transports).

Deferred to PR B: binding upload-session ops (offset/append/cancel) and post
edits to the owner via isOwnerOrAdmin, and per-IP/user rate-limiting on
login/registration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 31e7a1ea-8093-453d-ba1a-3ee714eb7b29

📥 Commits

Reviewing files that changed from the base of the PR and between cfad222 and ffaf15c.

📒 Files selected for processing (1)
  • packages/db/src/repositories/user-repository.ts

📝 Walkthrough

Walkthrough

This PR adds session-based authentication end to end: DB tables and repositories, core auth/session logic, API auth routes and write gating, and web login/signup plus auth-aware UI behavior.

Changes

Session Authentication Feature

Layer / File(s) Summary
DB schema and auth repositories
packages/db/src/schema.ts, packages/db/src/repositories/*, packages/db/drizzle/*, packages/db/test/auth-repositories.test.ts
Adds the sessions table, case-insensitive username uniqueness, nullable email, repository factories, migrations, snapshots, and repository tests.
Core auth service and permission helpers
packages/core/src/services/auth-service.ts, packages/core/src/services/permissions.ts, packages/core/src/errors.ts, packages/core/src/core.ts, packages/core/src/index.ts, packages/core/test/auth-service.test.ts
Adds auth errors, permission predicates, the auth service, Core wiring, public re-exports, and service tests.
API env config and auth utilities
.env.example, apps/api/src/env-config.ts, apps/api/src/lib/auth.ts, apps/api/src/lib/http.ts
Adds session env settings, cookie helpers, token parsing, auth enforcement, and status mapping.
API auth routes and protected write paths
apps/api/src/server.ts, apps/api/src/index.ts
Adds /auth/* routes, session-derived request state, user serialization, and authentication checks on asset/upload routes, plus session GC wiring.
API auth integration tests
apps/api/test/server.test.ts
Adds auth stubs, fixtures, and coverage for auth endpoints and authenticated write requests.
Web auth hooks and API client credentials
apps/web/src/lib/auth.ts, apps/web/src/lib/api.ts
Adds React Query auth hooks and browser credentialed requests.
Web login, signup, routing, and account UI
apps/web/src/routes/login.tsx, apps/web/src/routes/signup.tsx, apps/web/src/router.tsx, apps/web/src/routes/__root.tsx
Adds login/signup pages and routes, and auth-aware account links.
Web edit and upload gating
apps/web/src/components/tags/post-tag-panel.tsx, apps/web/src/routes/post-detail.tsx, apps/web/src/routes/upload.tsx
Hides editing/upload UI for signed-out users and resets in-progress edits on auth loss.

Estimated code review effort: 4 (Complex) | ~75 minutes

Possibly related PRs

  • deckyfx/bunbooru#2: Both PRs modify apps/api/src/env-config.ts around the EnvConfig singleton, including session-related NODE_ENV-driven config.
  • deckyfx/bunbooru#10: Both PRs update apps/api/src/index.ts’s re-export list from ./server.
  • deckyfx/bunbooru#12: Both PRs touch the PATCH /api/v1/assets/:id write path in apps/api/src/server.ts.
  • deckyfx/bunbooru#13: Both PRs touch the resumable upload API surface in apps/api/src/server.ts and its tests.
  • deckyfx/bunbooru#15: Both PRs modify the API server background sweep scheduling in apps/api/src/index.ts.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: account auth, server sessions, and auth-gated writes.
Docstring Coverage ✅ Passed Docstring coverage is 83.67% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/auth-sessions

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (1)
packages/db/test/auth-repositories.test.ts (1)

72-86: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Exercise createBootstrapping() concurrently.

This only proves sequential role assignment. A regression that drops pg_advisory_xact_lock() would still pass here, so the first-user admin guarantee is still effectively untested.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/test/auth-repositories.test.ts` around lines 72 - 86, The current
createBootstrapping test only checks sequential calls, so it does not verify the
first-user admin guarantee under concurrency. Update the test in
auth-repositories.test.ts to exercise users.createBootstrapping concurrently
with multiple requests and assert that only one account gets the admin role
while the others become members; keep the focus on the createBootstrapping
behavior so a missing pg_advisory_xact_lock() would fail the test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/server.ts`:
- Around line 206-245: The browser-facing auth handlers in server.ts should stop
returning the raw session token in the JSON response. Update the /auth/register
and /auth/login routes to continue setting the HttpOnly cookie via
buildSessionCookie, but only return the serialized user payload from
authService.register and authService.login. Keep the token internal to the
cookie/session flow and use serializeUser plus /auth/me for auth state.

In `@apps/web/src/lib/auth.ts`:
- Around line 37-50: The auth query in useCurrentUser() can remain stale for too
long after logout or session expiry, so update the auth refresh strategy in
auth.ts. Either reduce the staleTime on the CURRENT_USER_KEY query or add an
explicit invalidation/refetch path so useIsLoggedIn() and related edit-gating
state update promptly across tabs and after auth changes.

In `@apps/web/src/routes/upload.tsx`:
- Around line 31-33: The upload page currently treats any non-pending auth
result as anonymous, so a failed /auth/me request incorrectly shows the
login/signup CTA and blocks uploads. Update the auth gating in upload.tsx around
useCurrentUser and the upload prompt logic to handle the auth query error state
separately from a true unauthenticated user. In the branch that decides between
the dropzone and the anonymous-upload CTA, only show the CTA when the query
resolved successfully with no user; if auth errored, keep the upload flow from
falling back to the anonymous prompt and surface or preserve the signed-in path
instead.

In `@packages/core/src/services/auth-service.ts`:
- Around line 121-133: Make signup atomic in auth-service by ensuring
`users.createBootstrapping()` and `openSession()` succeed or fail together.
Update `register()` (and the `openSession`/`sessions.create` flow it calls) to
run both writes in a single transaction, or add rollback/cleanup if session
creation fails after the user is created. Keep the unique-violation handling in
`isUniqueViolation()` and `RegistrationConflictError`, but make sure a failed
session open does not leave a committed user behind.
- Around line 52-53: The username normalization path currently allows
whitespace-only input to become an empty string and be persisted. Update
normalizeUsername and the register() flow in auth-service to validate the
normalized result before hashing/inserting, and reject empty normalized
usernames for all callers with a clear validation error instead of continuing to
persistence.

In `@packages/core/test/auth-service.test.ts`:
- Around line 120-122: The `expect(...).rejects` checks in
`auth-service.test.ts` are not being awaited, so the test can complete before
the rejection assertion runs. Update the affected assertions in the `register`
test and the other nearby `rejects` checks to await them directly, using the
existing `service.register` and `RegistrationConflictError` assertions so the
async expectation is guaranteed to execute.

In `@packages/db/drizzle/0008_canonical_username_unique.sql`:
- Around line 1-2: The migration in the users canonical-username change needs a
preflight check for existing case-colliding values before removing the old
uniqueness constraint. Update the SQL in the canonical username migration to add
a guard step ahead of ALTER TABLE "users" DROP CONSTRAINT
"users_username_unique", using a DO block or equivalent to detect duplicate
lower(username) groups and raise a clear exception if any exist, so the rollout
fails early instead of during CREATE UNIQUE INDEX "users_username_lower_idx".

In `@packages/db/src/repositories/session-repository.ts`:
- Around line 40-45: The session cleanup logic in deleteExpired() should use the
same expiry cutoff as findValidByTokenHash(), which means treating expiresAt ===
now as expired too. Update the session repository’s deleteExpired flow (and any
shared predicate it uses) to delete rows with expiresAt less than or equal to
now, matching the gt(sessions.expiresAt, now) auth lookup behavior in
findValidByTokenHash(). Make the change in the session-repository methods so
both GC and validation stay consistent.

In `@packages/db/src/repositories/user-repository.ts`:
- Around line 47-57: Canonicalize usernames inside the repository so writes and
reads use the same normalized form. Update createBootstrapping and any username
lookup in findByUsername to normalize via lowercasing before insert/select,
rather than relying on callers to do it. Make the repository’s
createBootstrapping and findByUsername paths consistent with the lower(username)
unique index so mixed-case usernames like Alice can still be found with alice.

---

Nitpick comments:
In `@packages/db/test/auth-repositories.test.ts`:
- Around line 72-86: The current createBootstrapping test only checks sequential
calls, so it does not verify the first-user admin guarantee under concurrency.
Update the test in auth-repositories.test.ts to exercise
users.createBootstrapping concurrently with multiple requests and assert that
only one account gets the admin role while the others become members; keep the
focus on the createBootstrapping behavior so a missing pg_advisory_xact_lock()
would fail the test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b97bb53d-5039-46b8-af23-c34332ea3f21

📥 Commits

Reviewing files that changed from the base of the PR and between 60b197a and 822a79c.

📒 Files selected for processing (33)
  • .env.example
  • apps/api/src/env-config.ts
  • apps/api/src/index.ts
  • apps/api/src/lib/auth.ts
  • apps/api/src/lib/http.ts
  • apps/api/src/server.ts
  • apps/api/test/server.test.ts
  • apps/web/src/components/tags/post-tag-panel.tsx
  • apps/web/src/lib/api.ts
  • apps/web/src/lib/auth.ts
  • apps/web/src/router.tsx
  • apps/web/src/routes/__root.tsx
  • apps/web/src/routes/login.tsx
  • apps/web/src/routes/post-detail.tsx
  • apps/web/src/routes/signup.tsx
  • apps/web/src/routes/upload.tsx
  • packages/core/src/core.ts
  • packages/core/src/errors.ts
  • packages/core/src/index.ts
  • packages/core/src/services/auth-service.ts
  • packages/core/src/services/permissions.ts
  • packages/core/test/auth-service.test.ts
  • packages/db/drizzle/0007_auth_sessions.sql
  • packages/db/drizzle/0008_canonical_username_unique.sql
  • packages/db/drizzle/meta/0007_snapshot.json
  • packages/db/drizzle/meta/0008_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/index.ts
  • packages/db/src/repositories/session-repository.ts
  • packages/db/src/repositories/user-repository.ts
  • packages/db/src/schema.ts
  • packages/db/test/auth-repositories.test.ts
  • scripts/seed.ts

Comment thread apps/api/src/server.ts
Comment on lines +206 to +245
.post(
"/auth/register",
async ({ body, set }) => {
const email = body.email?.trim();
const { token, user } = await core.authService.register({
username: body.username,
password: body.password,
email: email ? email : null,
});
set.headers["set-cookie"] = buildSessionCookie(token, envConfig.SESSION_EXPIRY_MS, {
secure: envConfig.COOKIE_SECURE,
});
set.status = 201;
return { user: serializeUser(user), token };
},
{
body: t.Object({
username: t.String({ minLength: 1, maxLength: 100 }),
password: t.String({ minLength: 8, maxLength: 200 }),
email: t.Optional(t.String({ maxLength: 320 })),
}),
},
)
// Verify credentials and open a session (sets cookie + returns token).
.post(
"/auth/login",
async ({ body, set }) => {
const { token, user } = await core.authService.login(body.username, body.password);
set.headers["set-cookie"] = buildSessionCookie(token, envConfig.SESSION_EXPIRY_MS, {
secure: envConfig.COOKIE_SECURE,
});
return { user: serializeUser(user), token };
},
{
body: t.Object({
username: t.String({ minLength: 1, maxLength: 100 }),
password: t.String({ minLength: 1, maxLength: 200 }),
}),
},
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Don't return the raw session token from the browser-facing auth endpoints.

These handlers already set the HttpOnly session cookie, and the web flow in this PR learns auth state via /auth/me. Returning the same bearer token in JSON makes it readable to any injected script, which defeats the cookie-only protection and leaves the session reusable even after the cookie is cleared locally.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/server.ts` around lines 206 - 245, The browser-facing auth
handlers in server.ts should stop returning the raw session token in the JSON
response. Update the /auth/register and /auth/login routes to continue setting
the HttpOnly cookie via buildSessionCookie, but only return the serialized user
payload from authService.register and authService.login. Keep the token internal
to the cookie/session flow and use serializeUser plus /auth/me for auth state.

Comment thread apps/web/src/lib/auth.ts
Comment thread apps/web/src/routes/upload.tsx Outdated
Comment on lines +52 to +53
function normalizeUsername(username: string): string {
return username.trim().toLowerCase();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject usernames that normalize to empty strings.

register() lowercases and trims before insert, so " " becomes "" and can be persisted as a real username. Please validate the normalized value before hashing/inserting so whitespace-only usernames fail for every caller, not just the HTTP boundary.

Also applies to: 113-124

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/services/auth-service.ts` around lines 52 - 53, The
username normalization path currently allows whitespace-only input to become an
empty string and be persisted. Update normalizeUsername and the register() flow
in auth-service to validate the normalized result before hashing/inserting, and
reject empty normalized usernames for all callers with a clear validation error
instead of continuing to persistence.

Comment on lines +121 to +133
try {
user = await users.createBootstrapping({
username: normalized,
email: email ?? null,
passwordHash,
});
} catch (error) {
if (isUniqueViolation(error)) throw new RegistrationConflictError();
throw error;
}

const token = await openSession(user.id);
return { token, user };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make signup atomic with session creation.

users.createBootstrapping() commits the account before openSession() runs. If sessions.create() fails, register() returns an error even though the user row already exists, so a retry turns into a conflict while the caller thinks signup never completed. Please wrap both writes in one transaction or add compensating cleanup on session-open failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/services/auth-service.ts` around lines 121 - 133, Make
signup atomic in auth-service by ensuring `users.createBootstrapping()` and
`openSession()` succeed or fail together. Update `register()` (and the
`openSession`/`sessions.create` flow it calls) to run both writes in a single
transaction, or add rollback/cleanup if session creation fails after the user is
created. Keep the unique-violation handling in `isUniqueViolation()` and
`RegistrationConflictError`, but make sure a failed session open does not leave
a committed user behind.

Comment thread packages/core/test/auth-service.test.ts Outdated
Comment thread packages/db/drizzle/0008_canonical_username_unique.sql
Comment thread packages/db/src/repositories/session-repository.ts
Comment thread packages/db/src/repositories/user-repository.ts
- Reject whitespace-only usernames at the API boundary (username pattern
  requires a non-whitespace char), so they can't persist as an empty username.
- Canonicalize usernames inside the user repository (store lowercase on
  createBootstrapping; findByUsername matches lower(username)) so identity is
  case-insensitive regardless of caller casing and aligned with the unique index.
- Align session GC with the auth lookup: deleteExpired now removes `expiresAt <=
  now` (a session expiring exactly at `now` reads as expired, so GC reclaims it).
- Guard migration 0008 with a preflight DO block that raises a clear error if
  pre-existing case-colliding usernames would break CREATE UNIQUE INDEX.
- Upload page distinguishes an /auth/me error from a logged-out user (no more
  anonymous CTA blocking a possibly-signed-in user on a transient auth failure).
- Auth query: shorter staleTime + refetch-on-focus so logout/expiry elsewhere is
  picked up promptly and edit gates re-evaluate.
- Tests: await the `.rejects` assertions (they could pass without running);
  add a concurrency test proving the advisory-locked bootstrap yields exactly
  one admin, a case-insensitive username lookup test, and a 422 whitespace-
  username test.

Intentionally kept: login/register still return the session token in the body —
that's the approved dual-transport design so non-browser (Bearer) clients can
obtain a token; the web ignores it and uses the httpOnly cookie. Deferred to
PR B: wrapping user+session creation in one transaction (rare, recoverable —
the account exists and the user can log in; a clean fix needs transaction-aware
repositories).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@deckyfx

deckyfx commented Jul 1, 2026

Copy link
Copy Markdown
Owner Author

Addressed the CodeRabbit review in cfad222. 7 findings + the nitpick are fixed (whitespace-username rejection, repo-level username canonicalization + case-insensitive lookup, deleteExpired <= cutoff, migration 0008 preflight guard, upload auth-error branch, auth refetch-on-focus, awaited .rejects assertions, plus a concurrency test proving the advisory-locked bootstrap picks exactly one admin). Full suite 225 pass / 0 fail, typecheck + boundaries clean, and the migration guard verified to raise on real case-collisions.

Two findings I deliberately did not change:

  • "Don't return the session token from register/login" (Major). This is intentional: the token in the JSON body is the approved dual-transport design so non-browser clients can authenticate via Authorization: Bearer. Removing it would leave API/script/mobile callers with no way to obtain a token (they can't read the httpOnly cookie). The web flow ignores the returned token and relies on the cookie. When PR B adds dedicated long-lived API keys, we can revisit making session login cookie-only.

  • "Make signup atomic with session creation" (Major). Deferred to PR B. The failure mode (user row commits, then sessions.create fails) is rare and recoverable — the account exists, so the user can simply log in. A correct fix spans two repositories in one transaction, which requires transaction-aware repositories (a broader refactor than this PR's scope).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/db/src/repositories/user-repository.ts (1)

13-17: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Canonicalization is still split across repository entrypoints.

The new repository-level contract says usernames are canonicalized here, but only createBootstrapping() lowercases before insert. create() still persists caller casing, so seeded/admin-created rows can bypass the invariant that findByUsername() now assumes. Either canonicalize create() too or narrow this contract to the bootstrapping path.

Suggested alignment
 async create(input) {
-  const [row] = await db.insert(users).values(input).returning();
+  const [row] = await db
+    .insert(users)
+    .values({ ...input, username: input.username.toLowerCase() })
+    .returning();
   if (!row) {
     throw new Error("user insert returned no row");
   }
   return row;
 },

Also applies to: 57-59, 73-79

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/repositories/user-repository.ts` around lines 13 - 17, The
username canonicalization contract in UserRepository is inconsistent across
entrypoints: createBootstrapping() lowercases input, but create() still stores
caller casing while findByUsername() assumes lowercased identities. Update
UserRepository.create() to canonicalize usernames the same way as the
bootstrapping path, or else tighten the repository comment/contract to apply
only to createBootstrapping() and keep findByUsername() aligned with that
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/db/src/repositories/user-repository.ts`:
- Around line 13-17: The username canonicalization contract in UserRepository is
inconsistent across entrypoints: createBootstrapping() lowercases input, but
create() still stores caller casing while findByUsername() assumes lowercased
identities. Update UserRepository.create() to canonicalize usernames the same
way as the bootstrapping path, or else tighten the repository comment/contract
to apply only to createBootstrapping() and keep findByUsername() aligned with
that behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4f61d9e6-5a45-4039-bf96-add799912a30

📥 Commits

Reviewing files that changed from the base of the PR and between 822a79c and cfad222.

📒 Files selected for processing (9)
  • apps/api/src/server.ts
  • apps/api/test/server.test.ts
  • apps/web/src/lib/auth.ts
  • apps/web/src/routes/upload.tsx
  • packages/core/test/auth-service.test.ts
  • packages/db/drizzle/0008_canonical_username_unique.sql
  • packages/db/src/repositories/session-repository.ts
  • packages/db/src/repositories/user-repository.ts
  • packages/db/test/auth-repositories.test.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • apps/web/src/routes/upload.tsx
  • packages/core/test/auth-service.test.ts
  • apps/web/src/lib/auth.ts
  • packages/db/src/repositories/session-repository.ts
  • apps/api/test/server.test.ts
  • apps/api/src/server.ts

Follow-up to the PR #18 review: `create()` stored the caller's casing while
`createBootstrapping()` lowercased and `findByUsername()` matched
`lower(username)` — an inconsistent contract. `create()` now lowercases the
username too, so all write paths store the canonical form the doc/index promise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@deckyfx
deckyfx merged commit 38b4bd3 into main Jul 1, 2026
2 checks passed
@deckyfx
deckyfx deleted the feat/auth-sessions branch July 1, 2026 08:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant