devaanand commited on
Commit
bfb6467
·
1 Parent(s): ffe862f

Four-tier access model: self-governing admin, pre-login access, audit trail

Browse files

PI decisions 2026-07-22:
- Roles: pi | maintainer | lab | external (legacy "member" reads as
external). Lab members and above: unlimited saved runs.
- PI role holds escalation rights in-app (grant/revoke staff, act on
staff accounts, audit view); ADMIN_EMAILS is bootstrap + break-glass
only. Maintainers get the operational surface. Nobody can change
their own role.
- Construct verification is maintainer-only (the RA's job); PI/admin
keep a read-only queue.
- Access before sign-in: email-bound pre-assigned roles (staff-capable,
claimed on first password/Google sign-in) and signed 7-day invite
links (lab/external only, ?invite= signup flow).
- Append-only admin_audit table records every admin action; visible to
PI/env admins.
- Docs split: /guide = navigate-and-test; new /product page = access
model + capability matrix + architecture (moved from guide).

MANUAL_TESTING.md CHANGED
@@ -168,15 +168,38 @@ Checklist before giving the URL to real users:
168
 
169
  ## 12. Admin page (/admin)
170
 
171
- Requires `ADMIN_EMAILS` to include your signed-in email (see .env.example).
 
 
 
172
 
173
- 1. Sign in with an allowlisted account: an "Admin" link appears in the header;
174
- non-admins (and signed-out visitors) see an access notice at /admin.
175
  2. Overview: account/run/project counters plus scales awaiting verification.
176
- 3. Users: toggle a user to "lab" (their saved-run cap disappears - check
177
- /api/auth/me shows max_saved_runs null), reset a password (temporary
178
- password shown once; old one stops working), delete a user (removes all
179
- their data; self-deletion refused).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  4. Failed runs: a failed job lists with its error tail; Requeue re-runs it
181
  (refused when the corpus file is already gone - anonymous retention).
182
  5. Verification: mark a scale Verified; its "unverified" flag disappears
 
168
 
169
  ## 12. Admin page (/admin)
170
 
171
+ Requires `ADMIN_EMAILS` to include your signed-in email (see .env.example),
172
+ OR a pi/maintainer role. The env allowlist is bootstrap + break-glass; the
173
+ PI role carries the same escalation rights in-app. The full access model is
174
+ documented at /product.
175
 
176
+ 1. Sign in with an allowlisted or staff account: an "Admin" link appears in
177
+ the header; non-admins (and signed-out visitors) see an access notice.
178
  2. Overview: account/run/project counters plus scales awaiting verification.
179
+ 3. Users - four tiers (external user, lab member, maintainer, PI):
180
+ - Set a user to "lab member" or above: their saved-run cap disappears
181
+ (check /api/auth/me shows max_saved_runs null).
182
+ - Set a user to "maintainer" or "PI": they also get this admin page.
183
+ - Escalation needs PI or env-admin rights: a maintainer gets 403 on
184
+ granting pi/maintainer, and on resetting the password of, changing the
185
+ role of, or deleting a pi/maintainer account. A PI-by-role CAN do all
186
+ of that (no env entry needed). Nobody can change their own role.
187
+ - Reset a password (temporary password shown once; old one stops working),
188
+ delete a user (removes all their data; self-deletion refused).
189
+ 4. Access before sign-in:
190
+ - Pre-assign a role to an email (staff roles need PI/env rights): register
191
+ with that email afterwards - the account lands at that tier, the
192
+ assignment shows "claimed". Works for Google sign-ins too.
193
+ - Create an invite link (lab member or external only): open it in a
194
+ private window - the signup form announces the invite; registering
195
+ through it grants the role. Expired/garbage tokens refuse registration.
196
+ 5. Construct verification is maintainer-only: PI/env admins see the queue
197
+ read-only (no action buttons; the API returns 403), a maintainer can mark
198
+ scales verified. Statuses are applied back to the library YAML before
199
+ production.
200
+ 6. Audit trail (PI/env admins only; maintainers get 403 and don't see the
201
+ card): every role change, reset, deletion, invite, pre-assignment,
202
+ requeue, and verification appears with actor, target, and time.
203
  4. Failed runs: a failed job lists with its error tail; Requeue re-runs it
204
  (refused when the corpus file is already gone - anonymous retention).
205
  5. Verification: mark a scale Verified; its "unverified" flag disappears
backend/app/admin.py CHANGED
@@ -10,8 +10,12 @@ Four concrete pains drive this, nothing speculative:
10
  * usage numbers + failed-run triage (the PI's "how is testing going?"
11
  answered with counts, and stuck runs requeued without SQL).
12
 
13
- Access: signed-in AND email in the ADMIN_EMAILS env allowlist. Admin is an
14
- env capability, not a DB role, so the DB cannot mint admins.
 
 
 
 
15
  """
16
 
17
  from __future__ import annotations
@@ -27,16 +31,47 @@ from sqlalchemy.orm import Session
27
  from . import auth, retention, storage
28
  from . import jobs as jobs_module
29
  from .db import get_db
30
- from .models import Construct, Corpus, Job, Project, User
31
 
32
  router = APIRouter(prefix="/api/admin", tags=["admin"])
33
 
34
 
35
- def require_admin(request: Request) -> dict:
36
  user = auth.get_current_user(request)
37
- if user is None or not auth.is_admin(user.get("email")):
 
 
 
 
 
38
  raise HTTPException(403, "Admin access required.")
39
- return user
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
 
42
  # ---------------------------------------------------------------- overview
@@ -46,9 +81,12 @@ def overview(db: Session = Depends(get_db), _admin: dict = Depends(require_admin
46
  runs_by_status = dict(
47
  db.query(Job.status, func.count(Job.id)).group_by(Job.status).all()
48
  )
 
 
 
49
  return {
50
  "users": db.query(User).count(),
51
- "lab_users": db.query(User).filter_by(role="lab").count(),
52
  "projects": db.query(Project).count(),
53
  "anonymous_projects": db.query(Project).filter_by(owner_user_id="").count(),
54
  "corpora": db.query(Corpus).count(),
@@ -78,11 +116,12 @@ def list_users(db: Session = Depends(get_db), _admin: dict = Depends(require_adm
78
  "id": u.id,
79
  "email": u.email,
80
  "name": u.name,
81
- "role": u.role or "member",
82
  "google_only": not u.password_hash,
83
  "saved_runs": saved.get(u.id, 0),
84
  "created_at": u.created_at,
85
- "is_admin": auth.is_admin(u.email),
 
86
  }
87
  for u in db.query(User).order_by(User.created_at.desc()).all()
88
  ]
@@ -93,34 +132,44 @@ def set_role(
93
  user_id: str,
94
  body: dict,
95
  db: Session = Depends(get_db),
96
- _admin: dict = Depends(require_admin),
97
  ):
98
- role = str(body.get("role", "")).strip().lower()
99
- if role not in ("member", "lab"):
100
- raise HTTPException(400, "Role must be 'member' or 'lab'.")
 
101
  user = db.get(User, user_id)
102
  if user is None:
103
  raise HTTPException(404, "User not found")
 
 
 
 
 
 
104
  user.role = role
 
105
  db.commit()
106
  return {"id": user.id, "role": user.role}
107
 
108
 
109
  @router.post("/users/{user_id}/reset-password")
110
  def reset_password(
111
- user_id: str, db: Session = Depends(get_db), _admin: dict = Depends(require_admin)
112
  ):
113
  """Generate a temporary password, shown ONCE in the response. The admin
114
  passes it to the user, who should change it (or use Google sign-in)."""
115
  user = db.get(User, user_id)
116
  if user is None:
117
  raise HTTPException(404, "User not found")
 
118
  if not user.password_hash:
119
  raise HTTPException(
120
  400, "This is a Google account - it has no password to reset (they sign in via Google)."
121
  )
122
  temp = secrets.token_urlsafe(9) # 12 chars, meets the minimum length
123
  user.password_hash = auth.hash_password(temp)
 
124
  db.commit()
125
  return {"id": user.id, "email": user.email, "temporary_password": temp}
126
 
@@ -135,13 +184,117 @@ def delete_user(
135
  user = db.get(User, user_id)
136
  if user is None:
137
  raise HTTPException(404, "User not found")
 
138
  for project in db.query(Project).filter_by(owner_user_id=user_id).all():
139
  retention.delete_project_cascade(db, project)
 
140
  db.delete(user)
141
  db.commit()
142
  return None
143
 
144
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  # ----------------------------------------------------------- failed runs
146
  @router.get("/jobs/failed")
147
  def failed_jobs(db: Session = Depends(get_db), _admin: dict = Depends(require_admin)):
@@ -171,7 +324,7 @@ def failed_jobs(db: Session = Depends(get_db), _admin: dict = Depends(require_ad
171
 
172
  @router.post("/jobs/{job_id}/requeue")
173
  def requeue_job(
174
- job_id: str, db: Session = Depends(get_db), _admin: dict = Depends(require_admin)
175
  ):
176
  job = db.get(Job, job_id)
177
  if job is None:
@@ -188,6 +341,7 @@ def requeue_job(
188
  job.progress = 0.0
189
  job.started_at = ""
190
  job.finished_at = ""
 
191
  db.commit()
192
  jobs_module.submit_job(job.id)
193
  return {"id": job.id, "status": "queued"}
@@ -222,11 +376,21 @@ def set_verification(
222
  construct_id: str,
223
  body: dict,
224
  db: Session = Depends(get_db),
225
- _admin: dict = Depends(require_admin),
226
  ):
227
  """Operational overlay for the RA's workflow. The YAML library remains the
228
  durable source of truth: statuses set here are exported and written back to
229
- the library files by the developer before production (recorded decision)."""
 
 
 
 
 
 
 
 
 
 
230
  status = str(body.get("status", "")).strip()
231
  if status not in ("verified", "needs_verification"):
232
  raise HTTPException(400, "Status must be 'verified' or 'needs_verification'.")
@@ -234,5 +398,6 @@ def set_verification(
234
  if construct is None:
235
  raise HTTPException(404, "Construct not found")
236
  construct.verification_status = status
 
237
  db.commit()
238
  return {"id": construct.id, "verification_status": construct.verification_status}
 
10
  * usage numbers + failed-run triage (the PI's "how is testing going?"
11
  answered with counts, and stuck runs requeued without SQL).
12
 
13
+ Access: signed-in AND (email in the ADMIN_EMAILS env allowlist, OR role
14
+ pi/maintainer - PI decision 2026-07-22). The app is self-governing: the
15
+ PI role carries escalation rights (grant/revoke staff, act on staff
16
+ accounts), maintainers get the operational surface only, and the env
17
+ allowlist is bootstrap + break-glass (seed the first PI; recover a
18
+ locked-out lab). Every mutating action lands in the admin_audit table.
19
  """
20
 
21
  from __future__ import annotations
 
31
  from . import auth, retention, storage
32
  from . import jobs as jobs_module
33
  from .db import get_db
34
+ from .models import AdminAudit, Construct, Corpus, Job, Project, RoleAssignment, User
35
 
36
  router = APIRouter(prefix="/api/admin", tags=["admin"])
37
 
38
 
39
+ def require_admin(request: Request, db: Session = Depends(get_db)) -> dict:
40
  user = auth.get_current_user(request)
41
+ if user is None:
42
+ raise HTTPException(403, "Admin access required.")
43
+ env_admin = auth.is_admin(user.get("email"))
44
+ row = db.get(User, user["id"])
45
+ role = auth.normalize_role(row.role if row else None)
46
+ if not env_admin and not auth.role_is_staff(role):
47
  raise HTTPException(403, "Admin access required.")
48
+ # can_escalate gates the power-expanding paths below (staff role grants,
49
+ # actions on staff accounts): PIs and env-allowlisted admins have it,
50
+ # maintainers get the operational surface only.
51
+ return {
52
+ **user,
53
+ "role": role,
54
+ "env_admin": env_admin,
55
+ "can_escalate": env_admin or role == "pi",
56
+ }
57
+
58
+
59
+ def _require_escalation_rights(admin: dict, target: User, action: str) -> None:
60
+ """Actions on staff or env-allowlisted accounts require escalation rights
61
+ (PI role or env allowlist) - otherwise a maintainer could take over a PI
62
+ account (password reset), delete one, or mint more staff."""
63
+ if admin["can_escalate"]:
64
+ return
65
+ if auth.role_is_staff(target.role) or auth.is_admin(target.email):
66
+ raise HTTPException(
67
+ 403, f"Only a PI (or allowlisted admin) can {action} a PI/maintainer account."
68
+ )
69
+
70
+
71
+ def _audit(db: Session, admin: dict, action: str, target: str, detail: str = "") -> None:
72
+ """Append to the audit trail; committed together with the action itself."""
73
+ db.add(AdminAudit(actor_email=admin.get("email", ""), action=action,
74
+ target=target, detail=detail))
75
 
76
 
77
  # ---------------------------------------------------------------- overview
 
81
  runs_by_status = dict(
82
  db.query(Job.status, func.count(Job.id)).group_by(Job.status).all()
83
  )
84
+ users_by_role: dict[str, int] = {r: 0 for r in auth.ROLES}
85
+ for (role,) in db.query(User.role).all():
86
+ users_by_role[auth.normalize_role(role)] += 1
87
  return {
88
  "users": db.query(User).count(),
89
+ "users_by_role": users_by_role,
90
  "projects": db.query(Project).count(),
91
  "anonymous_projects": db.query(Project).filter_by(owner_user_id="").count(),
92
  "corpora": db.query(Corpus).count(),
 
116
  "id": u.id,
117
  "email": u.email,
118
  "name": u.name,
119
+ "role": auth.normalize_role(u.role),
120
  "google_only": not u.password_hash,
121
  "saved_runs": saved.get(u.id, 0),
122
  "created_at": u.created_at,
123
+ "is_admin": auth.is_admin(u.email) or auth.role_is_staff(u.role),
124
+ "env_admin": auth.is_admin(u.email),
125
  }
126
  for u in db.query(User).order_by(User.created_at.desc()).all()
127
  ]
 
