Phase 0 research resolves the library choices (jsonwebtoken + bcryptjs, chosen partly to avoid native-build friction on Windows dev environments), the Redis-backed revocation-denylist shape (reusing 002's own jti-replay-protection pattern exactly), a 4-hour token lifetime, and why fastify.authenticate populating the already-shared reqContext.actorId/actorType retroactively makes every audit trail since 007 accurate for real agent/admin actions instead of always 'unknown'. Also surfaces and scopes a real gap found along the way: User (login identity) and Agent (routing/skills profile) have never been linked. Adds Agent.userId as a nullable FK now (cheap, additive) without building the actual linking workflow, which belongs in 006's own identity/agents admin screens as a later, separate piece of work. Phase 1 adds data-model.md, the login/self-identity/account-creation/ logout contract, and five quickstart scenarios including a specific requirement to re-verify at least one already-shipped admin route per module (002-009), not just this feature's own new endpoints. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
3.7 KiB
3.7 KiB
Data Model: Identity and Authentication
User (modified — two additive columns)
| Field | Type | Notes |
|---|---|---|
id |
String @id @default(uuid()) |
unchanged |
email |
String @unique |
unchanged |
name |
String |
unchanged |
role |
UserRole @default(CUSTOMER) |
unchanged enum (ADMIN | AGENT | CUSTOMER) — this feature never assigns CUSTOMER (research.md/spec.md Assumptions); every row this feature creates or updates is ADMIN or AGENT |
passwordHash |
String |
new — bcryptjs hash, never the plaintext password; NOT NULL since every account this feature manages must be able to log in (FR-010 requires both seeded demo accounts to get a real one) |
active |
Boolean @default(true) |
new — mirrors Agent.active's existing convention exactly; a deactivated account's session is rejected on re-validation (Edge Cases/User Story 3), without a hard delete |
createdAt / updatedAt |
DateTime |
unchanged |
Agent (modified — one additive, nullable column)
| Field | Type | Notes |
|---|---|---|
userId |
String? @unique |
new — nullable FK to User.id, the schema capability to identify which login identity a routing/skills profile belongs to (research.md). No endpoint in this feature sets it; a follow-up in identity/agents (006) is expected to. |
No new Prisma model for "Session" — a session is a signed JWT the server never persists
(research.md's short-lived-JWT-plus-revocation-denylist decision); the denylist itself lives in
Redis (auth:revoked:<jti>, TTL = remaining token lifetime), not Postgres.
JwtPayload (existing type, src/common/types/auth.types.ts — one additive field)
| Field | Type | Notes |
|---|---|---|
sub |
string |
the User.id |
email |
string |
unchanged |
role |
string |
unchanged — User.role at issuance time |
actorType |
ActorType |
unchanged — always ActorType.USER for these sessions (research.md) |
jti |
string |
new — random UUID per issued token, the revocation-denylist key |
iat / exp |
number |
unchanged, standard JWT claims |
AuthUser (existing type, unchanged)
{ id, email, role, actorType } — what fastify.authenticate sets on request.user after
verifying the token; the same fields returned by login (FR-001) and the self-identity endpoint
(FR-007), minus jti/iat/exp (those are token bookkeeping, not identity).
Validation rules
- Login:
emaila valid email string,passwordnon-empty. The response for "no such user" and "wrong password" MUST be byte-for-byte identical (FR-002/SC-003) — achieved by always running the bcrypt comparison against either the found user's hash or a fixed dummy hash when no user is found, so the response timing and shape never differ by branch. - Account creation (User Story 4):
emailvalid and not already in use,namenon-empty,roleone ofADMIN/AGENT(neverCUSTOMER, research.md),passwordnon-empty (hashed before storage, never persisted or logged in plaintext).
State / lifecycle
User.active(new column, above) is the deactivation flag. The self-identity endpoint (User Story 3) re-fetches theUserrow bysubon every call and rejects if it no longer exists oractive: false— this is the feature's only server-side re-validation path;fastify .authenticateitself does not re-fetch on every request (that would defeat the point of a stateless JWT check), so a deactivated account's other already-issued-token requests remain valid until that token's natural expiry or an explicit logout, exactly as spec.md's Edge Cases already scopes it ("a short token lifetime bounds the rest").