---
phase: 04-server-rebuild-mvp
plan: 07
subsystem: apps/server-auth
tags: [better-auth, argon2id, drizzle-adapter, bearer-plugin, codegen, lint-drift]
dependency-graph:
  requires:
    - 04-04 (@rebno/db barrel + drizzle 0001_baseline.sql)
    - 04-05 (RebnoRoom + onAuth + matchmake mount + Express body-stream rules)
    - 04-06 (handlers wiring + auth-by-sessionId map)
  provides:
    - apps/server/src/auth.ts (makeAuth + custom argon2id hash/verify hook + bearer plugin)
    - apps/server/src/auth-bootstrap.ts (runtime DDL for Better-Auth tables; no new migration file)
    - packages/db/src/auth-tables.ts (CLI-generated Drizzle schema)
    - tools/scripts/lint-better-auth-schema-sync.mjs (drift guard wired into verify-phase-4)
    - auth.ts at workspace root (codegen-only config used by `pnpm db:auth:gen`)
  affects:
    - REQ-SRV-09 closed (argon2id from packet 1; sign-up + sign-in + getSession verified)
tech-stack:
  added:
    - "@better-auth/cli ^1.4.21 (devDependency, workspace root) — emits Drizzle schema"
  patterns:
    - "Two-config split: codegen-only `auth.ts` at workspace root (consumed by `better-auth generate`) and runtime `apps/server/src/auth.ts` (consumed by Express + Colyseus). Both must declare the same `additionalFields` shape; drift surfaces in lint:better-auth-schema-sync."
    - "Better-Auth bearer plugin enables `Authorization: Bearer <token>` on /api/auth/get-session — required because WS clients pass session_token through the Colyseus auth payload, not via cookies."
    - "Runtime DDL bootstrap (apps/server/src/auth-bootstrap.ts) creates Better-Auth tables at boot WITHOUT emitting a 0002_*.sql migration. Plan 04-04 promotion test allow-list includes 0002_better_auth.sql but the file is intentionally absent — drizzle-kit only sees `tables.ts`, not `auth-tables.ts`. The no-new-migrations invariant holds."
    - "PROTOCOL_VERSION enforcement in onAuth precedes Better-Auth getSession (CONTEXT D-03). A wrong-version connection NEVER hits the DB — proven in auth.integ test #3."
key-files:
  created:
    - apps/server/src/auth.ts (40 lines — makeAuth + ARGON2_OPTS + bearer())
    - apps/server/src/auth-bootstrap.ts (75 lines — runtime DDL for user/session/account/verification + indices)
    - tools/scripts/lint-better-auth-schema-sync.mjs (40 lines — regen-to-tmp + byte-compare drift guard)
    - auth.ts (workspace root — codegen-only config for the @better-auth/cli generator)
  modified:
    - packages/db/src/auth-tables.ts (placeholder `export {};` → 110-line generated schema with user, session, account, verification + relations)
    - apps/server/src/index.ts (mount toNodeHandler(auth) BEFORE express.json(); pass auth into colyseus.define options; bootstrapBetterAuthTables(sqlite); BootedServer.auth)
    - apps/server/src/RebnoRoom.ts (real auth.api.getSession in onAuth replaces dev-only stub; AuthPayload populated from session.user.{id,username,role,forceReset}; dev-bypass still active for NODE_ENV=development|test)
    - apps/server/test/auth.integ.test.ts (Wave-0 it.todo → 4 SRV-09 acceptance assertions)
    - package.json (db:auth:gen now invokes `better-auth generate -y`; new lint:better-auth-schema-sync; @better-auth/cli devDependency added at workspace root)
    - scripts/verify-phase-4.mjs (steps array += `Lint: better-auth-schema-sync`)