132
  user_id: str,
133
  body: dict,
134
  db: Session = Depends(get_db),
135
+ admin: dict = Depends(require_admin),
136
  ):
137
+ raw = str(body.get("role", "")).strip().lower()
138
+ if raw not in auth.ROLES and raw != "member": # "member" = legacy external
139
+ raise HTTPException(400, f"Role must be one of: {', '.join(auth.ROLES)}.")
140
+ role = auth.normalize_role(raw)
141
  user = db.get(User, user_id)
142
  if user is None:
143
  raise HTTPException(404, "User not found")
144
+ if user_id == admin["id"]:
145
+ raise HTTPException(400, "You cannot change your own role (ask another admin).")
146
+ _require_escalation_rights(admin, user, "change the role of")
147
+ if role in auth.STAFF_ROLES and not admin["can_escalate"]:
148
+ raise HTTPException(403, "Only a PI (or allowlisted admin) can grant PI/maintainer roles.")
149
+ old = auth.normalize_role(user.role)
150
  user.role = role
151
+ _audit(db, admin, "set_role", user.email, f"{old} -> {role}")
152
  db.commit()
153
  return {"id": user.id, "role": user.role}
154
 
155
 
156
  @router.post("/users/{user_id}/reset-password")
157
  def reset_password(
158
+ user_id: str, db: Session = Depends(get_db), admin: dict = Depends(require_admin)
159
  ):
160
  """Generate a temporary password, shown ONCE in the response. The admin
161
  passes it to the user, who should change it (or use Google sign-in)."""
162
  user = db.get(User, user_id)
163
  if user is None:
164
  raise HTTPException(404, "User not found")
165
+ _require_escalation_rights(admin, user, "reset the password of")
166
  if not user.password_hash:
167
  raise HTTPException(
168
  400, "This is a Google account - it has no password to reset (they sign in via Google)."
169
  )
170
  temp = secrets.token_urlsafe(9) # 12 chars, meets the minimum length
171
  user.password_hash = auth.hash_password(temp)
172
+ _audit(db, admin, "reset_password", user.email)
173
  db.commit()
174
  return {"id": user.id, "email": user.email, "temporary_password": temp}
175
 
 
184
  user = db.get(User, user_id)
185
  if user is None:
186
  raise HTTPException(404, "User not found")
187
+ _require_escalation_rights(admin, user, "delete")
188
  for project in db.query(Project).filter_by(owner_user_id=user_id).all():
189
  retention.delete_project_cascade(db, project)
190
+ _audit(db, admin, "delete_user", user.email, f"role was {auth.normalize_role(user.role)}")
191
  db.delete(user)
192
  db.commit()
193
  return None
194
 
195
 
196
+ # ---------------------------------------------------------------- invites
197
+ @router.post("/invites", status_code=201)
198
+ def create_invite(
199
+ body: dict,
200
+ db: Session = Depends(get_db),
201
+ admin: dict = Depends(require_admin),
202
+ ):
203
+ """Signed, expiring invite link: whoever registers through it lands at
204
+ the invited tier (external/lab only - staff is granted, never invited).
205
+ Stateless, so it cannot be revoked early; creation is audited."""
206
+ role = str(body.get("role", "")).strip().lower()
207
+ try:
208
+ token, expires = auth.create_invite_token(role, admin.get("email", ""))
209
+ except ValueError as exc:
210
+ raise HTTPException(400, str(exc)) from exc
211
+ _audit(db, admin, "invite_created", auth.normalize_role(role), f"expires {expires}")
212
+ db.commit()
213
+ return {"token": token, "role": auth.normalize_role(role), "expires_at": expires,
214
+ "ttl_days": auth.invite_ttl_days()}
215
+
216
+
217
+ # ------------------------------------------------------ pre-assigned roles
218
+ @router.get("/role-assignments")
219
+ def list_role_assignments(db: Session = Depends(get_db), _admin: dict = Depends(require_admin)):
220
+ return [
221
+ {"id": a.id, "email": a.email, "role": auth.normalize_role(a.role),
222
+ "assigned_by": a.assigned_by, "created_at": a.created_at,
223
+ "claimed_at": a.claimed_at or None}
224
+ for a in db.query(RoleAssignment)
225
+ .order_by(RoleAssignment.created_at.desc()).limit(100).all()
226
+ ]
227
+
228
+
229
+ @router.post("/role-assignments", status_code=201)
230
+ def create_role_assignment(
231
+ body: dict,
232
+ db: Session = Depends(get_db),
233
+ admin: dict = Depends(require_admin),
234
+ ):
235
+ """Bind a role to an email BEFORE the account exists: whoever first signs
236
+ in with this email (password or Google) lands at this tier. This is how
237
+ an external collaborator gets full credentials without touching env vars.
238
+ Staff assignments require escalation rights, same as the Users table."""
239
+ email = str(body.get("email", "")).strip().lower()
240
+ role = auth.normalize_role(str(body.get("role", "")))
241
+ if not auth.valid_email(email):
242
+ raise HTTPException(400, "Please enter a valid email address.")
243
+ if str(body.get("role", "")).strip().lower() not in auth.ROLES:
244
+ raise HTTPException(400, f"Role must be one of: {', '.join(auth.ROLES)}.")
245
+ if role in auth.STAFF_ROLES and not admin["can_escalate"]:
246
+ raise HTTPException(403, "Only a PI (or allowlisted admin) can pre-assign PI/maintainer roles.")
247
+ if db.query(User).filter_by(email=email).first():
248
+ raise HTTPException(409, "This email already has an account - change their role in the Users table.")
249
+ existing = db.query(RoleAssignment).filter_by(email=email).first()
250
+ if existing is not None:
251
+ if auth.role_is_staff(existing.role) and not admin["can_escalate"]:
252
+ raise HTTPException(403, "Only a PI (or allowlisted admin) can change a staff pre-assignment.")
253
+ existing.role = role
254
+ existing.assigned_by = admin.get("email", "")
255
+ existing.claimed_at = ""
256
+ assignment = existing
257
+ else:
258
+ assignment = RoleAssignment(email=email, role=role, assigned_by=admin.get("email", ""))
259
+ db.add(assignment)
260
+ _audit(db, admin, "role_preassigned", email, role)
261
+ db.commit()
262
+ return {"id": assignment.id, "email": email, "role": role}
263
+
264
+
265
+ @router.delete("/role-assignments/{assignment_id}", status_code=204)
266
+ def delete_role_assignment(
267
+ assignment_id: str,
268
+ db: Session = Depends(get_db),
269
+ admin: dict = Depends(require_admin),
270
+ ):
271
+ assignment = db.get(RoleAssignment, assignment_id)
272
+ if assignment is None:
273
+ raise HTTPException(404, "Assignment not found")
274
+ if auth.role_is_staff(assignment.role) and not admin["can_escalate"]:
275
+ raise HTTPException(403, "Only a PI (or allowlisted admin) can remove a staff pre-assignment.")
276
+ _audit(db, admin, "preassignment_removed", assignment.email,
277
+ auth.normalize_role(assignment.role))
278
+ db.delete(assignment)
279
+ db.commit()
280
+ return None
281
+
282
+
283
+ # -------------------------------------------------------------- audit log
284
+ @router.get("/audit")
285
+ def audit_log(db: Session = Depends(get_db), admin: dict = Depends(require_admin)):
286
+ """Top-down oversight is the PI's (and env admin's) view - maintainers
287
+ work the operational cards but don't review each other's actions."""
288
+ if not admin["can_escalate"]:
289
+ raise HTTPException(403, "The audit trail is visible to PIs and allowlisted admins.")
290
+ rows = db.query(AdminAudit).order_by(AdminAudit.at.desc(), AdminAudit.id.desc()).limit(100).all()
291
+ return [
292
+ {"at": r.at, "actor": r.actor_email, "action": r.action,
293
+ "target": r.target, "detail": r.detail}
294
+ for r in rows
295
+ ]
296
+
297
+
298
  # ----------------------------------------------------------- failed runs
299
  @router.get("/jobs/failed")
300
  def failed_jobs(db: Session = Depends(get_db), _admin: dict = Depends(require_admin)):
 
324
 
325
  @router.post("/jobs/{job_id}/requeue")
326
  def requeue_job(
327
+ job_id: str, db: Session = Depends(get_db), admin: dict = Depends(require_admin)
328
  ):
329
  job = db.get(Job, job_id)
330
  if job is None:
 
341
  job.progress = 0.0
342
  job.started_at = ""
343
  job.finished_at = ""
344
+ _audit(db, admin, "requeue_job", job.id[:8])
345
  db.commit()
346
  jobs_module.submit_job(job.id)
347
  return {"id": job.id, "status": "queued"}
 
376
  construct_id: str,
377
  body: dict,
378
  db: Session = Depends(get_db),
379
+ admin: dict = Depends(require_admin),
380
  ):
381
  """Operational overlay for the RA's workflow. The YAML library remains the
382
  durable source of truth: statuses set here are exported and written back to
383
+ the library files by the developer before production (recorded decision).
384
+
385
+ Verification is the MAINTAINER's job (PI decision 2026-07-22): the queue is
386
+ visible to all staff, but only maintainers mark scales - the trail then
387
+ shows the responsible RA, not whichever admin clicked. PI/env admins keep
388
+ read access; to verify, hold the maintainer role."""
389
+ if admin["role"] != "maintainer":
390
+ raise HTTPException(
391
+ 403, "Construct verification is done by maintainers. "
392
+ "PI/admin accounts have read access to the queue."
393
+ )
394
  status = str(body.get("status", "")).strip()
395
  if status not in ("verified", "needs_verification"):
396
  raise HTTPException(400, "Status must be 'verified' or 'needs_verification'.")
 
398
  if construct is None:
399
  raise HTTPException(404, "Construct not found")
400
  construct.verification_status = status
401
+ _audit(db, admin, "set_verification", construct.name, status)
402
  db.commit()
403
  return {"id": construct.id, "verification_status": construct.verification_status}
backend/app/auth.py CHANGED
@@ -77,8 +77,9 @@ def cookies_secure() -> bool:
77
 
78
 
79
  def admin_emails() -> set[str]:
80
- """Comma-separated allowlist; admin is an env-granted capability, not a DB
81
- role, so a compromised database cannot mint admins."""
 
82
  raw = os.environ.get("ADMIN_EMAILS", "")
83
  return {e.strip().lower() for e in raw.split(",") if e.strip()}
84
 
@@ -87,6 +88,42 @@ def is_admin(email: str | None) -> bool:
87
  return bool(email) and email.strip().lower() in admin_emails()
88
 
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  # ---------------------------------------------------------- passwords
91
  def hash_password(password: str) -> str:
92
  salt = secrets.token_bytes(16)
@@ -150,10 +187,46 @@ def get_current_user(request: Request) -> dict | None:
150
  "id": data["uid"],
151
  "email": data.get("email", ""),
152
  "name": data.get("name", ""),
153
- "tier": "member",
 
 
154
  }
155
 
156
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  # ------------------------------------------- anonymous daily run counter
158
  def _today() -> str:
159
  return datetime.now(timezone.utc).date().isoformat()
 
77
 
78
 
79
  def admin_emails() -> set[str]:
80
+ """Comma-separated allowlist; env-granted, so it bootstraps the first
81
+ admin and can never be locked out by DB state. See roles below for the
82
+ DB-granted staff tiers (PI decision 2026-07-22)."""
83
  raw = os.environ.get("ADMIN_EMAILS", "")
84
  return {e.strip().lower() for e in raw.split(",") if e.strip()}
85
 
 
88
  return bool(email) and email.strip().lower() in admin_emails()
89
 
90
 
91
+ # ------------------------------------------------------------- user roles
92
+ # Four tiers (PI decision 2026-07-22): pi | maintainer | lab | external.
93
+ # * external - default on signup; saved-run cap applies.
94
+ # * lab - lab members: unlimited saved runs.
95
+ # * maintainer - lab privileges + the /admin operational surface
96
+ # (user management for lab/external, resets, requeue,
97
+ # verification queue, invites).
98
+ # * pi - maintainer surface + escalation rights: grant/revoke
99
+ # staff roles and act on staff accounts. The app is
100
+ # self-governing; ADMIN_EMAILS is bootstrap + break-glass
101
+ # only (seed the first PI, recover a locked-out lab).
102
+ # Escalation therefore requires pi-or-env-admin (admin.py guards), so a
103
+ # maintainer - or a compromised maintainer session - cannot mint staff.
104
+ ROLES = ("external", "lab", "maintainer", "pi")
105
+ UNLIMITED_ROLES = frozenset({"lab", "maintainer", "pi"})
106
+ STAFF_ROLES = frozenset({"maintainer", "pi"})
107
+ INVITABLE_ROLES = frozenset({"external", "lab"}) # staff is granted, never invited
108
+
109
+
110
+ def normalize_role(role: str | None) -> str:
111
+ """Map stored roles to the current scheme ('member' predates 'external')."""
112
+ role = (role or "").strip().lower()
113
+ if role == "member":
114
+ return "external"
115
+ return role if role in ROLES else "external"
116
+
117
+
118
+ def role_unlimited(role: str | None) -> bool:
119
+ """Lab members and above: no saved-run cap."""
120
+ return normalize_role(role) in UNLIMITED_ROLES
121
+
122
+
123
+ def role_is_staff(role: str | None) -> bool:
124
+ return normalize_role(role) in STAFF_ROLES
125
+
126
+
127
  # ---------------------------------------------------------- passwords
128
  def hash_password(password: str) -> str:
129
  salt = secrets.token_bytes(16)
 
187
  "id": data["uid"],
188
  "email": data.get("email", ""),
189
  "name": data.get("name", ""),
190
+ # placeholder only - real role lives in the users table (queried per
191
+ # request in main.py/admin.py so role changes apply without re-login)
192
+ "tier": "external",
193
  }
194
 
195
 
