Desde

Viewer guide

Members and tokens

Instance roles, how sign-in and invites work, per-project access, why a denied read looks like a 404, and the dsv_ machine tokens that scripts and the Editor use.

Two separate things decide access in the Viewer: instance membership, which decides who has an account at all and what they're allowed to do, and machine tokens, which are how scripts and the Editor authenticate. This page covers both, plus the per-project access setting that ties them together.

The Viewer is invite-only. Nobody gets an account just by showing up, even with a valid GitHub login. See who gets an account below for the exact rule.

Instance roles#

Every account has exactly one of three roles, and it applies across the whole instance. There are no per-project roles: a project's access list is a plain list of who's allowed in, and what a listed person can actually do comes entirely from their instance role.

  • Viewer: can view and comment on any project they can access. Cannot create a project, and cannot manage members, invites, domain rules, or instance settings.
  • Editor: everything a Viewer can do, plus can create new projects and manage (rename, change access, rebuild, delete) any project they can already read.
  • Admin: everything an Editor can do, plus can read and manage every project on the instance, no matter its access setting. Also manages members, invites, domain rules, and instance settings.

The instance always keeps at least one admin. Demoting or removing the last active admin is refused with 409 There must be at least one admin.

VIEWER_ADMIN_TOKEN still works as before: an unscoped bearer that acts as Admin regardless of what the users table says. See The admin bearer below.

Signing in#

Four ways to sign in. Any combination can be turned on at once, and each is independently optional.

Method Needs What it's for
GitHub App A GitHub App configured Everyday sign-in, same mechanics as before.
Invite link Nothing An admin names one email and one role. Works with no SMTP.
Magic link SMTP configured A returning member types their email and gets a one-time link.
Admin-issued sign-in link Nothing An admin mints a one-time link for an existing member and sends it however they like.

Authenticating through any of these does not by itself create an account. See who gets an account below. That decision is a separate step, and it's the same step for all four methods.

Opening an invite link or a sign-in link shows a confirmation page; the button on it signs you in. Opening the link spends nothing, so a chat unfurl or a mail security scanner following the URL can't burn a one-time link before the person clicks it. A very old browser (Safari before 16.4) can't complete this step. It doesn't send the headers the server checks, so it gets an error page instead of a working sign-in.

From /settings → Members (admin only), an admin names one email address and picks a role, and the server creates an invite. It always returns a copyable URL, and if SMTP is configured it also emails it. The link expires in 7 days. An admin can regenerate it, which mints a fresh link and kills the old one, or revoke it outright.

With no SMTP configured, the link itself is the credential. Whoever clicks it becomes that email's user. That's the same trust model as a Slack invite link: treat the URL as sensitive until someone has used it, and send it only to the person it names.

Needs SMTP. A returning member types their email at /signin and gets a 15-minute one-time sign-in link.

It also doubles as self-serve join: someone whose email domain matches a domain rule, but who has no account yet, can request one too. Completing it creates their account at the rule's role. Requests are rate-limited, and the response is identical whether or not the address has an account, so the endpoint can't be used to check who's a member.

The zero-SMTP way to re-authenticate someone who already has an account. From the Members page, an admin mints a one-time link and sends it however they like: Slack, a text message, anything. It expires in 24 hours, longer than the self-serve magic link's 15 minutes, because it's typically handed to someone who won't look at it right away. Refused if the member has been removed.

Who gets an account#

One function, admitSignIn, runs after every successful authentication and decides whether an account gets created. In order:

  1. An existing active account (found by GitHub identity, or by email) signs in. A removed account is refused here, and nowhere else.
  2. A valid, unexpired, unused invite for this exact email creates the account at the invite's role.
  3. The local operator handing off to GitHub. The person who used the boot-printed one-time URL, now signing in with GitHub from the same browser, creates their new account as Admin.
  4. A brand-new instance with zero users creates the account as Admin. This is what makes a fresh GitHub-configured instance work with no local-operator step at all: the first person to sign in becomes Admin.
  5. A matching domain rule creates the account at the rule's role.
  6. Otherwise, refused. Nothing is created.

Rungs 3 and 4, the two ways to become Admin, are checked before rung 5 on purpose, so a brand-new instance always ends up with an admin, even if VIEWER_ALLOWED_EMAIL_DOMAINS was set from the very first boot.

The boot-printed local sign-in URL is a separate path. It does not go through admitSignIn at all. Opening it calls a different function, signInLocalOperator. That function signs you in as the local-operator account unconditionally, and that account is Admin by definition. The only check it makes is whether the local-operator account has been removed; if it has, the boot URL stops working and there is no third way back in. Rung 3 above is a different moment: it only fires if that same browser goes on to sign in with GitHub afterward.