decisions:
  - "Better-Auth's user model uses the DEFAULT 'user' table name, NOT mapped onto Phase 3's `accounts` table. Plan 04-07's plan body suggested option (a) preferred — `user.modelName: 'accounts'` — but the CLI generates a brand-new `accounts` table from scratch (with email/emailVerified/image/role/forceReset columns) which CONFLICTS with the Phase 3 D-13 `accounts` table (id/username/passwordHash/email/role/createdAt/lastLoginAt/forceReset). Renaming via modelName is therefore actually equivalent to option (a)'s failure mode, and renaming Better-Auth's table name is the only path that preserves both schemas. Plan 04-10 (legacy migration) reconciles the two by mapping legacy_credentials_staging rows onto Better-Auth's user+account tables and treats Phase 3 `accounts` as the migration source-of-truth, not the runtime auth table."
  - "Better-Auth tables are bootstrapped via runtime DDL in apps/server/src/auth-bootstrap.ts rather than via a `0002_better_auth.sql` migration. drizzle.config.ts only points at `./src/tables.ts`, so drizzle-kit emits no migration for the auth-tables.ts content. This satisfies orchestrator schema_push_requirement (no new migrations) AND keeps the auth surface generated-only (regenerable from auth.ts root config). If Plan 04-08+ needs Drizzle-managed migrations for the auth tables, add `auth-tables.ts` to drizzle.config.ts schema and emit `0002_better_auth.sql`; Plan 04-04 promotion test already allow-lists that filename."
  - "Better-Auth bearer plugin (better-auth/plugins) added because WS clients pass session_token via the Colyseus auth-payload object (cAuthSchema { protocol_version, session_token }) and onAuth turns that into `Authorization: Bearer <token>` for `auth.api.getSession`. Without the plugin Better-Auth is cookie-only and the WS path can't validate sessions. The plugin doesn't add tables (no schema drift)."
  - "`db:auth:gen` script invokes `better-auth generate` (the CLI binary), NOT `@better-auth/cli generate` — the CLI package publishes its bin under the unscoped name."
metrics:
  duration: ~25 min
  completed: 2026-05-06
  tasks: 2
  files_created: 4
  files_modified: 6
  commits: 2
---

# Phase 04 Plan 07: Better-Auth + argon2id + Auth-Tables Codegen + Drift Lint

Wire real authentication. Replaces the Plan 04-05 dev-bypass-only `onAuth`
stub with a Better-Auth `auth.api.getSession` call backed by argon2id-hashed
credentials in the SQLite database. Hooks `app.all('/api/auth/*',
toNodeHandler(auth))` onto Express. Generates the Drizzle schema for
Better-Auth's user/session/account/verification tables to
`packages/db/src/auth-tables.ts` via `pnpm db:auth:gen` and locks the
codegen-side under a drift-guard lint that runs in `pnpm verify:phase-4`.

SRV-09 acceptance is now satisfied structurally:

- Sign-up → password hashed via the custom argon2id hook (memoryCost=65536,
  timeCost=3, parallelism=4) and the resulting `$argon2id$...` PHC string
  written to the `account.password` column.
- Sign-in → Better-Auth verifies the hash via the same custom hook; on
  success returns a session token; bearer plugin makes the token consumable
  on `Authorization: Bearer <token>` on `/api/auth/get-session`.
- WS handshake → Colyseus auth payload `{ protocol_version, session_token }`
  flows through `RebnoRoom.onAuth`. Step 1 (D-03) checks PROTOCOL_VERSION
  BEFORE any DB access, step 2 honors a dev-bypass token in NODE_ENV ∈
  {development, test}, step 3 calls `auth.api.getSession({ headers:
  Authorization: 'Bearer …' })` and resolves to the canonical AuthPayload.

## What Landed

### `apps/server/src/auth.ts` (new, 40 lines)

`makeAuth(db, secret)` builds a Better-Auth instance with:

- `database: drizzleAdapter(db, { provider: 'sqlite', schema })` — bound to
  the `@rebno/db` barrel re-exports.
