You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Project membership is binary. PROJECT_ROLES is ['owner', 'member'] (packages/workers/src/config/constants.ts:1, hierarchy in packages/workers/src/policies/lib/roles.ts:16), and anything below owner is still a full reviewer: it can appraise, reconcile, edit outcomes, upload and delete PDFs, and delete studies.
That leaves no way to give someone read access. A supervisor, PI, methodologist, funder, or client who needs to watch a review in progress has to be added as a full member, which also makes them appear in reviewer assignment lists and lets one mis-click on a study destroy days of work (deletion is still immediate and permanent, #749).
The workarounds do not cover it. Emailing exports is stale and manual. The share link in #753 is anonymous, Overview-only, and deliberately excludes answers, comments, and annotations. Neither answers "my supervisor has an account and should see everything in this project, live, and change nothing."
#650 already names this: section 4 lists "observer / commenter (see progress and exports, cannot appraise)" as a possible later role, and the Phase 3 line on the readiness tracker says "viewer role". This issue carves it out so it can ship on its own, well before the consultancy work.
Why this is cheap: the write gate already exists
The enforcement mechanism is built and in production. It is currently driven only by billing.
Every mutator opens with assertWritable (packages/shared/src/sync/mutators.ts:57), which reads ctx.auth.writeAllowed and throws AppError('ReadOnly'). The workspace's authorizeWrite hook checks the same stamp (packages/workers/src/sync/workspace.ts:39). It fails closed if the stamp is missing.
The stamp is built in one place: writeAllowed: billing.accessMode !== 'readOnly' (packages/workers/src/sync/authorize.ts:56). A viewer role is one more term in that expression.
Role changes already take effect on live sockets. updateMemberRole calls refreshWorkspaceSessions (packages/workers/src/commands/members/updateMemberRole.ts), which forces a reconnect so authorize re-runs and picks up a fresh stamp.
hasProjectRole is an ordered hierarchy check (packages/workers/src/policies/lib/roles.ts:16-47), so inserting viewer below member makes every existing minRole: 'member' check reject viewers with no further edits.
What is missing is the role itself, the server call sites that never opted into a role check, and any UI concept of "you cannot write here".
Gaps to close
Server functions that pass no minRole. Of the 13 requireProjectAccess call sites, 5 pass nothing: org-projects.server.ts:202, :346, :396, dev-tools.server.ts:40, and both PDF API routes. minRole is optional on the guard (packages/web/src/server/guards/requireProjectAccess.ts:31), so those are open to any member today and would be open to a viewer tomorrow.
PDF upload and delete are gated on billing, not role.handlePost calls requireOrgWriteAccess(request.method, db, params.orgId) (.../studies/$studyId/pdfs.ts:97), which only checks accessMode. A viewer would still be able to upload and delete PDFs through the API routes.
The auth context enum is closed.authContextSchema is role: z.enum(['owner', 'member']) (packages/shared/src/sync/mutators.ts:34). Adding a third value is a cross-package change in shared, so the workers and web builds move together. Worth confirming whether an in-flight socket stamped by an older worker can fail validation mid-rollout, and whether viewer should instead stamp role: 'member', writeAllowed: false to keep the enum untouched.
The UI has no write concept.ProjectContext exposes only isOwner (packages/web/src/components/project/ProjectContext.tsx:31, :65). There is no canWrite for controls to hide behind, so a viewer would see every button and learn it failed only from a toast. The rejection path exists (packages/web/src/project/ConnectionPool.ts:69) but its copy is written for a lapsed subscription: "This organization has read-only access. Renew your subscription to make changes."
Proposal
Add viewer as the lowest project role. project_members.role is free-text with a default (packages/db/src/schema.ts:210), so the column needs no migration; the constant lists, the Zod enums, and the call sites are the work.
A viewer sees everything a member sees: all tabs, all studies, in-progress and finished checklists, reconciliations, annotations, the Overview, and exports. Read access is not partial. Anything narrower belongs to Read-only share link for a project's Overview #753.
ProjectContext gains canWrite. The UI hides write affordances rather than letting them fail: add studies, add checklist, assign reviewers, delete, outcomes editing, PDF upload, annotation tools, and every reconcile control.
Viewers are excluded from reviewer assignment lists (AssignReviewersSheet, ChecklistForm assignee options) and cannot be set as reviewer1/reviewer2.
Owners pick the role when adding a member and can change it later; updateMemberRole accepts viewer with last-owner protection unchanged.
A visible badge on the member list and a persistent marker in the project chrome so a viewer knows why the controls are gone, distinct in copy from the billing read-only state.
Tasks
Add viewer to PROJECT_ROLES, the ProjectRole type, and the PROJECT_ROLES hierarchy below member.
Decide and document the auth-context shape (new enum value vs member + writeAllowed: false), then update authorize to stamp it.
Pass an explicit minRole at every requireProjectAccess call site; add a role check to PDF POST and DELETE alongside the existing billing check.
Role selection in the add-member and invitation flows; role: z.enum(...) at org-projects.functions.ts:90; updateMemberRole accepts the new role.
canWrite on ProjectContext; hide write affordances across the project tabs and the PDF viewer.
Exclude viewers from reviewer assignment and checklist assignee lists.
Distinguish the role rejection from the billing rejection in rejectionMessage.
Member-list badge and project-chrome indicator.
Tests: a viewer's mutations are rejected server side, the PDF routes reject a viewer, a viewer cannot be assigned as a reviewer, and an e2e pass where a viewer sees a full project with no write controls.
Acceptance criteria
A viewer can open a project and see every tab, study, checklist, reconciliation, annotation, and Overview figure, and can run exports.
Every write path rejects a viewer at the server, including the ones that bypass the sync plane: PDF upload, PDF delete, and the project server functions.
No write control is visible to a viewer; nothing relies on a toast to explain the failure.
Promoting a viewer to member, or demoting a member to viewer, takes effect on an open session without a reload.
A viewer never appears as a candidate for reviewer assignment.
Open questions
Do viewers consume a billing seat? subscription.seats exists (packages/db/src/schema.ts:265); a supervisor who only watches is a weak thing to charge for, and free viewers are a real acquisition channel.
Related: #650 (consultancy orgs, where this was first noted), #753 (anonymous Overview share link, the account-free alternative), #749 (permanent deletion, part of why full membership is risky for a supervisor), #722 (archive export).
Problem
Project membership is binary.
PROJECT_ROLESis['owner', 'member'](packages/workers/src/config/constants.ts:1, hierarchy inpackages/workers/src/policies/lib/roles.ts:16), and anything below owner is still a full reviewer: it can appraise, reconcile, edit outcomes, upload and delete PDFs, and delete studies.That leaves no way to give someone read access. A supervisor, PI, methodologist, funder, or client who needs to watch a review in progress has to be added as a full member, which also makes them appear in reviewer assignment lists and lets one mis-click on a study destroy days of work (deletion is still immediate and permanent, #749).
The workarounds do not cover it. Emailing exports is stale and manual. The share link in #753 is anonymous, Overview-only, and deliberately excludes answers, comments, and annotations. Neither answers "my supervisor has an account and should see everything in this project, live, and change nothing."
#650 already names this: section 4 lists "observer / commenter (see progress and exports, cannot appraise)" as a possible later role, and the Phase 3 line on the readiness tracker says "viewer role". This issue carves it out so it can ship on its own, well before the consultancy work.
Why this is cheap: the write gate already exists
The enforcement mechanism is built and in production. It is currently driven only by billing.
assertWritable(packages/shared/src/sync/mutators.ts:57), which readsctx.auth.writeAllowedand throwsAppError('ReadOnly'). The workspace'sauthorizeWritehook checks the same stamp (packages/workers/src/sync/workspace.ts:39). It fails closed if the stamp is missing.writeAllowed: billing.accessMode !== 'readOnly'(packages/workers/src/sync/authorize.ts:56). A viewer role is one more term in that expression.updateMemberRolecallsrefreshWorkspaceSessions(packages/workers/src/commands/members/updateMemberRole.ts), which forces a reconnect soauthorizere-runs and picks up a fresh stamp.hasProjectRoleis an ordered hierarchy check (packages/workers/src/policies/lib/roles.ts:16-47), so insertingviewerbelowmembermakes every existingminRole: 'member'check reject viewers with no further edits.What is missing is the role itself, the server call sites that never opted into a role check, and any UI concept of "you cannot write here".
Gaps to close
Server functions that pass no
minRole. Of the 13requireProjectAccesscall sites, 5 pass nothing:org-projects.server.ts:202,:346,:396,dev-tools.server.ts:40, and both PDF API routes.minRoleis optional on the guard (packages/web/src/server/guards/requireProjectAccess.ts:31), so those are open to any member today and would be open to a viewer tomorrow.PDF upload and delete are gated on billing, not role.
handlePostcallsrequireOrgWriteAccess(request.method, db, params.orgId)(.../studies/$studyId/pdfs.ts:97), which only checksaccessMode. A viewer would still be able to upload and delete PDFs through the API routes.The auth context enum is closed.
authContextSchemaisrole: z.enum(['owner', 'member'])(packages/shared/src/sync/mutators.ts:34). Adding a third value is a cross-package change inshared, so the workers and web builds move together. Worth confirming whether an in-flight socket stamped by an older worker can fail validation mid-rollout, and whetherviewershould instead stamprole: 'member', writeAllowed: falseto keep the enum untouched.The UI has no write concept.
ProjectContextexposes onlyisOwner(packages/web/src/components/project/ProjectContext.tsx:31,:65). There is nocanWritefor controls to hide behind, so a viewer would see every button and learn it failed only from a toast. The rejection path exists (packages/web/src/project/ConnectionPool.ts:69) but its copy is written for a lapsed subscription: "This organization has read-only access. Renew your subscription to make changes."Proposal
Add
vieweras the lowest project role.project_members.roleis free-text with a default (packages/db/src/schema.ts:210), so the column needs no migration; the constant lists, the Zod enums, and the call sites are the work.authorizestampswriteAllowed = billing.accessMode !== 'readOnly' && role !== 'viewer'.ProjectContextgainscanWrite. The UI hides write affordances rather than letting them fail: add studies, add checklist, assign reviewers, delete, outcomes editing, PDF upload, annotation tools, and every reconcile control.AssignReviewersSheet,ChecklistFormassignee options) and cannot be set asreviewer1/reviewer2.updateMemberRoleacceptsviewerwith last-owner protection unchanged.Tasks
viewertoPROJECT_ROLES, theProjectRoletype, and thePROJECT_ROLEShierarchy belowmember.member+writeAllowed: false), then updateauthorizeto stamp it.minRoleat everyrequireProjectAccesscall site; add a role check to PDF POST and DELETE alongside the existing billing check.role: z.enum(...)atorg-projects.functions.ts:90;updateMemberRoleaccepts the new role.canWriteonProjectContext; hide write affordances across the project tabs and the PDF viewer.rejectionMessage.Acceptance criteria
Open questions
subscription.seatsexists (packages/db/src/schema.ts:265); a supervisor who only watches is a weak thing to charge for, and free viewers are a real acquisition channel.Related: #650 (consultancy orgs, where this was first noted), #753 (anonymous Overview share link, the account-free alternative), #749 (permanent deletion, part of why full membership is risky for a supervisor), #722 (archive export).