Skip to content

identity: station-only users (amendment 1) - #175

Merged
isaacvarg merged 10 commits into
mainfrom
identity/station-only-users
Nov 10, 2026
Merged

isaacvarg merged 10 commits into
mainfrom
identity/station-only-users

Conversation

@isaacvarg

Copy link
Copy Markdown
Owner

Implements SPEC-identity amendment 1, station-only users: everything marked (A1), success criteria 16–22, decisions 9–15.

An admin can add a worker who has no email: a name, roles (never Admin) and a PIN. That worker unlocks stations only and can never sign in personally. Later an admin can add an email, which turns them into a regular user with the same id, roles, PIN, badge and history.

Tickets

Notes

  • No schema change, migration, env var or dependency. A station-only user holds a reserved <uuid>@station.invalid placeholder, known only to packages/core/src/identity/station-only.ts. APIs return email: null and stationOnly: true instead.
  • An added email is stored with emailVerified: true (decision 15). Better Auth 1.7.8's magic-link verify returns 500 for an existing unverified user, because its lock row gets a non-uuid id. As a result, Google and Discord accounts that report the address as verified link right away. The spec records how they behave.
  • SPEC.md status table: identity A1 is done.

Verification

  • Local:
    • core: 3155 passed, 21 skipped
    • contracts: 132 passed
    • i18n: 3 passed
    • lint and tsc pass
  • E2Es run locally: users, station-users, station-user-email, sign-in and stations, on desktop and tablet, against the dev server. All pass; sign-in.spec.ts:23 failed once under parallel load and passed on rerun.
  • Not run locally: next build, which fails on a local SWC cache error. CI is its first run.

Follow-ups (not in this PR)

  • Same-key concurrent requests in withIdempotency: the second createStationUser gets a raw P2002 (a 500), and the second addEmail gets notStationOnly instead of the replay. This affects other modules too, so it's a project-wide fix.
  • Provider linking before the first magic link: consider refusing a Google or Discord link before the user's first session, and auditing and showing linked providers.

Closes #170

Admins can create users with a name, roles and PIN but no email. They can
only unlock stations, can't be Admin, and become regular users once an
admin adds an email. Tracked in #170.
Amendment 1: a worker with no email gets a name, roles (never Admin) and a
PIN. They hold a reserved <uuid>@station.invalid placeholder, known only to
isStationOnly in core; every API returns email: null + stationOnly instead.

- core: createStationUser (idempotent, audited user.create without the PIN),
  updateUserRoles refuses Admin for them, listUsers search skips placeholders
- contracts: CreateStationUserInput, users.createStationUser (POST /users),
  UserSummary/Me email nullable + stationOnly
- web: Add station user dialog (shared PinField with Generate, then Issue
  badge), "Station only" chip, Edit roles without Admin; strings en + es
- decisions.md: placeholder email over a nullable column, no Admin
- tablet E2E: add, register station, unlock with the PIN, pick attributed

Refs #171
The Add station user dialog looks the name up with users.list (debounced) and
warns when an active user already has it, ignoring case and surrounding spaces.
It never blocks: two people can share a name (SPEC-identity decision 13).

Refs #173
sendMagicLink and mayCreateAccount refuse the station-only placeholder
(even in ADMIN_EMAILS), session.create.before goes through the new core
mayStartSession, and createInvitation rejects the reserved domain with
errors.invitations.reservedEmail.

Refs #172
…ular user

addUserEmail (users.addEmail, PUT /users/{id}/email) replaces a station-only
user's placeholder, audits user.email.add and queues the new accountEnabled
email in the same transaction. The user keeps their id, roles, PIN and badge.
Add email in the row menu asks for the address twice (en + es).

The address is stored verified (spec decision 15): Better Auth 1.7.8's
magic-link verify returns 500 for an existing unverified user, because its
lock row in verifications gets a non-uuid id with generateId: false. The spec
now records how Google and Discord link for this user.

Refs #174
Review of amendment 1 (#171-#174):

- Clear PIN on a station-only user with no badge, and Revoke badge on one
  with no PIN, now ask first (SPEC-identity: "the PIN dialog warns about it
  but allows it"). One LastWayInDialog covers both; strings en + es.
- publicEmail(email) in core's station-only.ts is the one rule that hides
  the placeholder; toUserSummary, me.get and the app layout use it.
- core exports isStationOnly, publicEmail and stationOnlyPlaceholder only;
  stationOnlyEmailFilter stays internal.

Refs #170
From the /ship review of amendment 1 (code, security and test reviewers):

- addUserEmail refuses a deactivated station-only user (CONFLICT,
  errors.users.addEmailInactive): their sign-in email would lead nowhere,
  and reactivating doesn't resend it. The row menu hides Add email for them.
- resolveActor also refuses a personal session for a station-only user, so
  one that got past the sign-in hook some other way still can't act.
- displayName falls back through publicEmail, so a placeholder can never
  show on workflow screens.
- SPEC-identity: the reserved-domain check is in core createInvitation,
  not the contract; addUserEmail's deactivated rule.

Refs #170
@isaacvarg
isaacvarg merged commit b156869 into main Nov 10, 2026
1 check passed
@isaacvarg
isaacvarg deleted the identity/station-only-users branch November 10, 2026 21:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[spec] identity: station-only users (amendment 1)

1 participant