On this pageIntroduction
Introduction
Better Room is room-scoped contextual authorization for Better Auth. Someone presenting a room code gets a membership, not an identity: joining is not authenticating.
It is not an identity provider, a permission engine or a realtime server. It is the hard parts of letting people into a shared space with a short code: joining without an account, capacity that holds under a race, codes safe to leak a database over, and a change signal you broadcast yourself.
Installation
Better Room uses your application’s copy of Better Auth, so it needs an existing Better Auth setup.
Yarn does not add peer dependencies for you. With Yarn, also run yarn add better-auth @better-auth/core better-call zod.
Setup
Add the plugin to your Better Auth instance, and its client plugin to your auth client.
Then run Better Auth’s migration or schema generation so the five room tables exist. Call betterRoom() once per betterAuth() instance: the code key is derived from the secret of the instance it first binds to.
The model
Five terms, resolved in one direction. Each depends only on the one before it.
- Identity
- Actor
- Room
- Membership
- Access
- Identity
- Who someone is: a user and a session. Owned by Better Auth.
- Actor
- Whoever acts in a room. Anonymous, or the single actor a user owns. Owned by Better Room.
- Room
- A bounded context: active, locked or closed, with optional maxMembers and expiresAt. Owned by Better Room.
- Membership
- One actor in one room, with a role, an optional deadline, and leftAt or revokedAt once it ends. Owned by Better Room.
- Access
- Whether this actor may act here, now. Decided from the membership and the room when you ask. Owned by Better Room.
Roles are an open set of opaque strings. A join gets participant; any other role, such as facilitator, comes from addRoomMember on your server. room.createdBy is provenance and grants no authority: there is no host, only the roles your application names.
Joining is not authenticating
Better Auth establishes identity and sessions. Better Room resolves a code into a membership in one room. A caller with a session joins as their user’s actor; one without gets an anonymous actor and a signed grant cookie. Joining never creates a user.
| Aspect | Authentication | Joining a room |
|---|---|---|
| Presents | Credentials: password, OAuth, passkey, magic link | A room code |
| Establishes | A user and a session | A membership in one room, and a room_grant cookie when anonymous |
| Scope | The whole application | One room |
| Answers | Who are you? | May you act here, now? |
join.requireSessionTurn it on to refuse callers without a session as JOIN_NEEDS_A_SESSION. The refusal comes after the code resolves, so an unknown code still counts against the attempt budgets, and before anything is written. Anonymous members admitted earlier keep their access until it expires or is revoked.
When an anonymous member signs in, call promote so their memberships follow them to their account.
Room states
A room is active, locked or closed, and may carry a maxMembers and an expiresAt. A membership ends when its actor leaves, is revoked, or passes its own deadline.
- active
- Admits joins by code and additions.
- locked
- Refuses codes. Members stay; addRoomMember still adds.
- closed
- Ended for good. Cannot be undone or rotated.
- past expiresAt
- Not a stored status: the clock decides, so no write and no event marks it.
Leaving keeps the row and lets the same actor rejoin. Revoking is permanent. Show occupied from getRoomOccupancy as the number of people in a room, not room.memberCount, which is the admission gate and may sit above real occupancy after a write that failed halfway.
Capturing the code
Plaintext codes are returned only by createRoom and rotateRoomCode. No reversible copy exists anywhere, so if you drop one, the only way to get a working code for that room is to rotate. Four ways applications handle it, in rough order of how often they fit:
- 1Return it to the creator. Hold it in client state while the waiting screen lives. Enough for a code read out loud.
- 2Store it next to your room. Encrypted with a key you control, when a host must see the same code days later.
- 3Rotate on demand. Show the new code. The previous one still admits for the grace window.
- 4Share it at creation. Send it through your own link or invitation email and never persist it.
Operations
Self-service operations are routes, confined to the caller’s own actor, and grant nothing the caller did not already hold. The administrative operations are not routes at all: Better Auth never mounts them, a guessed path answers 404, and the client does not offer them. Your backend calls them through auth.api after deciding who may. createRoom is the one exception: it has a route, which refuses with CREATION_IS_SERVER_ONLY unless creation.overHttp is on.
| Operation | Route |
|---|---|
| createRoom() | POST /better-room/create |
| joinRoom() | POST /better-room/join |
| getRoomAccess() | GET /better-room/access |
| getRoomOccupancy() | GET /better-room/occupancy |
| listRoomMemberships() | GET /better-room/memberships |
| leaveRoom() | POST /better-room/leave |
| promoteRoomActor() | POST /better-room/promote |
| addRoomMember() | none, auth.api only |
| revokeRoomMember() | none, auth.api only |
| rotateRoomCode() | none, auth.api only |
| lock · unlock · close | none, auth.api only |
| reconcileRoomCapacity() | none, auth.api only |
Endpoints return reports, not rows. Each body is built field by field, so a column you add never leaks into a response. RoomReport, MembershipReport and their siblings are exported, and the client infers them.
createRoom()
Server-onlyPOST /better-room/createCreates a room and returns its plaintext code, the only time it is readable. Every field is optional: userId is provenance only and grants no authority. With creation.overHttp on, a signed-in caller can create over HTTP and is recorded as the creator.
joinRoom()
Self-servicePOST /better-room/joinPresents a code and returns a membership with the participant role. An anonymous caller gets a signed room_grant cookie, valid for grant.lifetime. A caller with a session resolves to that user’s actor and carries no grant.
getRoomAccess()
Self-serviceGET /better-room/accessSays whether the caller may act in the room right now. Over HTTP the room is reported only to a caller related to it; anyone else gets authorized: false with null room and membership. Through auth.api any room is reported in full, and a missing one throws UNKNOWN_ROOM.
getRoomOccupancy()
Self-serviceGET /better-room/occupancyCounts the memberships holding the room right now, from the memberships themselves. Show occupied, not room.memberCount. It counts memberships, not presence. Over HTTP only members may read it.
listRoomMemberships()
Self-serviceGET /better-room/membershipsLists the caller’s standing memberships, each paired with its room. Pages read up to 200 memberships; follow next as before until it is null, even past an empty page.
leaveRoom()
Self-servicePOST /better-room/leaveGives the caller’s seat back and keeps the membership row, so the same actor can rejoin. Leaving twice returns no second seat. It never overrides a revocation or an expiry.
promoteRoomActor()
Self-servicePOST /better-room/promoteMerges the anonymous actor into the signed-in user’s and carries its memberships across. It needs both the grant and the session on the request. Where both hold a membership in one room, the authenticated one is kept by a fixed rule, never by role. Past 10000 memberships complete is false, and your server finishes with auth.api.promoteRoomActor({ body: { actorId: merged, userId } }).
addRoomMember()
Server-onlyAdds an actor named by exactly one of userId or actorId, with any role and an optional expiresAt. It takes a seat like a join and works on a locked room. It is the only way to grant an elevated role.
revokeRoomMember()
Server-onlyWithdraws a membership permanently and returns its seat. A later join by that actor is refused as MEMBERSHIP_REVOKED; a second revocation as ALREADY_REVOKED.
rotateRoomCode()
Server-onlyIssues a new code. The previous one keeps admitting for code.grace seconds, so a code already shared does not break at once. Rotating again ends that window. A locked room can be rotated; a closed or expired one cannot.
lockRoom() · unlockRoom() · closeRoom()
Server-onlyLocking refuses joins by code and keeps the members already in; addRoomMember still adds. Closing ends the room and cannot be undone. Asking for the state a room already has succeeds without a write.
reconcileRoomCapacity()
Server-onlyCollects seats still held by memberships whose deadline passed, or whose revocation stopped halfway. Runs in batches of 1 to 1000, 200 by default; call it until owing comes back 0.
Options
Every option is optional. These are the defaults.
| Option | Default | Range |
|---|---|---|
code.format | 'crockford' | 'crockford' or 'numeric' |
code.length | 8, or 6 for numeric | 1 to 64 symbols |
code.grace | 120 | 1 to 86400 seconds |
grant.lifetime | 604800 | 1 to 34560000 seconds |
creation.overHttp | false | boolean |
join.requireSession | false | boolean |
attempts.window | 60 | 1 to 86400 seconds |
attempts.perIp | 10 | 1 to 2147483647 |
attempts.everyone | 600 | 1 to 2147483647 |
Options are checked when betterRoom() runs: a number out of range throws a RangeError, and a flag that is not a real boolean, an unknown format or a reserved additionalFields name throws a TypeError. Every duration has a ceiling, because a value that overflows date arithmetic silently disables what it configures. crockford uses 32 symbols without look-alike letters and folds I and L to 1, O to 0; numeric is digits only, for keypads and phone prompts.
Connections
Commands travel over HTTP and live updates over your own socket. The plugin owns no transport. It gives you the two halves every integration needs: a way to authorize a connection, and a signal for every change.
Check the room when the connection opens, with the headers the upgrade carried. Cookies travel on a same-origin upgrade, so a grant and a session resolve exactly as they do over HTTP.
Authorization is decided when you ask, so a revoked member keeps a live socket until you close it. Keep each socket’s roomId and actorId, and close on the events that end access. A locked room keeps its members.
Events
onChange runs after every change the plugin saves, typed as RoomEvent. A switch on event.type narrows each one.
| type | Fields | Raised when |
|---|---|---|
| joined | roomId, membership | A code admitted an actor, including a rejoin. |
| added | roomId, membership | addRoomMember added one. |
| left | roomId, membership | A member left. |
| revoked | roomId, membership | A membership was revoked. |
| expired | roomId, membership | Reconciliation took back a seat whose deadline passed. |
| created | roomId, room | A room was created. Its code is never in an event. |
| locked · unlocked · closed | roomId, room | The room changed state. |
| rotated | roomId | The code was rotated. The new code is never in an event. |
| promoted | actorId, merged | An anonymous actor was merged into actorId. |
| erased | actorId | The actor of a deleted user was erased. |
- Fires once per saved change, after the write, never for a refusal or a call that changed nothing.
- Runs in the process that handled the request, and the request waits for it. Hand slow work to a queue; with several instances, publish to your own broker.
- A listener that throws is logged and cannot undo the change or fail the request. Events are not retried.
- A deadline raises nothing when it passes. Schedule your own timer from
expiresAtif clients must hear it at that moment.
Room codes
A room code is a secret that admits strangers. It is stored as an HMAC of its canonical form, keyed by a subkey derived from your Better Auth secret, so a database dump does not hand out a working key to every live room.
- Presented
- 'k7qd-2m4p'
- Canonical
- 'K7QD2M4P'
- Identifier
- hmac(subkey(secret), canonical)
- Stored in
- roomCode.identifier
- Plaintext
- never persisted
- Codes are folded through a fixed ASCII table, so case, spaces and dashes do not matter, and any other character is refused.
- Rotating keeps the previous code admitting for
code.graceseconds, 120 by default. Only one generation is kept in grace. - Over HTTP, a caller with no relation to a room gets the same answer whether it exists or not, so room ids cannot be walked.
Brute force and privilege
A failed code counts twice: against the caller’s address and against a global budget. An address that spends its own ceiling waits for its window to roll over. The global ceiling only gates an address with a failure of its own, so a flood cannot lock out callers who never guessed. There is no per-room lockout, because that would let anyone who knows a room exists lock its members out.
Every failure to resolve is the same CODE_DID_NOT_RESOLVE; telling them apart would roughly halve the work of sweeping the code space. Once a code resolves, the refusal names its reason, because the caller has already proved the code is good.
Error codes
Reachable as auth.$ERROR_CODES on the server and as ROOM_ERROR_CODES from both entry points. On the client, use ROOM_ERROR_CODES for values; client.$ERROR_CODES is for types only.
| Code | Status | Raised when |
|---|---|---|
CODE_DID_NOT_RESOLVE | 400 | The code matched no live room. |
EXACTLY_ONE_IDENTITY | 400 | A member was named by both userId and actorId, or neither. |
NO_GRANT_TO_PROMOTE | 400 | Promotion arrived without a grant to merge. |
RESUME_NEEDS_BOTH_NAMES | 400 | Resuming a promotion named only one of the two. |
MEMBERSHIP_EXPIRES_IN_THE_PAST | 400 | An added membership would expire before it began. |
ROOM_EXPIRES_IN_THE_PAST | 400 | A created room would expire before it began. |
PROMOTION_NEEDS_A_SESSION | 401 | Promotion arrived without a session. |
CREATION_NEEDS_A_SESSION | 401 | Creation over HTTP arrived without a session. |
JOIN_NEEDS_A_SESSION | 401 | A join arrived without a session while join.requireSession is on. |
ROOM_LOCKED | 403 | The room refuses joins by code. |
ROOM_CLOSED | 403 | The room has ended. |
ROOM_EXPIRED | 403 | The room’s deadline passed. |
MEMBERSHIP_REVOKED | 403 | The membership was withdrawn. |
MEMBERSHIP_EXPIRED | 403 | The membership’s own window closed. |
CREATION_IS_SERVER_ONLY | 403 | Creation was called over HTTP while overHttp is off. |
RESUME_IS_SERVER_ONLY | 403 | Resuming a promotion was called over HTTP. |
NOT_A_MEMBER | 403 / 404 | Nobody holds the membership the call names. |
UNKNOWN_ROOM | 404 | No room has that id. |
UNKNOWN_ACTOR | 404 | No actor has that id, or it was erased mid-request. |
ROOM_AT_CAPACITY | 409 | The room has no seat left. |
ROOM_CONTENDED | 409 | The room or membership kept changing while the request settled it. |
ALREADY_A_MEMBER | 409 | The actor already holds a membership there. |
ALREADY_LINKED | 409 | The actor already belongs to a user. |
ALREADY_REVOKED | 409 | The membership was already revoked. |
GRANT_IS_STALE | 409 | The grant no longer matches the actor it names. |
TOO_MANY_ATTEMPTS | 429 | The caller’s attempt budget is spent. |
CODE_SPACE_EXHAUSTED | 503 | No free code was found for this room. |
Database
Five tables, declared through Better Auth’s plugin schema, so the official migration and schema generators create them. Row ids are Better Auth’s to generate, so every generateId mode works.
- roomActor
- One per anonymous participant, or one per user. userId is unique and deliberately not a foreign key.
- room
- Status, memberCount gate, optional maxMembers and expiresAt, and createdBy as provenance.
- roomCode
- The HMAC in a unique identifier column, and a status that rotation walks.
- roomMember
- Unique on (roomId, actorId), so two simultaneous joins collapse into one.
- roomAttempt
- The brute-force budgets. Required: joining does not work without it.
Four unique indexes carry behaviour, not just speed: roomMember (roomId, actorId), roomCode.identifier, roomAttempt.key and roomActor.userId. Drizzle, Kysely and Better Auth’s Prisma generation emit them. A hand-written Prisma schema must carry all four, and on MongoDB the roomActor.userId index must be created by hand as a partial unique index. The full details live in the reference.
What Better Room deliberately doesn’t do
- Not An identity providerBetter Auth signs people in and owns users and sessions.
- Not A permission engineRoles are opaque strings. What each may do is your policy. Deferred, not rejected.
- Not A realtime serveronChange hands you each change. Connections, retries and fan-out stay yours.
- Not Roles carried by a codeA code admits as participant. Roles are assigned on the server.
- Not A reversible stored codeOnly an HMAC of the canonical code is kept.
- Not A per-room lockoutIt would let anyone who knows a room exists lock its members out.
- Not A metadata JSON columnAdd typed columns through schema additionalFields, or keep it in your own tables.