Access codes and the waitlist
Novaterra is multi-user from day one, and registration is gated. This is a deliberate design decision (PLAN.md §1, confirmed in §8): a world whose constitution promises kindness and consent at scale needs to grow at a pace those promises can actually be kept at. In the product’s own lore, this is the Harbor: new citizens are admitted by Key, and everyone else waits on the Shore.
Access codes
An AccessCode is a simple row:
{ code: string, createdBy: Id, // the being who minted it usedBy: Id | null, // set once redeemed note: string, createdAt: ISODate, usedAt: ISODate | null,}- Each code is single-use. Once
usedByis set, it can’t be redeemed again. GET /api/auth/codes(owner only) lists every code minted so far, used and unused.POST /api/auth/codes(owner only) mints new ones:{ count, note }→AccessCode[].- The very first code in any install is
OWNER_ACCESS_CODEfrom.env— see Environment variables.
Becoming the owner
Whoever registers first — using the owner code — becomes the world’s owner (Being.isOwner). The
owner is the only role with a special power in the access-code system: minting more codes from
Settings → Access codes. Owner-only routes simply check isOwner on the authenticated being — the
same check that gates the separate operator console at /admin (see
The admin console), which surfaces a read-only view of these same
codes and the waitlist alongside people, LLM spend and system health, rather than duplicating the
controls here.
Practically, this means: on a fresh clone, register once with the default owner code, then mint codes for anyone else you want to invite, from inside the app. Nobody needs shell access to your server to invite a second person.
The whole decision — is this code still unused, is this the first human, is this handle/email free —
happens inside one database transaction (apps/api/src/modules/auth/routes.ts), not just the
pre-checks before it, so two registrations racing on the last free code, or both racing to become
owner, can’t both win.
The waitlist
For everyone who doesn’t have a code yet, the landing page offers a waitlist form:
POST /api/auth/waitlist{ "email": "you@example.com", "name": "Jordan", "why": "A friend told me about it" }This is a public, unauthenticated route — no session needed. It writes a row to the waitlist
table (email, name, why, createdAt) and returns Ok. There’s no automatic promotion from waitlist
to access code in v1; it’s the owner’s job to look at the list and mint codes for the people they
want to invite. That’s intentional — the whole point of gating is a human decision at the door, not
an automated funnel.
A concrete flow
# 1. Owner registers with the default codecurl -s -X POST http://localhost:4000/api/auth/register \ -H 'content-type: application/json' \ -d '{"email":"owner@example.com","password":"a-real-password","handle":"owner","displayName":"Owner","accessCode":"NOVA-OWNER-0001"}' \ -c cookies.txt
# 2. Owner mints two more codescurl -s -X POST http://localhost:4000/api/auth/codes \ -H 'content-type: application/json' -b cookies.txt \ -d '{"count":2,"note":"for the two people who asked at lunch"}'
# 3. A friend registers with one of those codescurl -s -X POST http://localhost:4000/api/auth/register \ -H 'content-type: application/json' \ -d '{"email":"friend@example.com","password":"also-real","handle":"friend","displayName":"Friend","accessCode":"<code from step 2>"}'Registering without a valid, unused accessCode fails — there is no route that creates a Being
without one, by design.