ADR-020: Client Review by Project Access Code (No Viewer Accounts)
- Status: Accepted
- Date: 2026-10-02
Context
The platform's users are freelancers: an Admin who owns a workspace and the Members the Admin adds. A freelancer's clients (and the stakeholders at those clients) only need to look at a project's approved user stories to confirm the project is on the right track.
The first design modelled that as a viewer user role: an invited, authenticated account with read-only access (F-011). That brought an invitation lifecycle, a role to maintain in every guard and test, and an account for people who never use the rest of the product. It also left the Client Viewer page (/viewer) as a demo that returned mock data for any code.
The existing projects.code cannot serve as a public key. A freelancer chooses it (WEB), it is unique only within a workspace (two freelancers can both use WEB), and it is short enough to guess.
Decision
- Remove the
vieweruser role. Onlyadminandmemberexist. Stakeholders have no account. - Give every project a
access_code. Server-generated,PRJ-plus 8 characters from a 32-symbol alphabet withoutO,0,I,1(about 10^12 values), unique across all workspaces. Admins and Members see it on the project and can regenerate it, which revokes the previous code immediately. - Expose one public, read-only route,
GET /v1/viewer/{access_code}, no token. It returns the project name, phase, and approved stories (priority then age order). It returns no project id, client record, user reference, or workspace identifier. - Resolve the workspace from the code, never from the request. The access code is the single deliberate cross-workspace read in the repository layer; every other repository method still takes a
workspace_id. - Rate limit and answer uniformly. 30 requests per minute per IP. An unknown code and a malformed code both answer
404 Project not found, so a guesser learns nothing about how close they were. - Name it Client Review. The page stays at
/viewer. The word "viewer" no longer names a person or a role. - Migrate existing viewer accounts by deactivating them, not promoting them, so nobody silently gains write access.
Consequences
Positive
- Clients review requirements with no sign-up, invitation email, or password.
- One role fewer in guards, tests, and docs; the access policy reduces to authenticated (Admin or Member) plus one small public surface.
- A leaked link is revoked by regenerating one code.
Negative
- The access code is a bearer secret: anyone holding it can read that project's approved stories. Sharing it is the freelancer's decision, and the portal is read-only.
- No per-person audit trail or per-stakeholder revocation; revoking means regenerating for everyone.
- The rate limiter is in memory and keyed by IP, so it is not shared across workers and may see only a proxy's address. Entropy of the code, not the limiter, is the primary defence; shared storage and forwarded-IP handling are follow-ups.
- Existing viewer accounts stop working. The migration deactivates them and the downgrade restores only the enum value.
Alternatives Considered
-
Keep the Viewer role and invitations (F-011)
- Gives per-person access control and an audit trail.
- Not selected: clients do not use the product beyond reading approved stories, and an account per stakeholder is the wrong cost for that.
-
Use the existing
projects.codeas the public key- No new column.
- Not selected: it is unique per workspace only, so a public lookup is ambiguous, and short codes are guessable.
-
Signed, expiring share links
- Stronger revocation and expiry semantics.
- Not selected for now: more moving parts than the need justifies; a regenerable code covers revocation. Can be layered on later.