Someone refused by the gate lands on a plain page: "This viewer is invite-only. Ask an admin to invite you." It gives no hint about who else has access, or whether the instance exists at all.

Domain rules#

A domain rule says: anyone with an address at this domain gets an account, at this role, the moment they sign in. An admin manages the list from /settings → Domain rules: add a domain and its default role, or remove one.

VIEWER_ALLOWED_EMAIL_DOMAINS seeds this list once, and does nothing after that. The gate is not "the table is still empty". It is a permanent marker in the instance settings, domainRulesSeededFromEnv. The first boot that hasn't set that marker yet checks the variable: if it's set, and the stored domain-rule table is still empty, each domain-only entry (no @) is copied in as a rule with role viewer. The marker is then set for good. That is what stops the rules from silently reappearing if an admin later deletes them all and restarts the server: once the marker exists, an empty table alone does not re-trigger the seed. An entry that's a full address (it contains @) can't become a domain rule; it's skipped, and the boot log names it and says to invite that address instead. Manage domains from Settings from then on.

Project access#

Every project has one of three access settings, set at creation and changeable later with PATCH /api/v1/projects/{id}:

  • all-members: every active member of the instance, of any role, can open it. The default for a new project.
  • invited: only the people on that project's access list, plus admins.
  • public-link: anonymous, no sign-in, same as before.

The full read rule, in order:

  1. public-link is readable by anyone, signed in or not, as long as the instance-wide allowPublicLinks setting is on.
  2. An admin, or the admin bearer, can read anything.
  3. A signed-in member can read an all-members project.
  4. If allowPublicLinks is off, a signed-in member can still read a public-link project. It behaves exactly like all-members until the setting is switched back on.
  5. A signed-in member on the access list can read an invited project.
  6. Otherwise, no.

There's no zero-members trap any more. An invited project with an empty access list is readable by nobody but admins. It's never world-readable by accident, the way an old members project with no members used to be. That migration-era rule was deleted along with visibility itself.

allowPublicLinks is an instance-wide switch, in Settings, on by default. Turn it off and every public-link project immediately requires sign-in, the same as an all-members project. Nothing about the project itself changes. Turn it back on and they're public again.

Managing a project's access list#

Managed from /review/{slug}Members, or with the API. Anyone who can manage the project (an admin, or an editor who can already read it) can add or remove people; anyone who can read the project can see the list.

Add someone by email:

curl -X POST http://localhost:3100/api/v1/projects/PROJECT_ID/members \
  -H "Authorization: Bearer dsv_your_token_here" \
  -H "Content-Type: application/json" \
  -d '{"email":"jane@acme.com"}'

There's no role on this list. A row here is a plain access-list entry: it decides whether someone can read an invited project, and nothing else. What someone can do comes entirely from their instance role, described above.

They need an account already#

Warning Adding someone to a project resolves their email to an existing, active account. If nobody has signed in (or been invited) with that address, the request fails with 404 and the message "That email doesn't belong to a member of this viewer yet. Invite them from Settings first."

So getting a new person onto an invited project is two steps: invite them to the instance first (see Invite link above), then add them to the project once they've signed in.

Emails are scoped#

The member list is readable by anyone who can read the project, including an anonymous visitor on a public-link project. Each row's email is only included for a caller who can manage the project, or who is themselves on the list. Everyone else gets the display name and avatar, with no email field at all.

Removing someone#

There is no last-member guard, for any role, on any project. Anyone with manage authority over the project can remove the last person on an invited project's access list, emptying it completely. That includes an Editor emptying their own access, which can lock them out of a project they can no longer read.

This is a deliberate product choice, not an oversight: people can lock themselves out if they choose to, and recover by asking an Admin to add them back. Nothing else is at risk when this happens. Removal never touches the project's access setting, so an emptied invited roster does not become readable to anyone else. It becomes readable to Admins only, and any Admin can restore access in one step.

There was a guard like this until 2026-08-29, refusing with 400 unless the caller was an admin. It was removed for the reason above: it existed only to protect against a mistake the person making it is entitled to make.

Why a denied read is a 404#

When you request a project you're not allowed to read, the Viewer answers 404 Project not found, the exact same response as a project id that doesn't exist. Never 403.

This is on purpose. A 403 would confirm that a project with that id exists, which is information the caller wasn't entitled to. The two cases are byte-identical from outside, so the response can't be used to enumerate projects.

The same rule applies to the prototype itself: /p/{slug}/ on an unreadable project answers a plain "Prototype not found", identical to an unknown slug.

A 403 does still appear, but only once readability has already been cleared: you can read the project, you simply can't manage it. At that point you already knew the project existed, so the 403 gives nothing away. See Who can manage a project for that rule.

The instance admin API#

