Skip to content

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 usedBy is 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_CODE from .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

Terminal window
# 1. Owner registers with the default code
curl -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 codes
curl -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 codes
curl -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.