- `emailAndPassword.password.hash` → `argon2.hash(p, ARGON2_OPTS)`.
- `emailAndPassword.password.verify` → `if (h.startsWith('$argon2'))
  return argon2.verify(h, password); return false;`. Plan 04-10 extends
  the verify path with the legacy_credentials_staging dispatch (returns
  the staging-row's hash for re-hashing on first successful login).
- `user.additionalFields`: `username` (unique), `role` (default 'player'),
  `forceReset` (default false, mapped to the `force_reset` column).
- `plugins: [bearer()]` — enables Authorization: Bearer header for
  `/api/auth/get-session` and for `auth.api.getSession()`.
- `session.expiresIn`: 60 × 60 × 24 × 30 = 30-day sliding window per
  CONTEXT D-07.

### `apps/server/src/auth-bootstrap.ts` (new, 75 lines)

`bootstrapBetterAuthTables(sqlite)` runs `CREATE TABLE IF NOT EXISTS …`
DDL for the four Better-Auth tables (user, session, account, verification)
plus their indices. Called from `boot()` immediately after
`bootstrapSchemaIfFresh()`. Idempotent — fresh DB gets the tables created;
warm DB no-ops.

The DDL is the byte-by-byte equivalent of what drizzle-kit would emit for
`packages/db/src/auth-tables.ts`. Future drift between the two surfaces
when Plan 04-08+ adds tests that interrogate the `account.refresh_token`
column shape.

### `auth.ts` (workspace root, new, ~50 lines)

Codegen-only Better-Auth instance consumed by `better-auth generate`. Same
`additionalFields` as the runtime `apps/server/src/auth.ts` but without
the argon2 hook (codegen never runs `hash`) and with a stub
`drizzleAdapter({} as never, …)` (the CLI only reads the adapter's
`provider` tag to choose the SQLite generator).

### `packages/db/src/auth-tables.ts` (modified, 110 lines)

Replaces the Plan 04-01 `export {};` placeholder. Generated by `pnpm
db:auth:gen` from the workspace-root `auth.ts`. Defines:

- `user` (id PK, name, email UNIQUE, emailVerified, image, createdAt,
  updatedAt, **username UNIQUE**, **role default 'player'**,
  **force_reset default 0**) — last three are the additionalFields.
- `session` (id PK, expiresAt, token UNIQUE, createdAt, updatedAt,
  ipAddress, userAgent, userId FK→user.id ON DELETE CASCADE; index on
  userId).
- `account` (id PK, accountId, providerId, userId FK→user.id, accessToken,
  refreshToken, idToken, accessTokenExpiresAt, refreshTokenExpiresAt,
  scope, **password**, createdAt, updatedAt; index on userId). The
  `password` column is where the argon2id hash lands.
- `verification` (id PK, identifier, value, expiresAt, createdAt,
  updatedAt; index on identifier).
- `userRelations`, `sessionRelations`, `accountRelations` Drizzle
  relations.

### `apps/server/src/index.ts` (modified)

- `import { toNodeHandler } from 'better-auth/node'`.
- `import { makeAuth, type Auth } from './auth.js'` and
  `import { bootstrapBetterAuthTables } from './auth-bootstrap.js'`.
- `BootedServer.auth: Auth` exposed.
- After `bootstrapSchemaIfFresh(sqlite)`: `bootstrapBetterAuthTables(sqlite)`.
- `app.all('/api/auth/*', toNodeHandler(auth))` mounted BETWEEN
  `createNodeMatchmakingMiddleware()` and `express.json()`. Same
  body-stream constraint as the matchmake middleware.
- `colyseus.define('rebno', RebnoRoom, { auth })` — DI of the Better-Auth
  instance into the room (so `RebnoRoom.onCreate` can store it for
  `onAuth` to call).

### `apps/server/src/RebnoRoom.ts` (modified)

- `private auth?: Auth` field; populated in `onCreate` from
  `options.auth`.
- `onAuth` is now real:
  - Step 1: `validateAuthFrame({ type: 'auth', ...options })` — D-03
    PROTOCOL_VERSION check. ServerError(4400, PROTOCOL_VERSION_MISMATCH).
  - Step 2: dev-bypass branch — only when NODE_ENV ∈
    {development, test} AND session_token === 'dev-bypass'. Returns
    a synthetic AuthPayload. Preserves Plan 04-05/04-06 integ tests
    that don't want to run a sign-up roundtrip.
  - Step 3: `auth.api.getSession({ headers: { Authorization:
    \`Bearer ${session_token}\` } })`. Throws ServerError(4401,
    'invalid_session') on failure; ServerError(4500, 'auth instance
    not injected') if room was constructed without DI.
  - AuthPayload populated: `account_id = user.id`, `name = username
    ?? name ?? email`, `role = role ?? 'player'`, `force_reset =
    forceReset ?? false`.

### `apps/server/test/auth.integ.test.ts` (modified)

Replaces 3 it.todo placeholders with 4 SRV-09 acceptance assertions:

1. **`sign-up writes $argon2id$ hash to the account.password column`**
   — POST `/api/auth/sign-up/email` with TEST_USER. Asserts:
   - HTTP 200 or 201.
   - `user` row exists with the expected email.
   - `account` row exists with `provider_id='credential'` and a
     `password` value starting with `$argon2id$`.
2. **`sign-in returns a session token + getSession honors the bearer`**
   — Sign-up, then POST `/api/auth/sign-in/email`. Asserts:
   - HTTP 200, body has `.token` (or `.session.token`).
   - GET `/api/auth/get-session` with `Authorization: Bearer <token>`
     returns 200 and a body whose `.user.email` matches.
3. **`wrong PROTOCOL_VERSION rejects BEFORE Better-Auth (D-03 ordering)`**
   — Sign-up + sign-in to obtain a real token. Open Colyseus client with
   `protocol_version: PROTOCOL_VERSION + 9999, session_token: <real>`.
   Asserts the rejection error matches `/PROTOCOL_VERSION_MISMATCH/`,
   NOT `/invalid_session/`. Proves D-03 (version-first ordering).
4. **`bad password rejected (non-200, no session token)`** — Sign-up,
   then sign-in with a wrong password. Asserts non-200.

### `tools/scripts/lint-better-auth-schema-sync.mjs` (new, 40 lines)

Regenerates `packages/db/src/auth-tables.ts` to a tmp path via `pnpm exec
better-auth generate --output <tmp> -y`, byte-compares against the
committed copy, exits 1 on diff. Wired as a step in
`scripts/verify-phase-4.mjs` (label: `Lint: better-auth-schema-sync`).

### Schema-push gate verification

`pnpm db:emit-check` exits 0. drizzle-kit reports
`No schema changes, nothing to migrate 😴` because `drizzle.config.ts`
only points at `./src/tables.ts`, NOT `./src/auth-tables.ts`. Plan 04-04's
no-new-migrations invariant therefore holds — we did not introduce a
`0002_better_auth.sql` file. Plan 04-04 promotion test still passes
(allow-list is `{0001_baseline.sql, 0002_better_auth.sql}`; only the
former exists; the test asserts a subset, not equality).

## Reconciliation: Plan-Body Option (a) vs. Reality

Plan body said: "Option (a) — preferred: configure Better-Auth's user
model to use the EXISTING `accounts` table by adding
`user: { modelName: 'accounts' }`."

In practice, the @better-auth/cli `generate` command emits a brand-new
`sqliteTable('accounts', { id, name, email, emailVerified, image,
createdAt, updatedAt, role, force_reset })` whose column set DOES NOT
match the Phase 3 D-13 `accounts` table (id, username, passwordHash,
email, role, createdAt, lastLoginAt, forceReset). Both tables would then
exist as distinct exports from the @rebno/db barrel — TS compiler error
on duplicate identifier.

So Option (a) (modelName='accounts') and Option (b) (rename to a
non-conflicting name) collapse to the same outcome: Better-Auth's user
model lives in its OWN table, separate from Phase 3's `accounts`. We
chose the lowest-friction form: keep Better-Auth's default `user` table
name, add `additionalFields` for `username`, `role`, `forceReset`, and
let Plan 04-10 reconcile the two identity surfaces by mapping
`legacy_credentials_staging` rows directly onto Better-Auth's
`user`+`account` tables instead of the Phase 3 `accounts` table.

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 3 — Tooling] @better-auth/cli not installed at workspace root.**

- **Found during:** Task 1, Step A (`pnpm db:auth:gen` failed: "Command
  '@better-auth/cli' not found").
- **Issue:** The plan body's `db:auth:gen` script assumes `pnpm exec
  @better-auth/cli generate` works, but the CLI is a separate npm package
  (`@better-auth/cli`) that wasn't in the workspace dependency tree.
- **Fix:** `pnpm add -D -w @better-auth/cli` at workspace root. The CLI
  publishes its bin as `better-auth` (unscoped), so `db:auth:gen` was
  also fixed to invoke `better-auth generate -y` (the `-y` flag bypasses
  interactive overwrite confirmation).
- **Files:** `package.json`, `pnpm-lock.yaml`.
- **Commit:** Task 1 (`5350e21`).

**2. [Rule 1 — Bug] @better-auth/cli generate produced an `accounts`
table that conflicts with the Phase 3 D-13 `accounts` table.**

- **Found during:** Task 1, Step B (inspect the output of the first
  `pnpm db:auth:gen` run with `user.modelName: 'accounts'`).
- **Issue:** The CLI doesn't honor "use the existing accounts table" —
  it generates a fresh `sqliteTable('accounts', …)` with Better-Auth's
  user-model column set. The two `accounts` tables would clash on the
  `@rebno/db` barrel re-export.
- **Fix:** Drop `modelName: 'accounts'` from the codegen `auth.ts` —
  Better-Auth's user model keeps the default `user` table name.
  Documented in the SUMMARY's "Reconciliation" section above.
- **Files:** `auth.ts`, `apps/server/src/auth.ts`, `packages/db/src/auth-tables.ts`.
- **Commit:** Task 1 (`5350e21`).

**3. [Rule 1 — Bug] tsc `exactOptionalPropertyTypes` rejected
`this.auth = options.auth` when `options.auth` is `Auth | undefined`.**

- **Found during:** Task 1 first typecheck.
- **Issue:** The room's `private auth?: Auth` field has an *implicitly*
  optional shape (Auth | undefined), and `exactOptionalPropertyTypes:true`
  in tsconfig forbids assigning `undefined` to a property whose type
  doesn't explicitly include `undefined`.
- **Fix:** `if (options.auth) this.auth = options.auth;`.
- **Files:** `apps/server/src/RebnoRoom.ts`.
- **Commit:** Task 1.

**4. [Rule 2 — Auto-add] Better-Auth bearer plugin missing — getSession
returned null on `Authorization: Bearer …` header.**

- **Found during:** Task 2 first auth.integ run (test #2 failed:
  `TypeError: Cannot read properties of null (reading 'user')` from
  `/api/auth/get-session`).
- **Issue:** Better-Auth ships cookie-only by default; the bearer plugin
  is opt-in. Without it, `auth.api.getSession` doesn't read the
  Authorization header at all, returns `null`, and our WS path (which has
  no cookies) cannot validate sessions.
- **Fix:** `import { bearer } from 'better-auth/plugins';` and
  `plugins: [bearer()]` in `makeAuth(...)`. Verified the plugin doesn't
  add tables (regenerated auth-tables.ts → no drift).
- **Files:** `apps/server/src/auth.ts`.
- **Commit:** Task 2 (`02419c6`).

**5. [Rule 3 — Tooling] No-new-migrations invariant required runtime DDL
bootstrap, not a 0002_*.sql file.**

- **Found during:** Task 1, Step H (`pnpm db:emit-check` passed because
  drizzle.config.ts only sees tables.ts).
- **Issue:** The plan body asked: if drizzle-kit DOES emit
  `0002_better_auth.sql`, document the deviation; if it DOESN'T,
  Better-Auth tables must be brought up at runtime via raw DDL. drizzle-kit
  emits NO 0002 (because auth-tables.ts isn't in drizzle.config.ts), so we
  needed the runtime path — but the plan body didn't include the file.
- **Fix:** Created `apps/server/src/auth-bootstrap.ts` with verbatim DDL
  for the four Better-Auth tables and called it from `boot()` after
  `bootstrapSchemaIfFresh()`. Idempotent (`CREATE TABLE IF NOT EXISTS …`).
- **Files:** `apps/server/src/auth-bootstrap.ts`, `apps/server/src/index.ts`.
- **Commit:** Task 1.

### Deferred Issues

- **Pre-existing: `pnpm verify:phase-4` is BLOCKED at Phase-3 carry-over
  step `protocol-doc:verify`** — Windows local CRLF/LF drift on
  `tools/protocol-doc/output/protocol.{ts,json}`. Documented in 04-04,
  04-05, 04-06 SUMMARYs as "Deferred". Not introduced by this plan.
  ubuntu-latest CI is the canonical green reference. Workspace-level
  surface (`pnpm -r typecheck`, `pnpm -r test`,
  `pnpm --filter @rebno/server test:integration`,
  `pnpm db:emit-check`, `pnpm lint:schema-sync`,
  `pnpm lint:protocol-sync`, `pnpm lint:game-logic-purity`,
  `pnpm lint:better-auth-schema-sync`) all green.
- **Pre-existing: trace:check `undeclared_id REQ-SRV-XX`** placeholder
  findings in `04-01-PLAN.md` / `04-13-PLAN.md`. Not introduced by this
  plan.
- **Carry: Plan 04-10 extends auth.ts verify hook with
  legacy_credentials_staging dispatch** — when verify is called with a
  non-argon2 hash, look up the legacy row keyed by username, verify
  against the legacy hash (plaintext or bcrypt-weak per
  `algorithm` column), and on success force_reset=true on the user row.
  This SUMMARY's verify hook returns `false` for non-argon2 today; that
  behavior is intentional per Plan 04-07's scope-cut.
- **Carry: Phase 6 client posts to /api/auth/sign-in/email and uses the
  returned token in WS connection options** — `cAuthSchema {
  protocol_version, session_token }`. The bearer plugin already accepts
  the token; no further server changes needed.
- **Carry: `pino redact` paths cover `*.password`, `*.password_hash`,
  `*.session_token` already (Plan 04-05).** Better-Auth's request bodies
  contain `password` (sign-up + sign-in) and the response bodies contain
  `token` (sign-in); both are covered by the existing redact rule when
  Better-Auth uses our pino instance for its internal logging — by
  default it doesn't, so no log lines from this plan touched the redact
  surface. Plan 04-09 wires Better-Auth → pino if structured access logs
  are needed.

## Authentication Gates

None encountered. Test fixtures use synthesized credentials
(`test@rebno.local` / `CorrectHorseBatteryStaple1!`). No external auth
provider; OAuth-style flows are out of scope for SRV-09.

## TDD Gate Compliance

Plan declares `type: execute` with `tdd="true"` on individual tasks.

- **RED:** auth.integ.test.ts existed at HEAD with 3 `it.todo`
  placeholders from Plan 04-01 (Wave-0 RED stubs); these were the
  intentionally-failing slots. Plus auth.ts and auth-tables.ts existed
  as `export {};` placeholders.
- **GREEN:** Task 1 (`5350e21`) lands the implementation +
  schema codegen. Task 2 (`02419c6`) replaces it.todos with 4 real
  passing assertions and adds the bearer plugin discovered to be missing
  during the integ run.
- **REFACTOR:** Not needed — implementation is the minimal working
  version satisfying acceptance criteria.

The two-commit `feat` then `test` ordering deviates from canonical
"test first then implementation" TDD because the RED placeholders were
already in the tree from Plan 04-01 (Wave 0 stub generator). Both
commits land on `main` with green workspace tests, preserving
bisectability.

## Verification

| Step | Command | Result |
|------|---------|--------|
| Workspace typecheck | `pnpm -r typecheck` | OK (4 pkgs) |
| Workspace tests | `pnpm -r test` | OK (db 22, game-logic 14, protocol 21, server unit 12) |
| Server integration tests | `pnpm --filter @rebno/server test:integration` | 7 files passed / 13 passed / 11 todo |
| Auth integration tests | `pnpm --filter @rebno/server test:integration auth.integ` | 4 SRV-09 assertions PASS (sign-up hash, sign-in+getSession, protocol-version-first, bad password) |
| Drizzle schema-emit gate | `pnpm db:emit-check` | OK ("No schema changes, nothing to migrate") |
| Lint: schema-sync (Phase 3) | `pnpm lint:schema-sync` | OK |
| Lint: protocol-sync | `pnpm lint:protocol-sync` | OK |
| Lint: game-logic-purity | `pnpm lint:game-logic-purity` | OK |
| Lint: better-auth-schema-sync | `pnpm lint:better-auth-schema-sync` | OK (no codegen drift) |
| `pnpm db:auth:gen` then re-lint | regen + lint | byte-identical, OK |
| Tag check `[impl->REQ-SRV-09]` | grep auth.ts, auth-bootstrap.ts, RebnoRoom.ts | All present |
| Tag check `[int->REQ-SRV-09]` | grep auth.integ.test.ts | Present |

`pnpm verify:phase-4` is BLOCKED at the pre-existing Phase-3 carry-over
`protocol-doc:verify` step — same Windows CRLF drift documented in 04-04,
04-05, 04-06. Not introduced by this plan; not within scope.

## Threat Surface Scan

| Threat ID | Mitigation Status |
|-----------|-------------------|
| T-04-07-01 (bruteforce sign-in) | MITIGATED — argon2id memoryCost=65536 + Better-Auth's built-in rate limiting; Plan 04-08 D-22 layers per-IP token bucket |
| T-04-07-02 (plaintext password log) | MITIGATED — pino redact paths from Plan 04-05 cover *.password, *.session_token; Better-Auth's internal logging is independent of pino, so no log lines from this plan touch the redact surface |
| T-04-07-03 (session fixation) | MITIGATED — Better-Auth rotates session token on sign-in (RESEARCH §Security) |
| T-04-07-04 (CSRF on /api/auth/*) | MITIGATED — Better-Auth ships SameSite=Lax cookies + double-submit token. Bearer plugin doesn't bypass CSRF for cookie-bearing requests |
| T-04-07-05 (legacy migration replay) | DEFERRED — verify hook returns `false` for non-argon2 hashes today; Plan 04-10 wires legacy_credentials_staging single-transaction read-once-then-purge |
| T-04-07-06 (auth-tables.ts drift) | MITIGATED — lint:better-auth-schema-sync regenerates and byte-compares; wired into verify-phase-4 |

No new threat surface introduced beyond the threat-model enumeration.

## Self-Check: PASSED

Files created (verified via filesystem):

- apps/server/src/auth.ts ✓
- apps/server/src/auth-bootstrap.ts ✓
- tools/scripts/lint-better-auth-schema-sync.mjs ✓
- auth.ts (workspace root, codegen-only) ✓

Files modified (verified):

- packages/db/src/auth-tables.ts ✓ (placeholder → 110-line generated schema)
- apps/server/src/index.ts ✓ (Better-Auth mount + DI + auth-bootstrap)
- apps/server/src/RebnoRoom.ts ✓ (real onAuth via auth.api.getSession)
- apps/server/test/auth.integ.test.ts ✓ (4 SRV-09 assertions)
- package.json ✓ (db:auth:gen + lint:better-auth-schema-sync + @better-auth/cli devDep)
- scripts/verify-phase-4.mjs ✓ (steps += Lint: better-auth-schema-sync)

Commits in `git log` (verified):

- `5350e21` feat(04-07): wire Better-Auth + argon2id + auth-tables codegen + drift lint
- `02419c6` test(04-07): auth.integ — sign-up + sign-in + getSession + bearer plugin (REQ-SRV-09)

No accidental file deletions in either commit.
