Identity
Who a person is on Creora, from the signature that signs them in to the page a stranger reads. A wallet is the whole identity: no password, no email login, no second handle.
Signing in
The landing page is / and the sign in card is /signin, where the Reown AppKit modal drives Sign In With Ethereum through src/components/auth/siwe-config.ts.
POST /api/auth/wallet/nonce answers a fresh nonce, kept five minutes and optionally bound to the address that asked. The wallet signs an EIP-4361 message binding the app's host and origin, the chain, that nonce, its issue time and an expiry ten minutes later. POST /api/auth/wallet/verify sends message and signature back.
src/lib/auth/siwe.ts holds every check: version 1, domain the app host, uri the app origin, a chain from src/lib/auth/chains.ts, issuedAt within ten minutes of now, expirationTime still ahead. The nonce is deleted as it is read, so a replay meets nothing, and one issued for an address must be signed by it. Only then is the signature checked: an ordinary wallet's directly, a contract wallet's through EIP-1271 where ETH_RPC_URLS names an endpoint for the chain. Without one, the card says smart contract wallets are unsupported on this network.
A first sign in creates the account: the lowercase address, a generated username, the chain signed on.
The session
The session is one HS256 JWT: its subject is the user id, and it carries the address, the chain and sv, the session version. Issuer is the app origin, audience creora, lifetime seven days. It rides in an httpOnly, sameSite=lax cookie, named auth_token in development and __Host-auth_token in production, where it is also secure and host bound.
Every authenticated request verifies the token and loads the user row; a session_version that has moved on is a 401. Storing or removing an Anthropic API key raises it, signing every other session out and handing this one a fresh cookie.
GET /api/auth/wallet/session answers the user behind the cookie, or nulls, never cached. POST /api/auth/wallet/logout clears it and needs no valid session. AppKit signs out on disconnect and on a change of account, but not on a change of network, since the launchpad switches chains mid task.
src/proxy.ts verifies the cookie before a page renders and gates six prefixes on a segment boundary: /dashboard, /projects, /settings, /editor, /forum/new and /forum/moderation. No session goes to /signin?redirect=<path>, a failed cookie to /signin?reason=expired, a signed in visitor on / or /signin to /dashboard. Everything else, the forum, explore, profiles and published sites included, renders signed out.
Usernames
The username is the one global namespace, and a published project lives at /u/<username>/<slug>. The rules are shared by the settings form and the API, in src/lib/contracts/username.ts.
- The shape. 3 to 32 characters: lowercase letters, digits, underscores and hyphens.
- The canonical key.
canonicalUsernamefolds case, drops separators, and folds the characters that read alike in a URL:1andltoi,0too,5tos. It is stored asusername_canonicalunder a unique index, sonick,Nick,n1ckandn-i-c-kcannot be four accounts. - Reserved names. Route names and official identities, matched on the canonical key so
adm1nis reserved too, plus the generateduser_<8 hex>shape, so nobody can pose as an account that never chose a name. - Holds. Giving up a chosen name, by renaming or by deleting the account, puts its canonical key out of reach for 30 days. Whoever released it may reclaim it; to everybody else it reads as taken, with no hint of when it frees up. A generated name is never held.
PATCH /api/users/me renames, at 10 account changes an hour, and keeps the session, since the token carries no username. A name the namespace cannot use is a 400; one reserved, taken or held is a 409.
Profiles
A profile is its own record under src/lib/profiles/, never fields on the user row, so nothing that serializes one can reach an encrypted API key. It appears on the first edit.
The fields, read and written at GET and PATCH /api/users/me/profile: a display name up to 50 characters, a bio up to 300, up to five links of a label and an https address each, a switch that shows the wallet address (off by default), and a visibility of public or hidden.
The public page is /u/<username>, resolved by canonical key: /u/Alice and /u/al-ice reach the same person and redirect to the stored spelling. It lists whatever that person has published. A hidden profile answers 404 to everyone but its owner and is never indexed.
Avatars and banners go to PUT /api/users/me/profile/image/avatar and .../banner, the image as the body rather than a form. The bytes decide the type: PNG, JPEG and WebP only; a declared type that disagrees is refused. An avatar is at most 1 MB and between 32 and 4096 pixels a side; a banner at most 4 MB, at least 320 by 80 and at most 8000 by 4096. The browser downscales the longest edge to 512 for an avatar and 1600 for a banner, metadata is stripped, and the bytes live in MongoDB, served from /api/assets/<id>.
The notification email
User.notification_email is optional, unverified and at most 254 characters, set from Settings, Account. The contract in src/lib/contracts/notification-email.ts binds every use: notifications only, what Creora has to say to somebody who is not looking at the screen. It is never authentication, never a login or recovery path, and never unique, since it identifies nobody. Nothing sends mail yet; no provider is configured.
Admins
ADMIN_ADDRESSES is a comma separated list of wallet addresses, compared lowercased, checked at boot and empty by default. It buys one thing, through src/lib/forum/moderation.ts: moderator powers in the forum. A moderator can pin, lock and remove posts, works the report queue at /forum/moderation, and is exempt from the gates on new accounts. Anybody else gets a 404, not a 403, and with the list empty the deployment has no moderators at all.
Authority is configuration, never data, so promoting a moderator is a deployment. Admins have no power over projects, credits or accounts; the launchpad's owner is a separate thing held on the chain, in operating the launchpad.
Deleting an account
DELETE /api/users/me needs the username typed back exactly. Every step is idempotent, so a failure part way is a retryable delete rather than an artefact whose owner is gone. The GitHub credential goes first, then queued jobs, then the profile, so /u/<name> stops resolving before anything else. Then project database drops are scheduled, then project API keys, sites and publications, so every published page is unreachable, then projects, messages, images, achievements and the credit account. Forum posts are kept and lose their author. The username hold is written while the row that owns the name still exists, the user row goes last, and the response clears the cookie.
What Creora never does with a wallet
Signing in is a signature and nothing else: no transaction, no gas, no approval, no funds moved, and the app never sees a private key. The only place Creora asks a wallet to sign a transaction is the launchpad, where the person starts each one themselves against public contracts.