Every route under /api/v1/instance/** is admin-only: the admin bearer, or a signed-in admin-role account (a PAT additionally needs the write scope for anything that mutates). The UI at /settings hides these sections from non-admins, but that's a courtesy: the server enforces the same rule on every route independently, whatever the client sends.

Area Routes
Members GET /instance/members · PATCH /instance/members/:userId (change role) · DELETE /instance/members/:userId (remove) · POST /instance/members/:userId/restore · POST /instance/members/:userId/signin-link
Invites POST /instance/invites · GET /instance/invites · POST /instance/invites/:id/regenerate · DELETE /instance/invites/:id
Domain rules GET /instance/domain-rules · PUT /instance/domain-rules/:domain · DELETE /instance/domain-rules/:domain
Settings GET /instance/settings · PATCH /instance/settings (the allowPublicLinks and allowAnonymousComments toggles)
Email PUT /instance/email (set the mail server) · DELETE /instance/email (turn it off and forget the password). Both refuse with 409 if VIEWER_SMTP_HOST is set in the environment, since the environment always wins.

This is an overview, not a full route-by-route reference. See the API reference for exact request and response shapes.

Machine tokens (dsv_)#

A machine token is a personal access token for anything that isn't a browser: a script, a CI job, or the Editor syncing comments into a Viewer project. It authenticates as you: it's not a separate account, and it never grants more authority than your account already has.

A token inherits its owner's role and status live, on every request. There's no snapshot taken at mint time. Demote or remove someone and their tokens narrow or stop working immediately, not at their next mint.

Minting one#

Go to /settings while signed in. The form has three parts:

  • Name: 1 to 64 characters. For you, not the server. Something like editor-macbook.
  • Scopes: two checkboxes, read and write.
  • Expiry: one year unless you set it. Ticking the box lets you choose 1 to 365 days; leaving it unticked mints a token that expires after 365 days. No token from this form lives forever.

Warning Write is an unchecked box. The form arrives with read ticked and write clear, so the default token is read-only. A read-only token can list projects and read comments, and it fails the first time anything tries to write: creating a project, posting a comment, syncing from the Editor. The failure is a 403 reading "This action requires a write-scoped token", and it arrives at the worst moment, well after minting.

If the token is for the Editor, or for creating or managing projects, tick write.

The token looks like dsv_a1b2c3d4e5f6a7b8_<secret> and is shown exactly once, in a banner with a Copy button. It's never stored anywhere you can read it back. The server keeps only a hash. Lose it and you mint a new one.

You can hold 50 live tokens at a time. Expired ones don't count toward the cap; revoking frees a slot.

Using one#

Send it as a bearer token:

curl http://localhost:3100/api/v1/projects \
  -H "Authorization: Bearer dsv_your_token_here"

An invalid or revoked token is a hard 401. It never falls back to your session cookie, even if the same browser is signed in. A machine credential that "works" because a cookie happened to ride along is the most confusing way a credential can fail.

Two things a write-scoped token does not do:

  • It doesn't grant authority beyond what your account already has. The routes that require managing a project (PATCH /projects/{id}, deployment upload, build trigger, repo connect/disconnect, project access-list management) still require your account to hold Editor or Admin authority and be able to read the project, exactly as a browser session would. write scope only proves the credential may write; it doesn't upgrade a Viewer-role account into one that can manage anything. Comment and participant writes are the exception, deliberately: those need only read access plus the write scope, which is what lets an Editor sync comments into a project it doesn't otherwise manage.
  • It doesn't reach projects you can't read. Token access resolves to your user and then goes through the same access rule as your browser session.

Revoking#

Each row at /settings shows the token's name, its non-secret id prefix, its scopes, when it was created, when it was last used, and when it expires. Revoke takes effect immediately and can't be undone.

Note Token management is session-only. A machine token can't mint, list or revoke tokens, including itself, and neither can the admin bearer. If it could, a leaked token would be self-renewing: mint a fresh one before anyone notices the original. /settings needs a signed-in browser.

The admin bearer#

VIEWER_ADMIN_TOKEN is a deployment-level operator credential. It reaches every project regardless of its access setting, it's not scoped, it's not revocable without restarting the process, and it doesn't appear in any list.

Treat it like the GitHub App private key it sits next to in the environment. Don't hand it to reviewers, and don't use it for routine work.

It's optional. With it unset, the server logs a warning at boot and everything still works: an Editor or Admin's own session, or their write-scoped personal access token, does everything the admin bearer would.

Next#

  • Projects: creating a project, and who can manage it once it exists.
  • Reviewing: participants, which are a different list from members.
  • Viewer configuration: VIEWER_ALLOWED_EMAIL_DOMAINS and the rest of the sign-in settings.