196
+ # ------------------------------------------------------------ invite links
197
+ # Signed, expiring tokens the PI copies into Slack; whoever registers through
198
+ # one lands at the invited tier instead of external. Stateless (no DB row):
199
+ # the cost is that an invite cannot be revoked before it expires - acceptable
200
+ # for a 7-day lab onboarding link, and every creation/redemption is audited.
201
+ INVITE_TTL_DAYS_DEFAULT = 7
202
+
203
+
204
+ def invite_ttl_days() -> int:
205
+ return int(os.environ.get("CCR_INVITE_TTL_DAYS", INVITE_TTL_DAYS_DEFAULT))
206
+
207
+
208
+ def create_invite_token(role: str, invited_by: str) -> tuple[str, str]:
209
+ """Returns (token, expires_at ISO date). Role must be invitable."""
210
+ from datetime import timedelta
211
+
212
+ role = normalize_role(role)
213
+ if role not in INVITABLE_ROLES:
214
+ raise ValueError(f"Only these roles can be invited: {', '.join(sorted(INVITABLE_ROLES))}.")
215
+ expires = (datetime.now(timezone.utc) + timedelta(days=invite_ttl_days())).date().isoformat()
216
+ return sign_payload({"invite": role, "by": invited_by, "exp": expires}), expires
217
+
218
+
219
+ def verify_invite_token(token: str | None) -> str | None:
220
+ """Role granted by a valid, unexpired invite token; None otherwise."""
221
+ data = verify_payload(token)
222
+ if not data or "invite" not in data:
223
+ return None
224
+ if str(data.get("exp", "")) < datetime.now(timezone.utc).date().isoformat():
225
+ return None # expired (dates are ISO, so string compare is correct)
226
+ role = normalize_role(str(data["invite"]))
227
+ return role if role in INVITABLE_ROLES else None
228
+
229
+
230
  # ------------------------------------------- anonymous daily run counter
231
  def _today() -> str:
232
  return datetime.now(timezone.utc).date().isoformat()
backend/app/guide.html CHANGED
@@ -120,13 +120,15 @@ systematically stress-testing the platform, but <b>your real data is the best te
120
  have</b>.</p>
121
 
122
  <h2 id="limits">Limits at a glance</h2>
123
- <p>Signing in lifts the anonymous caps; a lab account (admin-granted) removes the
124
- saved-run cap. The row limit is usually what you hit first, not the file size.</p>
 
 
125
  <div class="tablewrap">
126
  <table>
127
- <tr><th>Limit</th><th>Signed out</th><th>Signed in</th><th>Lab account</th></tr>
128
  <tr><td>Upload size</td><td>5&nbsp;MB</td><td>50&nbsp;MB</td><td>50&nbsp;MB</td></tr>
129
- <tr><td>Rows per file</td><td>200</td><td>20,000</td><td>20,000</td></tr>
130
  <tr><td>Runs per day</td><td>3, then sign in</td><td>unlimited</td><td>unlimited</td></tr>
131
  <tr><td>Saved runs kept</td><td>none (file deleted after each run)</td><td>15</td><td>unlimited</td></tr>
132
  </table>
@@ -291,9 +293,9 @@ pages don't update.</p>
291
  <ol>
292
  <li>Sign in, upload, run: no ANONYMOUS_DATA_REMOVED warning; re-running the same corpus
293
  works (file kept).</li>
294
- <li>Saved-run budget: the Step 3 card shows "N of M saved runs used". Lab accounts on
295
- this dev instance are set effectively unlimited; public accounts get 15. At the cap,
296
- new runs are refused until you delete old runs/projects (nothing is auto-deleted).</li>
297
  <li>Ownership: your projects are invisible to signed-out visitors and other accounts.
298
  Anonymous projects stay shared.</li>
299
  </ol>
@@ -308,126 +310,13 @@ pages don't update.</p>
308
  dupes): identical scores for identical texts, less compute.</li>
309
  </ol>
310
 
311
- <h2 id="architecture">11. How it works (architecture &amp; data flow)</h2>
312
- <p>For anyone who wants to look under the hood. The whole system is deliberately
313
- simple: one application, a few managed services around it, and no third-party AI
314
- APIs the embedding models run on our own server, so uploaded text never leaves
315
- the platform.</p>
316
 
317
- <h3>What runs where</h3>
318
- <figure>
319
- <svg viewBox="0 0 760 300" xmlns="http://www.w3.org/2000/svg" role="img"
320
- aria-label="Architecture: browser talks to one app container, which uses Postgres, R2, and Supabase/Google auth.">
321
- <defs>
322
- <marker id="arw" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">
323
- <path d="M0,0 L7,3 L0,6 Z" fill="var(--muted)"/>
324
- </marker>
325
- </defs>
326
- <!-- browser -->
327
- <rect class="box" x="20" y="120" width="150" height="60" rx="8"/>
328
- <text class="lbl" x="95" y="147" text-anchor="middle">Your browser</text>
329
- <text class="lbl-sm" x="95" y="165" text-anchor="middle">the web interface</text>
330
- <!-- app container -->
331
- <rect class="box-accent" x="270" y="90" width="220" height="120" rx="10"/>
332
- <text class="lbl" x="380" y="115" text-anchor="middle">One app container</text>
333
- <text class="lbl-sm" x="380" y="135" text-anchor="middle">API + interface (FastAPI/React)</text>
334
- <text class="lbl-sm" x="380" y="152" text-anchor="middle">embedding models run here</text>
335
- <text class="lbl-sm" x="380" y="169" text-anchor="middle">(MiniLM, E5) — no external AI</text>
336
- <text class="lbl-sm" x="380" y="190" text-anchor="middle">hosted on Hugging Face</text>
337
- <!-- stores -->
338
- <rect class="box" x="590" y="30" width="150" height="52" rx="8"/>
339
- <text class="lbl" x="665" y="52" text-anchor="middle">Postgres</text>
340
- <text class="lbl-sm" x="665" y="69" text-anchor="middle">records (Supabase)</text>
341
- <rect class="box" x="590" y="124" width="150" height="52" rx="8"/>
342
- <text class="lbl" x="665" y="146" text-anchor="middle">Object storage</text>
343
- <text class="lbl-sm" x="665" y="163" text-anchor="middle">files (Cloudflare R2)</text>
344
- <rect class="box" x="590" y="218" width="150" height="52" rx="8"/>
345
- <text class="lbl" x="665" y="240" text-anchor="middle">Sign-in</text>
346
- <text class="lbl-sm" x="665" y="257" text-anchor="middle">Google / Supabase</text>
347
- <!-- arrows -->
348
- <path class="flow" d="M170,150 L268,150"/>
349
- <path class="flow" d="M492,140 L588,58"/>
350
- <path class="flow" d="M492,150 L588,150"/>
351
- <path class="flow" d="M492,162 L588,244"/>
352
- </svg>
353
- <figcaption>The interface, the API, and the embedding models all live in one
354
- container. Around it: a persistent database for records, object storage for
355
- uploaded files, and Google/Supabase for sign-in. Every piece is on a free tier.</figcaption>
356
- </figure>
357
-
358
- <h3>What happens during a run</h3>
359
- <figure>
360
- <svg viewBox="0 0 760 190" xmlns="http://www.w3.org/2000/svg" role="img"
361
- aria-label="Data flow: upload, quality checks, embed items and texts, cosine similarity, scores, results and exports.">
362
- <defs>
363
- <marker id="arw2" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">
364
- <path d="M0,0 L7,3 L0,6 Z" fill="var(--muted)"/>
365
- </marker>
366
- </defs>
367
- <g>
368
- <rect class="box" x="15" y="30" width="120" height="54" rx="8"/>
369
- <text class="lbl-sm" x="75" y="52" text-anchor="middle">1. Upload</text>
370
- <text class="lbl-sm" x="75" y="68" text-anchor="middle">corpus + construct</text>
371
-
372
- <rect class="box" x="165" y="30" width="120" height="54" rx="8"/>
373
- <text class="lbl-sm" x="225" y="52" text-anchor="middle">2. Quality checks</text>
374
- <text class="lbl-sm" x="225" y="68" text-anchor="middle">language, warnings</text>
375
-
376
- <rect class="box-accent" x="315" y="30" width="130" height="54" rx="8"/>
377
- <text class="lbl-sm" x="380" y="52" text-anchor="middle">3. Embed both</text>
378
- <text class="lbl-sm" x="380" y="68" text-anchor="middle">items &amp; texts, one model</text>
379
-
380
- <rect class="box-accent" x="475" y="30" width="130" height="54" rx="8"/>
381
- <text class="lbl-sm" x="540" y="52" text-anchor="middle">4. Cosine similarity</text>
382
- <text class="lbl-sm" x="540" y="68" text-anchor="middle">text × each item</text>
383
-
384
- <rect class="box" x="635" y="30" width="110" height="54" rx="8"/>
385
- <text class="lbl-sm" x="690" y="52" text-anchor="middle">5. CCR score</text>
386
- <text class="lbl-sm" x="690" y="68" text-anchor="middle">mean of items</text>
387
-
388
- <rect class="box" x="240" y="120" width="280" height="50" rx="8"/>
389
- <text class="lbl-sm" x="380" y="141" text-anchor="middle">6. Results: distributions, per-item loadings,</text>
390
- <text class="lbl-sm" x="380" y="157" text-anchor="middle">top/bottom texts + CSV, metadata, repro script</text>
391
- </g>
392
- <path class="flow" style="marker-end:url(#arw2)" d="M135,57 L163,57"/>
393
- <path class="flow" style="marker-end:url(#arw2)" d="M285,57 L313,57"/>
394
- <path class="flow" style="marker-end:url(#arw2)" d="M445,57 L473,57"/>
395
- <path class="flow" style="marker-end:url(#arw2)" d="M605,57 L633,57"/>
396
- <path class="flow" style="marker-end:url(#arw2)" d="M690,84 L690,120 L522,145"/>
397
- </svg>
398
- <figcaption>CCR in one line: embed the validated scale items and your texts with
399
- the <em>same</em> model, then each text's cosine similarity to the items is its
400
- loading; the mean across items is the CCR score. It is deterministic embeddings
401
- plus arithmetic — no LLM, no prompting, no randomness.</figcaption>
402
- </figure>
403
-
404
- <h3>Where your data lives (and for how long)</h3>
405
- <ul>
406
- <li><b>Records</b> (account, projects, run results and summaries) → the Postgres
407
- database. Persistent, backed up.</li>
408
- <li><b>Uploaded files</b> → object storage. Signed-in users' files persist;
409
- anonymous uploads are deleted the moment their analysis finishes.</li>
410
- <li><b>Anonymous sessions</b> are wiped entirely after 24&nbsp;hours; nothing you
411
- upload anonymously is kept.</li>
412
- <li><b>Reproducibility</b>: every run also records the exact model version, an
413
- item-wording hash, and package versions, so the downloadable script
414
- reproduces the numbers on any machine.</li>
415
- </ul>
416
-
417
- <h3>A few engineering choices worth knowing</h3>
418
- <ul>
419
- <li><b>Same model for items and texts</b>, always — mixing models makes scores
420
- non-comparable, so the platform records the model on every run and warns you.</li>
421
- <li><b>MiniLM is the default</b> because it is the reference model in the published
422
- CCR work; the stronger E5 models are available and clearly labelled.</li>
423
- <li><b>Re-running a new construct on the same corpus is near-instant</b> — the
424
- text embeddings are cached and reused (identical every time), so only the
425
- cheap similarity step repeats.</li>
426
- <li><b>Warnings never silently "fix" your data</b> — the platform surfaces issues
427
- (wrong language, truncation, duplicates) and lets you decide.</li>
428
- </ul>
429
-
430
- <h2 id="feedback">12. Found something off?</h2>
431
  <p>Anything that doesn't match what this guide says it should do - or anything confusing,
432
  slow, or missing - post it in the lab's <b>#ccr Slack channel</b>: the 🐞 thread for bugs,
433
  the 💡 thread for ideas and feature requests. One line is enough; note the section number
 
120
  have</b>.</p>
121
 
122
  <h2 id="limits">Limits at a glance</h2>
123
+ <p>Accounts come in four tiers, set by the admins: <b>external user</b> (the default on
124
+ sign-up), <b>lab member</b>, <b>maintainer</b>, and <b>PI</b>. Signing in lifts the
125
+ anonymous caps; lab members and above have no saved-run cap. The row limit is usually
126
+ what you hit first, not the file size.</p>
127
  <div class="tablewrap">
128
  <table>
129
+ <tr><th>Limit</th><th>Signed out</th><th>External user</th><th>Lab member +</th></tr>
130
  <tr><td>Upload size</td><td>5&nbsp;MB</td><td>50&nbsp;MB</td><td>50&nbsp;MB</td></tr>
131
+ <tr><td>Rows per file</td><td>200</td><td>50,000</td><td>50,000</td></tr>
132
  <tr><td>Runs per day</td><td>3, then sign in</td><td>unlimited</td><td>unlimited</td></tr>
133
  <tr><td>Saved runs kept</td><td>none (file deleted after each run)</td><td>15</td><td>unlimited</td></tr>
134
  </table>
 
293
  <ol>
294
  <li>Sign in, upload, run: no ANONYMOUS_DATA_REMOVED warning; re-running the same corpus
295
  works (file kept).</li>
296
+ <li>Saved-run budget: the Step 3 card shows "N of M saved runs used". Lab members and
297
+ above have no cap; external accounts get 15. At the cap, new runs are refused until
298
+ you delete old runs/projects (nothing is auto-deleted).</li>
299
  <li>Ownership: your projects are invisible to signed-out visitors and other accounts.
300
  Anonymous projects stay shared.</li>
301
  </ol>
 
310
  dupes): identical scores for identical texts, less compute.</li>
311
  </ol>
312
 
313
+ <h2 id="architecture">10. Under the hood</h2>
314
+ <p>Architecture, data flow, data retention, and the full access/roles model
315
+ (tiers, invite links, pre-assigned roles, audit trail) live on their own page:
316
+ <a href="/product"><b>Product &amp; Architecture →</b></a>. This guide stays
317
+ focused on using and testing the platform.</p>
318
 
319
+ <h2 id="feedback">11. Found something off?</h2>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
  <p>Anything that doesn't match what this guide says it should do - or anything confusing,
