Registration, login, access codes and the waitlist.
Every schema below is exported from @novaterra/contracts and validated on both sides of the wire. Source: packages/contracts/src/auth.ts.
RegistrationProof
The answer to a door’s proof-of-work challenge (Council 038 6-A). challenge is the opaque, server-signed string GET /api/auth/door handed out; nonce is what the browser found such that sha256(challenge + ':' + nonce) starts with the challenge’s number of zero bits. Bounded on both sides for the same reason LinkToken is: the server issues short strings and anything wildly longer is a probe.
| Field | Type | Notes |
|---|
challenge | string (min 16, max 256) | |
nonce | string (min 1, max 32) | |
RegisterRequest
| Field | Type | Notes |
|---|
email | string (email) | |
password | string (min 8) | |
handle | string (min 2, max 32, regex) | |
displayName | string (min 1, max 80) | |
accessCode | string (min 4) | optional |
proof | RegistrationProof | optional |
DoorState
What the door says about itself, to anybody, before they have an account (GET /api/auth/door). mode is the honest answer to “can I come in without an invitation”: open on a Hall whose owner has not pulled the kill-switch, invite everywhere else. Every limit a stranger meets is published here as a NUMBER read from the constant that enforces it, so the sign-up screen and the deploy pre-flight describe the gate the server actually runs rather than one somebody remembered. challenge is null when the door is shut: there is nothing to prove your way past.
| Field | Type | Notes |
|---|
mode | 'open' | 'invite' | |
reason | string | |
verificationRequired | boolean | |
challenge | { token: string; bits: number (safeint, ≥ 0, ≤ 32); expiresAt: ISODate } | null | |
limits | { perIpPerDay: number (safeint); perDomainPerHour: number (safeint); perProviderDomainPerHour: number (safeint) } | |
WaitlistNotifyResult
Owner only: the one-shot “the door is open” letter to everybody on the waitlist.
| Field | Type | Notes |
|---|
sent | number (safeint) | |
failed | number (safeint) | |
alreadyTold | number (safeint) | |
total | number (safeint) | |
LoginRequest
| Field | Type | Notes |
|---|
email | string (email) | |
password | string | |
AuthResponse
| Field | Type | Notes |
|---|
being | Being | |
isOwner | boolean | |
AccessCode
| Field | Type | Notes |
|---|
code | string | |
createdBy | Id | |
usedBy | Id | null | |
note | string | optional · default '' |
createdAt | ISODate | |
usedAt | ISODate | null | |
WaitlistRequest
| Field | Type | Notes |
|---|
email | string (email) | |
name | string (max 80) | optional |
why | string (max 500) | optional |
EmailStatus
What the signed-in person’s own address looks like from here. emailConfigured is deliberately part of the response rather than something the UI guesses: a node with no RESEND_API_KEY cannot send anything, and a button offering to “send a link” that silently goes nowhere is worse than no button. It is a capability flag, not a secret — it says whether this node has a mail provider, not who it is or what its key is. ⭐ email IS NULLABLE, AND THE NULL IS A FACT RATHER THAN AN ABSENCE OF ONE. A credentials row is written by exactly one thing — POST /api/auth/register — so every being that arrived any other way has none: the twelve seeded citizens on a fresh node, and anybody created outside registration. GET /api/auth/email used to answer them 404 Account, which is a sentence about the ACCOUNT (“there is no such thing here”) standing in for a sentence about the ADDRESS (“nobody has set one”). Those are different facts and must stay different, the same way an ABSENT capability grade is not a grade of 0 and an empty event list from prose is not an empty calendar. It also made /verify a 404 for every being that is not the registered owner, which tests/contracts/citizenship.test.ts is the standing objection to: a being is a being. null therefore means “no address is set on this account”, and it is the only value that means it. verified: false beside a real address means “set, not confirmed” and must not be read as this case — a UI that branches on verified alone offers to send a link to nowhere.
| Field | Type | Notes |
|---|
email | string (email) | null | |
verified | boolean | |
verifiedAt | ISODate | null | |
pending | boolean | |
emailConfigured | boolean | |
required | boolean | optional |
VerifyEmailRequest
| Field | Type | Notes |
|---|
token | string (min 16, max 256) | |
PasswordResetRequest
“Send me a way back in.” Answered identically whether or not the address is known here.
| Field | Type | Notes |
|---|
email | string (email) | |
SetPasswordRequest
The other half: the token from the link, and the password to put in its place.
| Field | Type | Notes |
|---|
token | string (min 16, max 256) | |
password | string (min 8) | |
InviteEmailRequest
Owner only: mint a code and send it to an address, rather than reading it off a screen.
| Field | Type | Notes |
|---|
email | string (email) | |
note | string (max 200) | optional · default '' |
InviteEmail
One invitation, as the owner’s own screen shows it back. codeHint is a MASK (NOVA-••••-7F3K), not the code. An access code is a single-use bearer credential: whoever reads it can claim a place in this world, and this list is the one screen that will hold dozens of them at once. Settings once printed 27 live codes in plaintext and that was the finding; a list whose job is “who did I invite, and did they arrive” does not need to carry the credential to do it. The owner who needs the literal code still has GET /api/auth/codes.
| Field | Type | Notes |
|---|
id | Id | |
email | string (email) | |
codeHint | string | |
note | string | optional · default '' |
sentBy | Id | |
delivered | boolean | |
reason | string | optional · default '' |
used | boolean | |
createdAt | ISODate | |