# Phase 0 Research: Identity and Authentication ## Decision: Finish the existing scaffold's own intended design, not a new one - **Decision**: `User`/`UserRole`, the two seeded-but-passwordless demo accounts, and `AuthUser`/`JwtPayload` in `src/common/types` are the real target — this feature adds a `passwordHash` column, replaces `identity/auth`'s email-only stub with real password verification and JWT issuance, and makes `fastify.authenticate` actually verify that JWT. - **Rationale**: Every shape needed (the JWT payload's exact fields, the user/role model, even the bootstrap accounts) was already scaffolded before this session's spec-driven rebuild began — this is the same "give an existing, unwired scaffold its first real implementation" pattern every other phase in this codebase has followed, not a new design decision. - **Alternatives considered**: A separate, purpose-built `Session`/`Credential` model instead of extending `User` — rejected; `User` already has exactly the fields a staff account needs (email, name, role), and doc 06 never defined a competing entity for this. ## Decision: reuse the existing, already-required `JWT_SECRET` env var — don't invent a new one - **Decision**: Token signing/verification uses `env.JWT_SECRET` — a `z.string().min(16)`, no-default, required environment variable already defined in `src/config/env.ts` and already set in `.env.test`/`.env.example`/`vitest.config.ts` since before this session's spec-driven rebuild began. This feature adds no new secret env var, only `AUTH_TOKEN_LIFETIME_HOURS` (a non-secret, defaultable number). - **Rationale**: Same "finish the scaffold's own intended design" pattern as `User`/ `JwtPayload` themselves — `JWT_SECRET` was clearly provisioned for exactly this feature and has simply never been read by any code until now. - **Alternatives considered**: A feature-specific `AUTH_JWT_SECRET` — considered and rejected once `JWT_SECRET` was found; would create two secrets doing the identical job. ## Decision: `jsonwebtoken` for signing/verifying, `bcryptjs` for password hashing - **Decision**: Add `jsonwebtoken` (plain library, no Fastify plugin registration — kept consistent with `auth.plugin.ts`'s existing manual-decorator style rather than introducing the `@fastify/jwt` plugin ecosystem) and `bcryptjs` (pure JavaScript, no native compilation step — `bcrypt`/`argon2`'s native bindings are a real source of friction on this team's Windows dev environment, confirmed earlier this session when Docker/Prisma tooling already needed workarounds for the same class of platform friction). - **Rationale**: Both are the standard, widely-used choice for their job; `bcryptjs` specifically avoids re-litigating the native-module build problems this session has already hit more than once on Windows. - **Alternatives considered**: `@fastify/jwt` — rejected only for consistency with this codebase's existing hand-rolled decorator style, not a correctness concern. `argon2` — rejected for the same native-build-friction reason as `bcrypt`; `bcryptjs` is a well- established, secure-enough choice for this scale (JWT `Vitest`-verified via existing precedent, not a cryptographic novelty). ## Decision: Redis-backed revocation denylist, reusing 002's own jti-tracking mechanism - **Decision**: `JwtPayload` gains a `jti` (JWT ID, a random UUID per issued token). Logout adds that `jti` to a Redis key (`auth:revoked:`) with a TTL equal to the token's own remaining lifetime. `fastify.authenticate` checks this key (in addition to verifying the signature and expiry) before accepting a token. - **Rationale**: This is the exact same shape as 002's `hasSeenJti`/`markJtiSeen` replay- protection mechanism (`src/infrastructure/cache`) — reused directly rather than inventing a second Redis-backed token-tracking pattern. A TTL equal to remaining lifetime means the denylist entry is automatically cleaned up and never grows unbounded. - **Alternatives considered**: A full server-side session table (every issued token recorded in Postgres, checked on every request) — rejected as unnecessary weight; spec.md's own Assumptions explicitly chose "short-lived JWT + revocation-on-logout-only" over full session tracking, and Redis is already the right tool for this exact shape of check (fast, TTL-native). ## Decision: A 4-hour token lifetime - **Decision**: Issued JWTs expire 4 hours after issuance (`exp` claim). - **Rationale**: Long enough that a working agent isn't repeatedly forced to re-authenticate mid-shift, short enough that a leaked/forgotten token's exposure window is bounded in hours, not days — a reasonable default for an internal staff tool with no remember-me/refresh-token flow in this feature's scope (Assumptions: no MFA/hardening pass yet either). - **Alternatives considered**: A refresh-token pair (short-lived access token + long-lived refresh token) — rejected as more mechanism than this feature's scope calls for; nothing in spec.md's user stories requires silent re-authentication, and it can be added later without breaking the token shape this feature establishes. ## Decision: `fastify.authenticate` also populates the existing, already-shared `reqContext.actorId`/`actorType` - **Decision**: On a valid token, `fastify.authenticate` sets `request.user` (the full `AuthUser`) AND `request.reqContext.actorId = user.id`, `request.reqContext.actorType = ActorType.USER` — the same two `RequestContext` fields `authenticateProductIntegration` already populates for customer-originated requests (002). - **Rationale**: Every module from 007 onward already reads `request.reqContext?.actorId ?? 'unknown'` in its controllers (`actorFrom(request)` helpers in assignments, tickets, escalation, resolutions) expecting exactly this to eventually be populated by a real staff auth mechanism — this was a forward-compatible convention already in place, not something this feature needs to change call sites for. Every one of those audit trails ( `AssignmentHistory.actor`, `EscalationEvent.triggeredBy`, etc.) becomes accurate for real agent/admin actions the moment this feature ships, with no changes to 007-009's own code. - **Alternatives considered**: A separate `request.user`-only convention, leaving `reqContext .actorId` customer-only — rejected; would require touching every existing `actorFrom` call site across four already-shipped features for no benefit, when the field was clearly designed to be auth-mechanism-agnostic from the start. ## Decision: Role-gating via a `requireRole(...roles)` preHandler factory, not a fixed decorator - **Decision**: A new exported function, `requireRole(...allowedRoles: string[])`, returns a Fastify preHandler that checks `request.user?.role` against the given list, throwing `AuthorizationError` (403) if it doesn't match — used as `{ preHandler: [fastify.authenticate, requireRole('ADMIN')] }`. Not a fixed `fastify.requireAdmin` decorator, even though `ADMIN` is the only role checked today. - **Rationale**: A factory function generalizes to any future role/permission check (e.g. a hypothetical `SENIOR_AGENT`) without a new decorator per role; `fastify.authenticate` and `requireRole` compose as two separate preHandlers, matching this codebase's existing `[fastify.authenticateProductIntegration, fastify.checkIntegrationRateLimit]` two-step preHandler-array convention exactly. - **Alternatives considered**: A single combined `fastify.authenticateAdmin` decorator — rejected; would duplicate `fastify.authenticate`'s own token-verification logic for every new role instead of composing with it. ## Decision: `Agent.userId` is added as a nullable link, but linking is not this feature's own workflow - **Decision**: `Agent` gains `userId String? @unique`, a nullable FK to `User.id` — the schema capability to say "this login identity's routing/skills profile is that `Agent` row" — but this feature does not add an endpoint or admin screen to set it. No seed data links the demo agent account to an `Agent` row either (none is seeded for it today). - **Rationale**: While investigating account creation (User Story 4), a real gap surfaced: a `User` (the thing that logs in) and an `Agent` (the thing 006/007 route tickets to) have never been connected — an `AGENT`-role `User` today has no way to be identified as a *specific* `Agent` for "tickets assigned to me"-style queries supporthub-web's own agent dashboard will need. Adding the column now is cheap and unblocks that later without a schema change at that point; building the actual linking workflow (which almost certainly belongs in 006's `identity/agents` admin screens, alongside team/skill assignment, not this identity/auth feature) is a real, separate piece of scope this feature doesn't need to solve today. - **Alternatives considered**: Building the full link-an-account-to-an-agent workflow as part of this feature — rejected as scope creep; this feature's own job is proving a `User` *can* authenticate and be authorized, not completing every downstream consumer of that identity. Making `Agent.userId` required — rejected; an `Agent` created via 006's existing screens has no `User` account requirement today and shouldn't suddenly need one just because this feature exists. ## Decision: Which existing routes get gated is unchanged — only the gate itself becomes real - **Decision**: This feature does not add `fastify.authenticate` to any route that doesn't already have it, and does not add `requireRole('ADMIN')` to every existing admin route as part of this feature's own implementation — SC-002 is satisfied by demonstrating the mechanism works on a representative sample (one action per module), with the mechanical work of adding `requireRole('ADMIN')` to every remaining `/admin/*` route across 002-009 tracked as this feature's own Polish-phase task, not a scope expansion into re-designing any other feature's authorization model. - **Rationale**: FR-006 is explicit: "this feature does not change which routes are gated, only makes the gate real." Deciding which of the many already-shipped admin routes should be admin-only vs. any-authenticated-agent is a real per-route judgment call (e.g., should an agent be able to create a hierarchy node? almost certainly not; should an agent read one? probably yes) — this feature makes that judgment call possible to enforce, and applies it everywhere in its own Polish phase, but doesn't silently redesign any other feature's own intended access model beyond what's obviously admin-only (write/config endpoints) vs. read/agent-usable. - **Alternatives considered**: Leaving every existing route exactly as `fastify.authenticate`- only (no `requireRole`) and treating role-based gating as entirely out of scope — rejected; spec.md's own User Story 2/FR-005 explicitly requires admin-only enforcement to exist somewhere concrete, not just as an available-but-unused mechanism.