321
  slow, or missing - post it in the lab's <b>#ccr Slack channel</b>: the 🐞 thread for bugs,
322
  the 💡 thread for ideas and feature requests. One line is enough; note the section number
backend/app/main.py CHANGED
@@ -13,6 +13,7 @@ import json
13
  import os
14
  import uuid
15
  from contextlib import asynccontextmanager
 
16
  from pathlib import Path
17
 
18
  from fastapi import Depends, FastAPI, HTTPException, Request, Response, UploadFile
@@ -31,7 +32,7 @@ from .construct_files import parse_construct_file
31
  from .construct_lib import sync_library
32
  from .db import DATA_DIR, Base, SessionLocal, auto_migrate_sqlite, engine, get_db
33
  from .ingest import IngestError, load_corpus, max_rows as corpus_max_rows, suggest_text_column
34
- from .models import Construct, Corpus, Job, Project, User
35
  from .reproducibility import (
36
  requirements_filename,
37
  requirements_text,
@@ -255,13 +256,15 @@ def auth_me(
255
  ):
256
  if user:
257
  row = db.get(User, user["id"])
258
- role = (row.role if row else None) or "member"
259
  return {
260
  "signed_in": True,
261
  "name": user["name"],
262
  "email": user["email"],
263
  "role": role,
264
- "is_admin": auth.is_admin(user["email"]),
 
 
265
  # max_rows is NOT unlimited for signed-in users: CCR_MAX_ROWS is a
266
  # global ingest ceiling and is usually the limit that actually
267
  # binds (deployed instances set it well below the byte ceiling).
@@ -270,8 +273,8 @@ def auth_me(
270
  "limits": {"max_bytes": max_upload_bytes(), "max_rows": corpus_max_rows()},
271
  "usage": {
272
  "saved_runs": _saved_runs_used(db, user["id"]),
273
- # lab accounts: unlimited saved runs (admin-granted role)
274
- "max_saved_runs": None if role == "lab" else auth.user_max_saved_runs(),
275
  },
276
  }
277
  return {
@@ -287,6 +290,26 @@ def auth_me(
287
  }
288
 
289
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
  @app.post("/api/auth/register", status_code=201)
291
  def register(body: RegisterIn, response: Response, db: Session = Depends(get_db)):
292
  email = body.email.strip().lower()
@@ -296,7 +319,16 @@ def register(body: RegisterIn, response: Response, db: Session = Depends(get_db)
296
  raise HTTPException(400, f"Password must be at least {auth.MIN_PASSWORD_LEN} characters.")
297
  if db.query(User).filter_by(email=email).first():
298
  raise HTTPException(409, "An account with this email already exists. Sign in instead.")
299
- user = User(email=email, name=body.name.strip(), password_hash=auth.hash_password(body.password))
 
 
 
 
 
 
 
 
 
300
  db.add(user)
301
  db.commit()
302
  _set_session_cookie(response, user)
@@ -356,8 +388,10 @@ def google_callback(request: Request, code: str = "", db: Session = Depends(get_
356
  user = db.query(User).filter_by(email=info["email"]).first()
357
  if user is None:
358
  # Google-verified account: no local password (password login is refused
359
- # with a pointer to the Google button).
360
- user = User(email=info["email"], name=info["name"], password_hash="")
 
 
361
  db.add(user)
362
  db.commit()
363
 
@@ -677,9 +711,9 @@ def create_job(
677
  )
678
  else:
679
  # Signed-in tier: saved-run cap instead of deletion (their data, their
680
- # call). Lab-role accounts (admin-granted) are uncapped.
681
  row = db.get(User, user["id"])
682
- if (row.role if row else "member") != "lab" and (
683
  _saved_runs_used(db, user["id"]) >= auth.user_max_saved_runs()
684
  ):
685
  raise HTTPException(
@@ -840,6 +874,20 @@ def testing_guide():
840
  return FileResponse(GUIDE_HTML, media_type="text/html")
841
 
842
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
843
  @app.get("/admin", include_in_schema=False)
844
  def admin_page():
845
  """Serve the SPA at /admin; the frontend renders the admin view there
 
13
  import os
14
  import uuid
15
  from contextlib import asynccontextmanager
16
+ from datetime import datetime, timezone
17
  from pathlib import Path
18
 
19
  from fastapi import Depends, FastAPI, HTTPException, Request, Response, UploadFile
 
32
  from .construct_lib import sync_library
33
  from .db import DATA_DIR, Base, SessionLocal, auto_migrate_sqlite, engine, get_db
34
  from .ingest import IngestError, load_corpus, max_rows as corpus_max_rows, suggest_text_column
35
+ from .models import AdminAudit, Construct, Corpus, Job, Project, RoleAssignment, User
36
  from .reproducibility import (
37
  requirements_filename,
38
  requirements_text,
 
256
  ):
257
  if user:
258
  row = db.get(User, user["id"])
259
+ role = auth.normalize_role(row.role if row else None)
260
  return {
261
  "signed_in": True,
262
  "name": user["name"],
263
  "email": user["email"],
264
  "role": role,
265
+ # effective admin: env allowlist OR staff role (pi/maintainer) -
266
+ # drives the Admin link in the header
267
+ "is_admin": auth.is_admin(user["email"]) or auth.role_is_staff(role),
268
  # max_rows is NOT unlimited for signed-in users: CCR_MAX_ROWS is a
269
  # global ingest ceiling and is usually the limit that actually
270
  # binds (deployed instances set it well below the byte ceiling).
 
273
  "limits": {"max_bytes": max_upload_bytes(), "max_rows": corpus_max_rows()},
274
  "usage": {
275
  "saved_runs": _saved_runs_used(db, user["id"]),
276
+ # lab members and above: unlimited saved runs (admin-granted)
277
+ "max_saved_runs": None if auth.role_unlimited(role) else auth.user_max_saved_runs(),
278
  },
279
  }
280
  return {
 
290
  }
291
 
292
 
293
+ def _initial_role(db: Session, email: str, invite_token: str | None = None) -> str:
294
+ """Tier a brand-new account lands at. Precedence: an email-bound
295
+ pre-assignment (may carry staff roles - the 'credentials before first
296
+ sign-in' case) beats an invite link (bearer token, external/lab only)
297
+ beats the external default. Claiming is recorded in the audit trail."""
298
+ pre = db.query(RoleAssignment).filter_by(email=email).first()
299
+ if pre is not None and not pre.claimed_at:
300
+ pre.claimed_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
301
+ role = auth.normalize_role(pre.role)
302
+ db.add(AdminAudit(actor_email=email, action="role_claimed", target=email,
303
+ detail=f"pre-assigned {role} by {pre.assigned_by}"))
304
+ return role
305
+ invited = auth.verify_invite_token(invite_token)
306
+ if invited:
307
+ db.add(AdminAudit(actor_email=email, action="invite_redeemed",
308
+ target=email, detail=invited))
309
+ return invited
310
+ return "external"
311
+
312
+
313
  @app.post("/api/auth/register", status_code=201)
314
  def register(body: RegisterIn, response: Response, db: Session = Depends(get_db)):
315
  email = body.email.strip().lower()
 
319
  raise HTTPException(400, f"Password must be at least {auth.MIN_PASSWORD_LEN} characters.")
320
  if db.query(User).filter_by(email=email).first():
321
  raise HTTPException(409, "An account with this email already exists. Sign in instead.")
322
+ # A dead invite link should say so, not silently demote to external -
323
+ # unless a pre-assignment covers the email anyway.
324
+ has_preassignment = db.query(RoleAssignment).filter_by(email=email).first() is not None
325
+ if body.invite_token and not auth.verify_invite_token(body.invite_token) and not has_preassignment:
326
+ raise HTTPException(400, "This invite link is invalid or has expired. Ask for a new one.")
327
+ user = User(
328
+ email=email, name=body.name.strip(),
329
+ password_hash=auth.hash_password(body.password),
330
+ role=_initial_role(db, email, body.invite_token),
331
+ )
332
  db.add(user)
333
  db.commit()
334
  _set_session_cookie(response, user)
 
388
  user = db.query(User).filter_by(email=info["email"]).first()
389
  if user is None:
390
  # Google-verified account: no local password (password login is refused
391
+ # with a pointer to the Google button). Pre-assigned roles apply here
392
+ # too - the "credentials before first sign-in" path works either way.
393
+ user = User(email=info["email"], name=info["name"], password_hash="",
394
+ role=_initial_role(db, info["email"].strip().lower()))
395
  db.add(user)
396
  db.commit()
397
 
 
711
  )
712
  else:
713
  # Signed-in tier: saved-run cap instead of deletion (their data, their
714
+ # call). Lab members and above (admin-granted roles) are uncapped.
715
  row = db.get(User, user["id"])
716
+ if not auth.role_unlimited(row.role if row else None) and (
717
  _saved_runs_used(db, user["id"]) >= auth.user_max_saved_runs()
718
  ):
719
  raise HTTPException(
 
874
  return FileResponse(GUIDE_HTML, media_type="text/html")
875
 
876
 
877
+ # /product: the under-the-hood companion to /guide - access model (tiers,
878
+ # invites, pre-assignments, audit), architecture, data flow, retention.
879
+ # The guide stays a how-to-use-and-test document; this page holds the
880
+ # product/architecture detail (split requested 2026-07-22).
881
+ PRODUCT_HTML = Path(__file__).resolve().parent / "product.html"
882
+
883
+
884
+ @app.get("/product", include_in_schema=False)
885
+ def product_page():
886
+ if not PRODUCT_HTML.exists():
887
+ raise HTTPException(404, "Product page not available on this instance.")
888
+ return FileResponse(PRODUCT_HTML, media_type="text/html")
889
+
890
+
891
  @app.get("/admin", include_in_schema=False)
892
  def admin_page():
893
  """Serve the SPA at /admin; the frontend renders the admin view there
backend/app/models.py CHANGED
@@ -29,12 +29,51 @@ class User(Base):
29
  email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
30
  name: Mapped[str] = mapped_column(String(120), default="")
31
  password_hash: Mapped[str] = mapped_column(Text) # scrypt$salt$digest (auth.py)
32
- role: Mapped[str] = mapped_column(String(16), default="member") # member | lab
33
- # "lab" = unlimited saved runs (set via /admin). Admins are NOT a DB role:
34
- # admin access is granted by the ADMIN_EMAILS env allowlist (auth.is_admin).
 
 
 
35
  created_at: Mapped[str] = mapped_column(String(32), default=_now)
36
 
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  class Project(Base):
39
  __tablename__ = "projects"
40
 
 
29
  email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
30
  name: Mapped[str] = mapped_column(String(120), default="")
31
  password_hash: Mapped[str] = mapped_column(Text) # scrypt$salt$digest (auth.py)
32
+ role: Mapped[str] = mapped_column(String(16), default="external")
33
+ # external | lab | maintainer | pi (auth.ROLES; legacy "member" reads as
34
+ # external). lab+ = unlimited saved runs; maintainer/pi also get /admin.
35
+ # Staff roles are grantable only by ADMIN_EMAILS env-allowlisted admins
36
+ # (admin.py guards), so the API cannot self-escalate; the env allowlist
37
+ # remains the bootstrap and break-glass admin path.
38
  created_at: Mapped[str] = mapped_column(String(32), default=_now)
39
 
40
 
41
+ class RoleAssignment(Base):
42
+ """Pre-provisioned access: a role bound to an email BEFORE the account
43
+ exists (PI request 2026-07-22 - e.g. an external collaborator who must
44
+ land with full credentials on first sign-in, Google or password).
45
+
46
+ Unlike invite links (bearer tokens, external/lab only), assignments are
47
+ email-bound and may carry staff roles - so creating one for pi/maintainer
48
+ requires escalation rights. Claimed rows are kept as history."""
49
+
50
+ __tablename__ = "role_assignments"
51
+
52
+ id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_uuid)
53
+ email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
54
+ role: Mapped[str] = mapped_column(String(16))
55
+ assigned_by: Mapped[str] = mapped_column(String(255), default="")
56
+ created_at: Mapped[str] = mapped_column(String(32), default=_now)
57
+ claimed_at: Mapped[str] = mapped_column(String(32), default="") # "" = pending
58
+
59
+
60
+ class AdminAudit(Base):
61
+ """Append-only trail of admin actions (who did what to whom, when).
62
+
63
+ What makes multiple admins trustworthy: role grants, password resets,
64
+ deletions, invites, requeues, and verification changes all land here.
65
+ Never updated or deleted from the app."""
66
+
67
+ __tablename__ = "admin_audit"
68
+
69
+ id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_uuid)
70
+ at: Mapped[str] = mapped_column(String(32), default=_now)
71
+ actor_email: Mapped[str] = mapped_column(String(255))
72
+ action: Mapped[str] = mapped_column(String(40)) # e.g. set_role, invite_created
73
+ target: Mapped[str] = mapped_column(String(300), default="") # email/name acted on
74
+ detail: Mapped[str] = mapped_column(Text, default="")
75
+
76
+
77
  class Project(Base):
78
  __tablename__ = "projects"
79
 
backend/app/product.html ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>CCR Platform - Product &amp; Architecture</title>
7
+ <style>
8
+ :root {
9
+ --bg: #ffffff; --fg: #1a1a1a; --muted: #666; --border: #ddd;
10
+ --accent: #2456a6; --card: #f7f7f8; --amber-bg: #fff7e0; --amber-border: #e0b84d;
11
+ --code-bg: #f0f0f2;
12
+ }
13
+ @media (prefers-color-scheme: dark) {
14
+ :root {
15
+ --bg: #16181c; --fg: #e6e6e6; --muted: #9a9a9a; --border: #3a3d44;
16
+ --accent: #7aa7e8; --card: #1f2228; --amber-bg: #2e2810; --amber-border: #8a6d1f;
17
+ --code-bg: #24272e;
18
+ }
19
+ }
20
+ * { box-sizing: border-box; }
21
+ body {
22
+ margin: 0; background: var(--bg); color: var(--fg);
23
+ font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
24
+ }
25
+ main { max-width: 860px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
26
+ h1 { font-size: 1.7rem; margin: 0 0 .25rem; }
27
+ h2 { font-size: 1.25rem; margin: 2.2rem 0 .6rem; padding-top: .6rem; border-top: 1px solid var(--border); }
28
+ h3 { font-size: 1.02rem; margin: 1.2rem 0 .4rem; }
29
+ p, li { color: var(--fg); }
30
+ .sub { color: var(--muted); margin: 0 0 1.2rem; }
31
+ a { color: var(--accent); }
32
+ code { background: var(--code-bg); padding: .1em .35em; border-radius: 4px; font-size: .9em; }
33
+ .note {
34
+ background: var(--amber-bg); border: 1px solid var(--amber-border);
35
+ border-radius: 8px; padding: .8rem 1rem; margin: 1rem 0;
36
+ }
37
+ .note b { display: block; margin-bottom: .25rem; }
38
+ .tablewrap { overflow-x: auto; }
39
+ table { border-collapse: collapse; width: 100%; margin: .8rem 0; font-size: .93rem; }
40
+ th, td { border: 1px solid var(--border); padding: .45rem .6rem; text-align: left; vertical-align: top; }
41
+ th { background: var(--card); }
42
+ ol li, ul li { margin: .3rem 0; }
43
+ .top { position: fixed; right: 1rem; bottom: 1rem; background: var(--card);
44
+ border: 1px solid var(--border); border-radius: 8px; padding: .4rem .7rem;
45
+ text-decoration: none; font-size: .85rem; }
46
+ figure { margin: 1rem 0 1.4rem; }
47
+ figure svg { width: 100%; height: auto; border: 1px solid var(--border);
48
+ border-radius: 8px; background: var(--card); }
49
+ figcaption { color: var(--muted); font-size: .85rem; margin-top: .4rem; }
50
+ .box { fill: var(--bg); stroke: var(--border); stroke-width: 1.5; }
51
+ .box-accent { fill: none; stroke: var(--accent); stroke-width: 2; }
52
+ .lbl { fill: var(--fg); font: 600 13px sans-serif; }
53
+ .lbl-sm { fill: var(--muted); font: 11px sans-serif; }
54
+ .flow { stroke: var(--muted); stroke-width: 1.5; fill: none; marker-end: url(#arw); }
55
+ h2[id], h3[id] { scroll-margin-top: 1rem; }
56
+ .anchor {
57
+ opacity: 0; text-decoration: none; color: var(--muted); font-weight: 400;
58
+ margin-left: .4rem; cursor: pointer; transition: opacity .12s;
59
+ }
60
+ h2:hover .anchor, h3:hover .anchor, .anchor:focus { opacity: .7; }
61
+ .anchor:hover { opacity: 1; color: var(--accent); }
62
+ :target { animation: flash 1.4s ease-out; }
63
+ @keyframes flash {
64
+ from { background: var(--amber-bg); }
65
+ to { background: transparent; }
66
+ }
67
+ .toast {
68
+ position: fixed; left: 50%; bottom: 1.5rem; transform: translateX(-50%);
69
+ background: var(--accent); color: #fff; padding: .5rem .9rem; border-radius: 8px;
70
+ font-size: .85rem; opacity: 0; pointer-events: none; transition: opacity .2s;
71
+ }
72
+ .toast.show { opacity: 1; }
73
+ .yes { color: var(--accent); font-weight: 600; }
74
+ .no { color: var(--muted); }
75
+ @media (prefers-reduced-motion: reduce) {
76
+ :target { animation: none; }
77
+ .anchor { transition: none; }
78
+ }
79
+ </style>
80
+ </head>
81
+ <body>
82
+ <main>
83
+
84
+ <h1>CCR Platform - Product &amp; Architecture</h1>
85
+ <p class="sub">The under-the-hood companion to the <a href="/guide">testing guide</a>:
86
+ who can do what, how access is governed, what runs where, and the engineering
87
+ decisions behind it. The guide tells you how to <em>use</em> the platform; this
88
+ page tells you how it <em>works</em>.</p>
89
+
90
+ <h2 id="access">1. Access model: four tiers + break-glass</h2>
91
+ <p>Every account has one of four roles. New sign-ups land as <b>external user</b>
92
+ unless an invite link or a pre-assigned role says otherwise (see
93
+ <a href="#prelogin">§2</a>). Roles are managed entirely in the app, on the
94
+ <code>/admin</code> page - no server configuration involved.</p>
95
+
96
+ <figure>
97
+ <svg viewBox="0 0 760 330" xmlns="http://www.w3.org/2000/svg" role="img"
98
+ aria-label="Access model: env allowlist and PI hold escalation rights over maintainer, who manages lab members and external users. Every action is audited.">
99
+ <defs>
100
+ <marker id="arw" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">
101
+ <path d="M0,0 L7,3 L0,6 Z" fill="var(--muted)"/>
102
+ </marker>
103
+ </defs>
104
+ <!-- top row: escalation holders -->
105
+ <rect class="box" x="40" y="20" width="230" height="64" rx="8" stroke-dasharray="5,4"/>
106
+ <text class="lbl" x="155" y="44" text-anchor="middle">ADMIN_EMAILS (env)</text>
107
+ <text class="lbl-sm" x="155" y="62" text-anchor="middle">bootstrap + break-glass only</text>
108
+ <rect class="box-accent" x="330" y="20" width="180" height="64" rx="8"/>
109
+ <text class="lbl" x="420" y="44" text-anchor="middle">PI</text>
110
+ <text class="lbl-sm" x="420" y="62" text-anchor="middle">runs the team from /admin</text>
111
+ <rect class="box" x="560" y="20" width="170" height="64" rx="8"/>
112
+ <text class="lbl" x="645" y="44" text-anchor="middle">Audit trail</text>
113
+ <text class="lbl-sm" x="645" y="62" text-anchor="middle">every action recorded</text>
114
+ <!-- escalation arrows -->
115
+ <path class="flow" d="M155,84 L155,120 L330,150"/>
116
+ <path class="flow" d="M420,84 L420,148"/>
117
+ <text class="lbl-sm" x="440" y="115">grant / revoke any role,</text>
118
+ <text class="lbl-sm" x="440" y="130">act on staff accounts</text>
119
+ <!-- maintainer -->
120
+ <rect class="box-accent" x="330" y="150" width="180" height="64" rx="8"/>
121
+ <text class="lbl" x="420" y="174" text-anchor="middle">Maintainer</text>
122
+ <text class="lbl-sm" x="420" y="192" text-anchor="middle">operations + construct verification</text>
123
+ <!-- maintainer arrows -->
124
+ <path class="flow" d="M420,214 L420,248"/>
125
+ <text class="lbl-sm" x="440" y="238">manage lab / external, invites,</text>
126
+ <text class="lbl-sm" x="440" y="253">resets, requeues</text>
127
+ <!-- bottom row -->
128
+ <rect class="box" x="180" y="250" width="180" height="60" rx="8"/>
129
+ <text class="lbl" x="270" y="274" text-anchor="middle">Lab member</text>
130
+ <text class="lbl-sm" x="270" y="292" text-anchor="middle">unlimited saved runs</text>
131
+ <rect class="box" x="400" y="250" width="180" height="60" rx="8"/>
132
+ <text class="lbl" x="490" y="274" text-anchor="middle">External user</text>
133
+ <text class="lbl-sm" x="490" y="292" text-anchor="middle">saved-run cap (15)</text>
134
+ </svg>
135
+ <figcaption>Escalation flows top-down only: PIs (and the env allowlist, as recovery)
136
+ manage staff; maintainers manage members; nobody can raise their own role. The
137
+ audit trail sees everything.</figcaption>
138
+ </figure>
139
+
140
+ <h3 id="matrix">Capability matrix</h3>
141
+ <div class="tablewrap">
142
+ <table>
143
+ <tr><th>Capability</th><th>Signed out</th><th>External user</th><th>Lab member</th><th>Maintainer</th><th>PI</th></tr>
144
+ <tr><td>Run analyses</td><td>3/day, data deleted after</td><td class="yes">✓</td><td class="yes">✓</td><td class="yes">✓</td><td class="yes">✓</td></tr>
145
+ <tr><td>Saved runs</td><td class="no">none kept</td><td>15</td><td class="yes">unlimited</td><td class="yes">unlimited</td><td class="yes">unlimited</td></tr>
146
+ <tr><td>/admin: manage lab &amp; external users, password resets, requeue failed runs, invites, pre-assignments</td><td class="no">-</td><td class="no">-</td><td class="no">-</td><td class="yes">✓</td><td class="yes">✓</td></tr>
147
+ <tr><td>Construct verification (mark scales verified)</td><td class="no">-</td><td class="no">-</td><td class="no">-</td><td class="yes">✓ their job</td><td class="no">queue visible, read-only</td></tr>
148
+ <tr><td>Grant/revoke PI &amp; maintainer; act on staff accounts</td><td class="no">-</td><td class="no">-</td><td class="no">-</td><td class="no">-</td><td class="yes">✓</td></tr>
149
+ <tr><td>Audit trail (who did what, when)</td><td class="no">-</td><td class="no">-</td><td class="no">-</td><td class="no">-</td><td class="yes">✓</td></tr>
150
+ </table>
151
+ </div>
152
+ <p>Two deliberate asymmetries: <b>verification belongs to maintainers</b> - the
153
+ trail then names the responsible RA, not whichever admin clicked - while
154
+ <b>oversight belongs to the PI</b>, who sees everything (including the audit
155
+ trail) but delegates the hands-on QA. And <b>nobody can change their own
156
+ role</b>, so no session can quietly climb the ladder.</p>
157
+
158
+ <div class="note">
159
+ <b>Break-glass: the ADMIN_EMAILS environment variable</b>
160
+ A short server-side allowlist holds the same powers as the PI role. It exists
161
+ for exactly two reasons: seeding the very first PI (someone has to grant the
162
+ first role) and recovering a locked-out lab. Day-to-day it should be one
163
+ entry - the developer - and everything else lives in the app.
164
+ </div>
165
+
166
+ <h2 id="prelogin">2. Access before first sign-in</h2>
167
+ <p>Three ways someone has the right access <em>before</em> they ever log in,
168
+ in increasing order of privilege:</p>
169
+ <ol>
170
+ <li><b>Anonymous use</b> - no account at all: 3 runs/day, small files, uploads
171
+ deleted right after analysis. The zero-commitment trial tier.</li>
172
+ <li><b>Invite links</b> - a signed URL an admin copies from /admin into Slack
173
+ or an email. Whoever registers through it lands as a <b>lab member</b> (or
174
+ external user). Bearer-style: anyone with the link can use it, so it only
175
+ carries non-staff roles and expires after 7 days. It cannot be revoked
176
+ early (it is stateless by design); creation and every redemption are audited.</li>
177
+ <li><b>Pre-assigned roles</b> - a role bound to a <em>specific email</em>
178
+ before the account exists. When that person first signs in - password or
179
+ Google - they land at the assigned tier, staff roles included. This is how
180
+ an external collaborator gets full credentials on day one: pre-assign
181
+ <code>maintainer</code> to their email, send them the URL, done. Staff
182
+ pre-assignments require PI (or break-glass) rights, are listed as pending
183
+ until claimed, and can be removed any time before the claim.</li>
184
+ </ol>
185
+
186
+ <h2 id="architecture">3. How it works (architecture &amp; data flow)</h2>
187
+ <p>The whole system is deliberately simple: one application, a few managed
188
+ services around it, and no third-party AI APIs — the embedding models run on
189
+ our own server, so uploaded text never leaves the platform.</p>
190
+
191
+ <h3>What runs where</h3>
192
+ <figure>
193
+ <svg viewBox="0 0 760 300" xmlns="http://www.w3.org/2000/svg" role="img"
194
+ aria-label="Architecture: browser talks to one app container, which uses Postgres, R2, and Supabase/Google auth.">
195
+ <defs>
196
+ <marker id="arw3" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">
197
+ <path d="M0,0 L7,3 L0,6 Z" fill="var(--muted)"/>
198
+ </marker>
199
+ </defs>
200
+ <!-- browser -->
201
+ <rect class="box" x="20" y="120" width="150" height="60" rx="8"/>
202
+ <text class="lbl" x="95" y="147" text-anchor="middle">Your browser</text>
203
+ <text class="lbl-sm" x="95" y="165" text-anchor="middle">the web interface</text>
204
+ <!-- app container -->
205
+ <rect class="box-accent" x="270" y="90" width="220" height="120" rx="10"/>
206
+ <text class="lbl" x="380" y="115" text-anchor="middle">One app container</text>
207
+ <text class="lbl-sm" x="380" y="135" text-anchor="middle">API + interface (FastAPI/React)</text>
208
+ <text class="lbl-sm" x="380" y="152" text-anchor="middle">embedding models run here</text>
209
+ <text class="lbl-sm" x="380" y="169" text-anchor="middle">(MiniLM, E5) — no external AI</text>
210
+ <text class="lbl-sm" x="380" y="190" text-anchor="middle">hosted on Hugging Face</text>
211
+ <!-- stores -->
212
+ <rect class="box" x="590" y="30" width="150" height="52" rx="8"/>
213
+ <text class="lbl" x="665" y="52" text-anchor="middle">Postgres</text>
214
+ <text class="lbl-sm" x="665" y="69" text-anchor="middle">records (Supabase)</text>
215
+ <rect class="box" x="590" y="124" width="150" height="52" rx="8"/>
216
+ <text class="lbl" x="665" y="146" text-anchor="middle">Object storage</text>
217
+ <text class="lbl-sm" x="665" y="163" text-anchor="middle">files (Cloudflare R2)</text>
218
+ <rect class="box" x="590" y="218" width="150" height="52" rx="8"/>
219
+ <text class="lbl" x="665" y="240" text-anchor="middle">Sign-in</text>
220
+ <text class="lbl-sm" x="665" y="257" text-anchor="middle">Google / Supabase</text>
221
+ <!-- arrows -->
222
+ <path class="flow" style="marker-end:url(#arw3)" d="M170,150 L268,150"/>
223
+ <path class="flow" style="marker-end:url(#arw3)" d="M492,140 L588,58"/>
224
+ <path class="flow" style="marker-end:url(#arw3)" d="M492,150 L588,150"/>
225
+ <path class="flow" style="marker-end:url(#arw3)" d="M492,162 L588,244"/>
226
+ </svg>
227
+ <figcaption>The interface, the API, and the embedding models all live in one
228
+ container. Around it: a persistent database for records, object storage for
229
+ uploaded files, and Google/Supabase for sign-in. Every piece is on a free tier.</figcaption>
230
+ </figure>
231
+
232
+ <h3>What happens during a run</h3>
233
+ <figure>
234
+ <svg viewBox="0 0 760 190" xmlns="http://www.w3.org/2000/svg" role="img"
235
+ aria-label="Data flow: upload, quality checks, embed items and texts, cosine similarity, scores, results and exports.">
236
+ <defs>
237
+ <marker id="arw2" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">
238
+ <path d="M0,0 L7,3 L0,6 Z" fill="var(--muted)"/>
239
+ </marker>
240
+ </defs>
241
+ <g>
242
+ <rect class="box" x="15" y="30" width="120" height="54" rx="8"/>
243
+ <text class="lbl-sm" x="75" y="52" text-anchor="middle">1. Upload</text>
244
+ <text class="lbl-sm" x="75" y="68" text-anchor="middle">corpus + construct</text>
245
+
246
+ <rect class="box" x="165" y="30" width="120" height="54" rx="8"/>
247
+ <text class="lbl-sm" x="225" y="52" text-anchor="middle">2. Quality checks</text>
248
+ <text class="lbl-sm" x="225" y="68" text-anchor="middle">language, warnings</text>
249
+
250
+ <rect class="box-accent" x="315" y="30" width="130" height="54" rx="8"/>
251
+ <text class="lbl-sm" x="380" y="52" text-anchor="middle">3. Embed both</text>
252
+ <text class="lbl-sm" x="380" y="68" text-anchor="middle">items &amp; texts, one model</text>
253
+
254
+ <rect class="box-accent" x="475" y="30" width="130" height="54" rx="8"/>
255
+ <text class="lbl-sm" x="540" y="52" text-anchor="middle">4. Cosine similarity</text>
256
+ <text class="lbl-sm" x="540" y="68" text-anchor="middle">text × each item</text>
257
+
258
+ <rect class="box" x="635" y="30" width="110" height="54" rx="8"/>
259
+ <text class="lbl-sm" x="690" y="52" text-anchor="middle">5. CCR score</text>
260
+ <text class="lbl-sm" x="690" y="68" text-anchor="middle">mean of items</text>
261
+
262
+ <rect class="box" x="240" y="120" width="280" height="50" rx="8"/>
263
+ <text class="lbl-sm" x="380" y="141" text-anchor="middle">6. Results: distributions, per-item loadings,</text>
264
+ <text class="lbl-sm" x="380" y="157" text-anchor="middle">top/bottom texts + CSV, metadata, repro script</text>
265
+ </g>
266
+ <path class="flow" style="marker-end:url(#arw2)" d="M135,57 L163,57"/>
267
+ <path class="flow" style="marker-end:url(#arw2)" d="M285,57 L313,57"/>
268
+ <path class="flow" style="marker-end:url(#arw2)" d="M445,57 L473,57"/>
269
+ <path class="flow" style="marker-end:url(#arw2)" d="M605,57 L633,57"/>
270
+ <path class="flow" style="marker-end:url(#arw2)" d="M690,84 L690,120 L522,145"/>
271
+ </svg>
272
+ <figcaption>CCR in one line: embed the validated scale items and your texts with
273
+ the <em>same</em> model, then each text's cosine similarity to the items is its
274
+ loading; the mean across items is the CCR score. It is deterministic embeddings
275
+ plus arithmetic — no LLM, no prompting, no randomness.</figcaption>
276
+ </figure>
277
+
278
+ <h3>Where your data lives (and for how long)</h3>
279
+ <ul>
280
+ <li><b>Records</b> (account, projects, run results and summaries) → the Postgres
281
+ database. Persistent, backed up.</li>
282
+ <li><b>Uploaded files</b> → object storage. Signed-in users' files persist;
283
+ anonymous uploads are deleted the moment their analysis finishes.</li>
284
+ <li><b>Anonymous sessions</b> are wiped entirely after 24&nbsp;hours; nothing you
285
+ upload anonymously is kept.</li>
286
+ <li><b>Reproducibility</b>: every run also records the exact model version, an
287
+ item-wording hash, and package versions, so the downloadable script
288
+ reproduces the numbers on any machine.</li>
289
+ </ul>
290
+
291
+ <h3>A few engineering choices worth knowing</h3>
292
+ <ul>
293
+ <li><b>Same model for items and texts</b>, always — mixing models makes scores
294
+ non-comparable, so the platform records the model on every run and warns you.</li>
295
+ <li><b>MiniLM is the default</b> because it is the reference model in the published
296
+ CCR work; the stronger E5 models are available and clearly labelled.</li>
297
+ <li><b>Re-running a new construct on the same corpus is near-instant</b> — the
298
+ text embeddings are cached and reused (identical every time), so only the
299
+ cheap similarity step repeats.</li>
300
+ <li><b>Warnings never silently "fix" your data</b> — the platform surfaces issues
301
+ (wrong language, truncation, duplicates) and lets you decide.</li>
302
+ <li><b>Admin power is layered, not global</b> — see <a href="#access">§1</a>:
303
+ operations are delegated to maintainers, escalation stays with the PI, and
304
+ one env variable exists purely as the fire escape.</li>
305
+ </ul>
306
+
307
+ <h2 id="feedback">4. Questions?</h2>
308
+ <p>For how-to and testing scenarios, see the <a href="/guide">testing guide</a>.
309
+ Anything unclear on this page - post in the lab's <b>#ccr Slack channel</b> or email
310
+ <a href="mailto:devaanand@umass.edu">devaanand@umass.edu</a>.</p>
311
+
312
+ </main>
313
+ <a class="top" href="#">↑ Top</a>
314
+ <div class="toast" id="toast" role="status" aria-live="polite">Link copied</div>
315
+ <script>
316
+ (function () {
317
+ var toast = document.getElementById('toast'), timer;
318
+ function ping(msg) {
319
+ toast.textContent = msg;
320
+ toast.classList.add('show');
321
+ clearTimeout(timer);
322
+ timer = setTimeout(function () { toast.classList.remove('show'); }, 1600);
323
+ }
324
+ function share(id) {
325
+ if (history.replaceState) history.replaceState(null, '', '#' + id);
326
+ location.hash = id;
327
+ var url = location.href;
328
+ if (navigator.clipboard && navigator.clipboard.writeText) {
329
+ navigator.clipboard.writeText(url).then(
330
+ function () { ping('Section link copied'); },
331
+ function () { ping('Link in address bar'); }
332
+ );
333
+ } else {
334
+ ping('Link in address bar');
335
+ }
336
+ }
337
+ var heads = document.querySelectorAll('h2[id], h3[id]');
338
+ Array.prototype.forEach.call(heads, function (h) {
339
+ var a = document.createElement('a');
340
+ a.className = 'anchor';
341
+ a.href = '#' + h.id;
342
+ a.textContent = '#';
343
+ a.setAttribute('aria-label', 'Copy link to this section');
344
+ a.addEventListener('click', function (e) { e.preventDefault(); share(h.id); });
345
+ h.appendChild(a);
346
+ h.style.cursor = 'pointer';
347
+ h.addEventListener('click', function (e) {
348
+ if (e.target.tagName === 'A') return;
349
+ share(h.id);
350
+ });
351
+ });
352
+ })();
353
+ </script>
354
+ </body>
355
+ </html>
backend/app/schemas.py CHANGED
@@ -26,6 +26,7 @@ class RegisterIn(BaseModel):
26
  email: str = Field(min_length=3, max_length=255)
27
  password: str = Field(min_length=8, max_length=200)
28
  name: str = Field(min_length=1, max_length=120)
 
29
 
30
 
31
  class LoginIn(BaseModel):
 
26
  email: str = Field(min_length=3, max_length=255)
27
  password: str = Field(min_length=8, max_length=200)
28
  name: str = Field(min_length=1, max_length=120)
29
+ invite_token: str | None = None # signed invite link payload (auth.py)
30
 
31
 
32
  class LoginIn(BaseModel):
backend/static/assets/index-DRZ4Mumd.js ADDED
The diff for this file is too large to render. See raw diff
 
backend/static/assets/index-cmMEZ7Xf.js DELETED
The diff for this file is too large to render. See raw diff
 
backend/static/index.html CHANGED
@@ -4,7 +4,7 @@
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>CCR Platform - Contextualized Construct Representations</title>
7
- <script type="module" crossorigin src="/assets/index-cmMEZ7Xf.js"></script>
8
  <link rel="stylesheet" crossorigin href="/assets/index-CN_FzJfm.css">
9
  </head>
10
  <body>
 
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>CCR Platform - Contextualized Construct Representations</title>
7
+ <script type="module" crossorigin src="/assets/index-DRZ4Mumd.js"></script>
8
  <link rel="stylesheet" crossorigin href="/assets/index-CN_FzJfm.css">
9
  </head>
10
  <body>
backend/tests/test_admin.py CHANGED
@@ -126,6 +126,70 @@ def _as_admin(client):
126
  return client
127
 
128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  # --------------------------------------------------------- reset + delete
130
  def test_password_reset_issues_working_temp_password(client):
131
  register(client, "forgetful@lab.test")
@@ -228,12 +292,33 @@ def test_failed_job_requeue(client, monkeypatch):
228
 
229
 
230
  # ------------------------------------------------------------ verification
231
- def test_verification_queue_marks_verified(client):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  sign_in_as(client, ADMIN_EMAIL, "Admin")
233
  queue = client.get("/api/admin/constructs?status=needs_verification").json()
234
  assert len(queue) > 0
235
  target = queue[0]
236
 
 
 
 
 
 
 
 
237
  resp = client.post(
238
  f"/api/admin/constructs/{target['id']}/verification", json={"status": "verified"}
239
  )
@@ -244,3 +329,120 @@ def test_verification_queue_marks_verified(client):
244
  # visible to regular users too (flag disappears in the picker/details)
245
  pub = next(c for c in client.get("/api/constructs").json() if c["id"] == target["id"])
246
  assert pub["verification_status"] == "verified"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  return client
127
 
128
 
129
+ def _user_id(client, email):
130
+ return next(u["id"] for u in client.get("/api/admin/users").json() if u["email"] == email)
131
+
132
+
133
+ # ------------------------------------------- four tiers (PI decision 2026-07-22)
134
+ def test_staff_roles_get_admin_surface_and_unlimited_runs(client, monkeypatch):
135
+ monkeypatch.setenv("CCR_USER_MAX_SAVED_RUNS", "1")
136
+ register(client, "newpi@lab.test", "The PI")
137
+ me = client.get("/api/auth/me").json()
138
+ assert me["role"] == "external" and me["is_admin"] is False # default tier
139
+ assert me["usage"]["max_saved_runs"] == 1
140
+ client.post("/api/auth/logout")
141
+
142
+ _as_admin(client)
143
+ for role in ("pi", "maintainer", "lab", "external"): # all four grantable
144
+ resp = client.post(f"/api/admin/users/{_user_id(client, 'newpi@lab.test')}/role",
145
+ json={"role": role})
146
+ assert resp.status_code == 200 and resp.json()["role"] == role
147
+ client.post(f"/api/admin/users/{_user_id(client, 'newpi@lab.test')}/role",
148
+ json={"role": "pi"})
149
+ assert client.post(f"/api/admin/users/{_user_id(client, 'newpi@lab.test')}/role",
150
+ json={"role": "owner"}).status_code == 400
151
+ overview = client.get("/api/admin/overview").json()
152
+ assert overview["users_by_role"]["pi"] >= 1
153
+ client.post("/api/auth/logout")
154
+
155
+ client.post("/api/auth/login", json={"email": "newpi@lab.test", "password": "password123"})
156
+ me = client.get("/api/auth/me").json()
157
+ assert me["role"] == "pi"
158
+ assert me["is_admin"] is True # staff role grants the admin surface...
159
+ assert me["usage"]["max_saved_runs"] is None # ...and unlimited saved runs
160
+ assert client.get("/api/admin/overview").status_code == 200
161
+
162
+
163
+ def test_maintainer_cannot_escalate_or_touch_staff(client):
164
+ _as_admin(client)
165
+ for email, role in (("mnt@lab.test", "maintainer"), ("boss@lab.test", "pi"),
166
+ ("phd2@lab.test", "external")):
167
+ sign_in_as(client, email)
168
+ client.post("/api/auth/logout")
169
+ _as_admin(client)
170
+ client.post(f"/api/admin/users/{_user_id(client, email)}/role", json={"role": role})
171
+ boss_id, phd_id = _user_id(client, "boss@lab.test"), _user_id(client, "phd2@lab.test")
172
+ client.post("/api/auth/logout")
173
+
174
+ client.post("/api/auth/login", json={"email": "mnt@lab.test", "password": "password123"})
175
+ # operational surface works, and lab/external grants are allowed...
176
+ assert client.get("/api/admin/overview").status_code == 200
177
+ assert client.post(f"/api/admin/users/{phd_id}/role",
178
+ json={"role": "lab"}).status_code == 200
179
+ # ...but staff grants and any action on a staff account are env-admin only
180
+ assert client.post(f"/api/admin/users/{phd_id}/role",
181
+ json={"role": "maintainer"}).status_code == 403
182
+ assert client.post(f"/api/admin/users/{boss_id}/role",
183
+ json={"role": "external"}).status_code == 403
184
+ assert client.post(f"/api/admin/users/{boss_id}/reset-password").status_code == 403
185
+ assert client.delete(f"/api/admin/users/{boss_id}").status_code == 403
186
+ client.post("/api/auth/logout")
187
+
188
+ _as_admin(client) # env admin CAN demote a staff account
189
+ assert client.post(f"/api/admin/users/{boss_id}/role",
190
+ json={"role": "lab"}).status_code == 200
191
+
192
+
193
  # --------------------------------------------------------- reset + delete
194
  def test_password_reset_issues_working_temp_password(client):
195
  register(client, "forgetful@lab.test")
 
292
 
293
 
294
  # ------------------------------------------------------------ verification
295
+ def _make_role(client, email, role):
296
+ """Register (if needed) and set a role, as the env admin; leaves the
297
+ client signed in as that user."""
298
+ sign_in_as(client, email)
299
+ client.post("/api/auth/logout")
300
+ _as_admin(client)
301
+ resp = client.post(f"/api/admin/users/{_user_id(client, email)}/role", json={"role": role})
302
+ assert resp.status_code == 200, resp.json()
303
+ client.post("/api/auth/logout")
304
+ client.post("/api/auth/login", json={"email": email, "password": "password123"})
305
+
306
+
307
+ def test_verification_is_the_maintainers_job(client):
308
+ """PI decision 2026-07-22: maintainers verify; PI/admin see the queue
309
+ read-only (the trail then names the responsible RA)."""
310
  sign_in_as(client, ADMIN_EMAIL, "Admin")
311
  queue = client.get("/api/admin/constructs?status=needs_verification").json()
312
  assert len(queue) > 0
313
  target = queue[0]
314
 
315
+ # env admin: queue visible, verification action refused
316
+ resp = client.post(
317
+ f"/api/admin/constructs/{target['id']}/verification", json={"status": "verified"}
318
+ )
319
+ assert resp.status_code == 403 and "maintainer" in resp.json()["detail"]
320
+
321
+ _make_role(client, "ra@lab.test", "maintainer")
322
  resp = client.post(
323
  f"/api/admin/constructs/{target['id']}/verification", json={"status": "verified"}
324
  )
 
329
  # visible to regular users too (flag disappears in the picker/details)
330
  pub = next(c for c in client.get("/api/constructs").json() if c["id"] == target["id"])
331
  assert pub["verification_status"] == "verified"
332
+
333
+
334
+ # ------------------------------------- pre-login access + audit (PI email 2026-07-22)
335
+ def test_preassigned_role_lands_on_first_signin(client):
336
+ """The Dr. Chen scenario: full credentials bound to an email before the
337
+ account exists; first sign-in (password or Google) claims them."""
338
+ _as_admin(client)
339
+ resp = client.post(
340
+ "/api/admin/role-assignments",
341
+ json={"email": "collaborator@other-lab.edu", "role": "maintainer"},
342
+ )
343
+ assert resp.status_code == 201
344
+ pending = client.get("/api/admin/role-assignments").json()
345
+ entry = next(a for a in pending if a["email"] == "collaborator@other-lab.edu")
346
+ assert entry["role"] == "maintainer" and entry["claimed_at"] is None
347
+ client.post("/api/auth/logout")
348
+
349
+ register(client, "collaborator@other-lab.edu", "Dr. Chen")
350
+ me = client.get("/api/auth/me").json()
351
+ assert me["role"] == "maintainer" and me["is_admin"] is True
352
+ assert me["usage"]["max_saved_runs"] is None
353
+ client.post("/api/auth/logout")
354
+
355
+ _as_admin(client)
356
+ claimed = next(
357
+ a for a in client.get("/api/admin/role-assignments").json()
358
+ if a["email"] == "collaborator@other-lab.edu"
359
+ )
360
+ assert claimed["claimed_at"] is not None
361
+ # existing accounts are managed in the Users table, not via pre-assignment
362
+ resp = client.post(
363
+ "/api/admin/role-assignments",
364
+ json={"email": "collaborator@other-lab.edu", "role": "lab"},
365
+ )
366
+ assert resp.status_code == 409
367
+
368
+
369
+ def test_maintainer_cannot_preassign_staff(client):
370
+ _make_role(client, "mnt3@lab.test", "maintainer")
371
+ assert client.post(
372
+ "/api/admin/role-assignments",
373
+ json={"email": "someone@new.edu", "role": "pi"},
374
+ ).status_code == 403
375
+ assert client.post(
376
+ "/api/admin/role-assignments",
377
+ json={"email": "someone@new.edu", "role": "lab"},
378
+ ).status_code == 201
379
+
380
+
381
+ def test_invite_link_grants_role_on_register(client, monkeypatch):
382
+ _as_admin(client)
383
+ invite = client.post("/api/admin/invites", json={"role": "lab"}).json()
384
+ assert invite["role"] == "lab" and invite["token"]
385
+ # staff can never be invited by bearer link
386
+ assert client.post("/api/admin/invites", json={"role": "maintainer"}).status_code == 400
387
+ client.post("/api/auth/logout")
388
+
389
+ resp = client.post("/api/auth/register", json={
390
+ "email": "invited@lab.test", "password": "password123",
391
+ "name": "Invited", "invite_token": invite["token"],
392
+ })
393
+ assert resp.status_code == 201
394
+ me = client.get("/api/auth/me").json()
395
+ assert me["role"] == "lab" and me["usage"]["max_saved_runs"] is None
396
+ client.post("/api/auth/logout")
397
+
398
+ # dead/garbage tokens refuse registration instead of silently demoting
399
+ resp = client.post("/api/auth/register", json={
400
+ "email": "invited2@lab.test", "password": "password123",
401
+ "name": "Invited2", "invite_token": "garbage.token",
402
+ })
403
+ assert resp.status_code == 400 and "invite" in resp.json()["detail"].lower()
404
+
405
+ monkeypatch.setenv("CCR_INVITE_TTL_DAYS", "-1") # mint an already-expired invite
406
+ _as_admin(client)
407
+ expired = client.post("/api/admin/invites", json={"role": "lab"}).json()
408
+ client.post("/api/auth/logout")
409
+ resp = client.post("/api/auth/register", json={
410
+ "email": "invited3@lab.test", "password": "password123",
411
+ "name": "Invited3", "invite_token": expired["token"],
412
+ })
413
+ assert resp.status_code == 400
414
+
415
+
416
+ def test_audit_trail_records_and_is_pi_only(client):
417
+ _as_admin(client)
418
+ audit = client.get("/api/admin/audit")
419
+ assert audit.status_code == 200
420
+ actions = {(a["action"], a["target"]) for a in audit.json()}
421
+ assert ("role_preassigned", "collaborator@other-lab.edu") in actions
422
+ assert ("role_claimed", "collaborator@other-lab.edu") in actions
423
+ assert ("invite_redeemed", "invited@lab.test") in actions
424
+ assert any(a[0] == "set_verification" for a in actions)
425
+ client.post("/api/auth/logout")
426
+
427
+ # maintainers work the cards but don't get top-down oversight
428
+ client.post("/api/auth/login", json={"email": "mnt3@lab.test", "password": "password123"})
429
+ assert client.get("/api/admin/audit").status_code == 403
430
+
431
+
432
+ def test_pi_role_holds_escalation_rights_and_no_self_change(client):
433
+ _make_role(client, "pi2@lab.test", "pi")
434
+ me = client.get("/api/auth/me").json()
435
+ assert me["role"] == "pi" and me["is_admin"] is True
436
+
437
+ sign_in_as(client, "newbie@lab.test")
438
+ client.post("/api/auth/logout")
439
+ client.post("/api/auth/login", json={"email": "pi2@lab.test", "password": "password123"})
440
+ newbie_id = _user_id(client, "newbie@lab.test")
441
+ # a PI-by-role can mint staff and see the audit trail - no env entry needed
442
+ assert client.post(f"/api/admin/users/{newbie_id}/role",
443
+ json={"role": "maintainer"}).status_code == 200
444
+ assert client.get("/api/admin/audit").status_code == 200
445
+ # but nobody, PI included, can change their own role
446
+ my_id = _user_id(client, "pi2@lab.test")
447
+ resp = client.post(f"/api/admin/users/{my_id}/role", json={"role": "external"})
448
+ assert resp.status_code == 400 and "own role" in resp.json()["detail"]
backend/tests/test_api.py CHANGED
@@ -78,6 +78,13 @@ def test_health(client):
78
  assert client.get("/api/health").json()["status"] == "ok"
79
 
80
 
 
 
 
 
 
 
 
81
  def test_seed_constructs_present(client):
82
  names = {c["name"] for c in client.get("/api/constructs").json()}
83
  assert "Satisfaction with Life" in names
 
78
  assert client.get("/api/health").json()["status"] == "ok"
79
 
80
 
81
+ def test_guide_and_product_pages_served(client):
82
+ for path in ("/guide", "/product"):
83
+ resp = client.get(path)
84
+ assert resp.status_code == 200, path
85
+ assert "text/html" in resp.headers["content-type"]
86
+
87
+
88
  def test_seed_constructs_present(client):
89
  names = {c["name"] for c in client.get("/api/constructs").json()}
90
  assert "Satisfaction with Life" in names
frontend/src/AdminPage.jsx CHANGED
@@ -2,8 +2,18 @@ import { useCallback, useEffect, useState } from "react";
2
 
3
  // Minimal admin surface (v1): overview counters, user roles + password
4
  // resets, failed-run requeue, and the RA's construct-verification queue.
5
- // Access is enforced server-side (ADMIN_EMAILS); this page just renders
6
- // what the admin API returns.
 
 
 
 
 
 
 
 
 
 
7
 
8
  async function adminFetch(path, options = {}) {
9
  const resp = await fetch(path, options);
@@ -29,15 +39,26 @@ export default function AdminPage({ auth }) {
29
  const [users, setUsers] = useState([]);
30
  const [failed, setFailed] = useState([]);
31
  const [constructs, setConstructs] = useState([]);
 
 
32
  const [onlyUnverified, setOnlyUnverified] = useState(true);
 
 
 
33
  const [error, setError] = useState("");
34
  const [notice, setNotice] = useState("");
35
 
 
 
 
 
36
  const reload = useCallback(() => {
37
  setError("");
38
  adminFetch("/api/admin/overview").then(setOverview).catch((e) => setError(e.message));
39
  adminFetch("/api/admin/users").then(setUsers).catch(() => {});
40
  adminFetch("/api/admin/jobs/failed").then(setFailed).catch(() => {});
 
 
41
  adminFetch(
42
  "/api/admin/constructs" + (onlyUnverified ? "?status=needs_verification" : "")
43
  ).then(setConstructs).catch(() => {});
@@ -87,8 +108,11 @@ export default function AdminPage({ auth }) {
87
  <h3>Overview</h3>
88
  {overview ? (
89
  <p className="hint">
90
- <b>{overview.users}</b> accounts ({overview.lab_users} lab tier,{" "}
91
- {overview.signups_last_7_days} new this week) · <b>{overview.runs_total}</b>{" "}
 
 
 
92
  runs total ({overview.runs_last_7_days} this week
93
  {overview.runs_by_status?.failed ? `, ${overview.runs_by_status.failed} failed` : ""}) ·{" "}
94
  <b>{overview.projects}</b> projects ({overview.anonymous_projects} anonymous) ·{" "}
@@ -112,20 +136,21 @@ export default function AdminPage({ auth }) {
112
  <tr key={u.id}>
113
  <td>{u.email}{u.is_admin ? " ★" : ""}</td>
114
  <td>{u.name}</td>
115
- <td><span className={`pill ${u.role === "lab" ? "completed" : "queued"}`}>{u.role}</span></td>
116
- <td>{u.saved_runs}</td>
117
- <td className="muted small">{u.google_only ? "Google" : "password"}</td>
118
  <td>
119
- <button
120
- className="linkish"
121
- onClick={() =>
122
- act(() => post(`/api/admin/users/${u.id}/role`, {
123
- role: u.role === "lab" ? "member" : "lab",
124
- }))
125
  }
126
  >
127
- {u.role === "lab" ? "Make member" : "Make lab (unlimited)"}
128
- </button>{" "}
 
 
 
 
 
 
129
  {!u.google_only && (
130
  <button
131
  className="linkish"
@@ -139,7 +164,7 @@ export default function AdminPage({ auth }) {
139
  Reset password
140
  </button>
141
  )}{" "}
142
- {!u.is_admin && (
143
  <button
144
  className="linkish danger"
145
  onClick={() => {
@@ -162,6 +187,128 @@ export default function AdminPage({ auth }) {
162
  </div>
163
  </div>
164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  {/* Failed runs */}
166
  <div className="card">
167
  <h3>Failed runs</h3>
@@ -204,10 +351,11 @@ export default function AdminPage({ auth }) {
204
  <div className="card">
205
  <h3>Construct verification</h3>
206
  <p className="hint">
207
- For the RA workflow: mark a scale verified once its wording is checked
208
- against the original publication (cross-reference the verification
209
- checklist spreadsheet). Statuses set here are applied back to the
210
- library files before production.
 
211
  {" "}
212
  <button className="linkish" onClick={() => setOnlyUnverified((v) => !v)}>
213
  {onlyUnverified ? "Show all" : "Show unverified only"}
@@ -230,18 +378,20 @@ export default function AdminPage({ auth }) {
230
  </span>
231
  </td>
232
  <td>
233
- <button
234
- className="linkish"
235
- onClick={() =>
236
- act(() => post(`/api/admin/constructs/${c.id}/verification`, {
237
- status: c.verification_status === "verified"
238
- ? "needs_verification"
239
- : "verified",
240
- }))
241
- }
242
- >
243
- {c.verification_status === "verified" ? "Un-verify" : "Mark verified"}
244
- </button>
 
 
245
  </td>
246
  </tr>
247
  ))}
 
2
 
3
  // Minimal admin surface (v1): overview counters, user roles + password
4
  // resets, failed-run requeue, and the RA's construct-verification queue.
5
+ // Access is enforced server-side (ADMIN_EMAILS allowlist or pi/maintainer
6
+ // role); this page just renders what the admin API returns. Actions a
7
+ // maintainer isn't allowed to take (granting staff roles, touching staff
8
+ // accounts) are rejected by the server and surface in the error banner.
9
+
10
+ const ROLES = ["external", "lab", "maintainer", "pi"];
11
+ const ROLE_LABELS = {
12
+ external: "external user",
13
+ lab: "lab member",
14
+ maintainer: "maintainer",
15
+ pi: "PI",
16
+ };
17
 
18
  async function adminFetch(path, options = {}) {
19
  const resp = await fetch(path, options);
 
39
  const [users, setUsers] = useState([]);
40
  const [failed, setFailed] = useState([]);
41
  const [constructs, setConstructs] = useState([]);
42
+ const [assignments, setAssignments] = useState([]);
43
+ const [audit, setAudit] = useState(null); // null = not visible (maintainers)
44
  const [onlyUnverified, setOnlyUnverified] = useState(true);
45
+ const [inviteRole, setInviteRole] = useState("lab");
46
+ const [assignEmail, setAssignEmail] = useState("");
47
+ const [assignRole, setAssignRole] = useState("lab");
48
  const [error, setError] = useState("");
49
  const [notice, setNotice] = useState("");
50
 
51
+ // Only maintainers can flip verification statuses (the RA workflow);
52
+ // PI/admin see the queue read-only. Enforced server-side too.
53
+ const canVerify = auth?.role === "maintainer";
54
+
55
  const reload = useCallback(() => {
56
  setError("");
57
  adminFetch("/api/admin/overview").then(setOverview).catch((e) => setError(e.message));
58
  adminFetch("/api/admin/users").then(setUsers).catch(() => {});
59
  adminFetch("/api/admin/jobs/failed").then(setFailed).catch(() => {});
60
+ adminFetch("/api/admin/role-assignments").then(setAssignments).catch(() => {});
61
+ adminFetch("/api/admin/audit").then(setAudit).catch(() => setAudit(null)); // 403 for maintainers
62
  adminFetch(
63
  "/api/admin/constructs" + (onlyUnverified ? "?status=needs_verification" : "")
64
  ).then(setConstructs).catch(() => {});
 
108
  <h3>Overview</h3>
109
  {overview ? (
110
  <p className="hint">
111
+ <b>{overview.users}</b> accounts (
112
+ {ROLES.filter((r) => overview.users_by_role?.[r])
113
+ .map((r) => `${overview.users_by_role[r]} ${ROLE_LABELS[r]}`)
114
+ .join(", ") || "none"}
115
+ ; {overview.signups_last_7_days} new this week) · <b>{overview.runs_total}</b>{" "}
116
  runs total ({overview.runs_last_7_days} this week
117
  {overview.runs_by_status?.failed ? `, ${overview.runs_by_status.failed} failed` : ""}) ·{" "}
118
  <b>{overview.projects}</b> projects ({overview.anonymous_projects} anonymous) ·{" "}
 
136
  <tr key={u.id}>
137
  <td>{u.email}{u.is_admin ? " ★" : ""}</td>
138
  <td>{u.name}</td>
 
 
 
139
  <td>
140
+ <select
141
+ value={u.role}
142
+ onChange={(e) =>
143
+ act(() => post(`/api/admin/users/${u.id}/role`, { role: e.target.value }))
 
 
144
  }
145
  >
146
+ {ROLES.map((r) => (
147
+ <option key={r} value={r}>{ROLE_LABELS[r]}</option>
148
+ ))}
149
+ </select>
150
+ </td>
151
+ <td>{u.saved_runs}</td>
152
+ <td className="muted small">{u.google_only ? "Google" : "password"}</td>
153
+ <td>
154
  {!u.google_only && (
155
  <button
156
  className="linkish"
 
164
  Reset password
165
  </button>
166
  )}{" "}
167
+ {!u.env_admin && (
168
  <button
169
  className="linkish danger"
170
  onClick={() => {
 
187
  </div>
188
  </div>
189
 
190
+ {/* Access before sign-in: pre-assigned roles + invite links */}
191
+ <div className="card">
192
+ <h3>Access before sign-in</h3>
193
+ <p className="hint">
194
+ <b>Pre-assign a role to an email</b> (e.g. an external collaborator who
195
+ should land with full credentials): whoever first signs in with that
196
+ email - password or Google - gets the role automatically.
197
+ </p>
198
+ <form
199
+ onSubmit={(e) => {
200
+ e.preventDefault();
201
+ if (!assignEmail.trim()) return;
202
+ act(async () => {
203
+ await post("/api/admin/role-assignments", {
204
+ email: assignEmail.trim(), role: assignRole,
205
+ });
206
+ setAssignEmail("");
207
+ });
208
+ }}
209
+ style={{ display: "flex", gap: ".5rem", flexWrap: "wrap", alignItems: "center" }}
210
+ >
211
+ <input
212
+ type="email"
213
+ placeholder="person@university.edu"
214
+ value={assignEmail}
215
+ onChange={(e) => setAssignEmail(e.target.value)}
216
+ style={{ minWidth: "16rem" }}
217
+ />
218
+ <select value={assignRole} onChange={(e) => setAssignRole(e.target.value)}>
219
+ {ROLES.map((r) => (
220
+ <option key={r} value={r}>{ROLE_LABELS[r]}</option>
221
+ ))}
222
+ </select>
223
+ <button type="submit" disabled={!assignEmail.trim()}>Pre-assign</button>
224
+ </form>
225
+ {assignments.length > 0 && (
226
+ <div className="table-wrap">
227
+ <table className="docs">
228
+ <thead>
229
+ <tr><th>Email</th><th>Role</th><th>By</th><th>Status</th><th /></tr>
230
+ </thead>
231
+ <tbody>
232
+ {assignments.map((a) => (
233
+ <tr key={a.id}>
234
+ <td>{a.email}</td>
235
+ <td>{ROLE_LABELS[a.role] || a.role}</td>
236
+ <td className="muted small">{a.assigned_by}</td>
237
+ <td className="muted small">
238
+ {a.claimed_at ? `claimed ${a.claimed_at.slice(0, 10)}` : "pending"}
239
+ </td>
240
+ <td>
241
+ {!a.claimed_at && (
242
+ <button
243
+ className="linkish danger"
244
+ onClick={() =>
245
+ act(() => adminFetch(`/api/admin/role-assignments/${a.id}`, { method: "DELETE" }))
246
+ }
247
+ >
248
+ Remove
249
+ </button>
250
+ )}
251
+ </td>
252
+ </tr>
253
+ ))}
254
+ </tbody>
255
+ </table>
256
+ </div>
257
+ )}
258
+ <p className="hint" style={{ marginTop: "1rem" }}>
259
+ <b>Or create an invite link</b> (anyone with the link; lab member /
260
+ external only - staff is granted per person above): paste it in Slack,
261
+ it expires after a week.
262
+ </p>
263
+ <div style={{ display: "flex", gap: ".5rem", alignItems: "center" }}>
264
+ <select value={inviteRole} onChange={(e) => setInviteRole(e.target.value)}>
265
+ <option value="lab">lab member</option>
266
+ <option value="external">external user</option>
267
+ </select>
268
+ <button
269
+ onClick={() =>
270
+ act(async () => {
271
+ const r = await post("/api/admin/invites", { role: inviteRole });
272
+ const url = `${window.location.origin}/?invite=${encodeURIComponent(r.token)}`;
273
+ try { await navigator.clipboard.writeText(url); } catch { /* show below */ }
274
+ setNotice(`Invite link (${ROLE_LABELS[r.role]}, expires ${r.expires_at}) - copied: ${url}`);
275
+ })
276
+ }
277
+ >
278
+ Create invite link
279
+ </button>
280
+ </div>
281
+ </div>
282
+
283
+ {/* Audit trail - PI/env-admin only (404s/403s hide it for maintainers) */}
284
+ {audit !== null && (
285
+ <div className="card">
286
+ <h3>Audit trail</h3>
287
+ {audit.length === 0 ? (
288
+ <p className="hint">No admin actions recorded yet.</p>
289
+ ) : (
290
+ <div className="table-wrap">
291
+ <table className="docs">
292
+ <thead>
293
+ <tr><th>When</th><th>Who</th><th>Action</th><th>Target</th><th>Detail</th></tr>
294
+ </thead>
295
+ <tbody>
296
+ {audit.map((a, i) => (
297
+ <tr key={i}>
298
+ <td className="muted small">{a.at.replace("T", " ").slice(0, 16)}</td>
299
+ <td className="small">{a.actor}</td>
300
+ <td className="small">{a.action.replaceAll("_", " ")}</td>
301
+ <td className="small">{a.target}</td>
302
+ <td className="muted small">{a.detail}</td>
303
+ </tr>
304
+ ))}
305
+ </tbody>
306
+ </table>
307
+ </div>
308
+ )}
309
+ </div>
310
+ )}
311
+
312
  {/* Failed runs */}
313
  <div className="card">
314
  <h3>Failed runs</h3>
 
351
  <div className="card">
352
  <h3>Construct verification</h3>
353
  <p className="hint">
354
+ The maintainer's workflow: mark a scale verified once its wording is
355
+ checked against the original publication (cross-reference the
356
+ verification checklist spreadsheet). Statuses set here are applied
357
+ back to the library files before production.
358
+ {!canVerify && " Your account has read access; verification actions are for maintainers."}
359
  {" "}
360
  <button className="linkish" onClick={() => setOnlyUnverified((v) => !v)}>
361
  {onlyUnverified ? "Show all" : "Show unverified only"}
 
378
  </span>
379
  </td>
380
  <td>
381
+ {canVerify && (
382
+ <button
383
+ className="linkish"
384
+ onClick={() =>
385
+ act(() => post(`/api/admin/constructs/${c.id}/verification`, {
386
+ status: c.verification_status === "verified"
387
+ ? "needs_verification"
388
+ : "verified",
389
+ }))
390
+ }
391
+ >
392
+ {c.verification_status === "verified" ? "Un-verify" : "Mark verified"}
393
+ </button>
394
+ )}
395
  </td>
396
  </tr>
397
  ))}
frontend/src/App.jsx CHANGED
@@ -55,6 +55,19 @@ export default function App() {
55
  const [authName, setAuthName] = useState("");
56
  const [authError, setAuthError] = useState("");
57
  const [authBusy, setAuthBusy] = useState(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
  const loadProjects = () =>
60
  api.listProjects().then(setProjects).catch((e) => setError(e.message));
@@ -70,6 +83,15 @@ export default function App() {
70
  setError(`Sign-in problem: ${authFail.replaceAll("-", " ")}.`);
71
  window.history.replaceState({}, "", "/");
72
  }
 
 
 
 
 
 
 
 
 
73
  }, []);
74
 
75
  async function handleAuthSubmit(e) {
@@ -78,7 +100,14 @@ export default function App() {
78
  setAuthBusy(true);
79
  try {
80
  if (authMode === "register") {
81
- await api.register({ email: authEmail.trim(), password: authPassword, name: authName.trim() });
 
 
 
 
 
 
 
82
  } else {
83
  await api.login({ email: authEmail.trim(), password: authPassword });
84
  }
@@ -163,6 +192,12 @@ export default function App() {
163
  <div className="modal-backdrop" onClick={() => setShowLogin(false)}>
164
  <div className="modal" onClick={(e) => e.stopPropagation()}>
165
  <h3>{authMode === "register" ? "Create an account" : "Sign in"}</h3>
 
 
 
 
 
 
166
  <p className="hint">
167
  Accounts are free. Signing in lifts the anonymous limits
168
  {auth?.limits?.max_rows
 
55
  const [authName, setAuthName] = useState("");
56
  const [authError, setAuthError] = useState("");
57
  const [authBusy, setAuthBusy] = useState(false);
58
+ const [inviteToken, setInviteToken] = useState("");
59
+
60
+ // Invite links carry their role in the signed payload; decode it for the
61
+ // banner only - the server re-verifies the signature on registration.
62
+ const inviteRole = (() => {
63
+ if (!inviteToken) return null;
64
+ try {
65
+ const data = JSON.parse(atob(inviteToken.split(".")[0].replace(/-/g, "+").replace(/_/g, "/")));
66
+ return { lab: "lab member", external: "external user" }[data.invite] || null;
67
+ } catch {
68
+ return null;
69
+ }
70
+ })();
71
 
72
  const loadProjects = () =>
73
  api.listProjects().then(setProjects).catch((e) => setError(e.message));
 
83
  setError(`Sign-in problem: ${authFail.replaceAll("-", " ")}.`);
84
  window.history.replaceState({}, "", "/");
85
  }
86
+ // Invite link (?invite=TOKEN): open the signup form with the token
87
+ // attached. The URL keeps the token until signup succeeds, so a page
88
+ // refresh doesn't lose the invite.
89
+ const invite = params.get("invite");
90
+ if (invite) {
91
+ setInviteToken(invite);
92
+ setAuthMode("register");
93
+ setShowLogin(true);
94
+ }
95
  }, []);
96
 
97
  async function handleAuthSubmit(e) {
 
100
  setAuthBusy(true);
101
  try {
102
  if (authMode === "register") {
103
+ await api.register({
104
+ email: authEmail.trim(), password: authPassword, name: authName.trim(),
105
+ ...(inviteToken ? { invite_token: inviteToken } : {}),
106
+ });
107
+ if (inviteToken) {
108
+ setInviteToken("");
109
+ window.history.replaceState({}, "", "/"); // invite consumed
110
+ }
111
  } else {
112
  await api.login({ email: authEmail.trim(), password: authPassword });
113
  }
 
192
  <div className="modal-backdrop" onClick={() => setShowLogin(false)}>
193
  <div className="modal" onClick={(e) => e.stopPropagation()}>
194
  <h3>{authMode === "register" ? "Create an account" : "Sign in"}</h3>
195
+ {inviteToken && authMode === "register" && (
196
+ <p className="hint" style={{ fontWeight: 600 }}>
197
+ 🎟 You've been invited{inviteRole ? ` as a ${inviteRole}` : ""} - create
198
+ your account below and the access comes with it.
199
+ </p>
200
+ )}
201
  <p className="hint">
202
  Accounts are free. Signing in lifts the anonymous limits
203
  {auth?.limits?.max_rows