A Cloudflare Worker that gives Divine members a human-readable identity: a username that works as a NIP-05 Nostr address, an ActivityPub/WebFinger handle, an ATProto handle, and a profile page at username.divine.video. One name, resolvable across the open social protocols.
The Worker owns the username registry, a public reservation and claiming flow, and an admin console. It runs on the public edge (not the ArgoCD-managed GKE stack) and mirrors its read model to Fastly KV so divine-router can answer identity lookups at the edge. Because it publishes the public identity model that other services consume, deploy it alongside divine-router, pds.divine.video, and entryway.divine.video.
- Username claiming — Members claim a username with a NIP-98 signed HTTP request that proves ownership of their Nostr key. No sessions, no passwords.
- Public reservation flow — A pre-claim reservation path (
names.divine.video) that gates names behind a Cashu payment or an invite code, confirms ownership by email, and expires unconfirmed holds after 48 hours. - NIP-05 verification — Serves
/.well-known/nostr.jsonat both the root domain (alice@divine.video) and the per-user subdomain (_@alice.divine.video). - Subdomain profiles —
https://alice.divine.video/renders the Divine web app shell with the user's profile pre-injected, plus Open Graph and Twitter card tags for rich sharing. - ActivityPub discovery — WebFinger and NodeInfo endpoints make
@alice@divine.videoresolvable on the fediverse and let Divine be counted as an instance. - ATProto handles — Links a username to a
did:plc:identity soalice.divine.videoresolves as an ATProto handle (served bydivine-routerfrom the mirrored record). - Admin console — A React admin UI plus API for reserving, revoking, burning, assigning, restoring, and tagging usernames, with search, stats, and CSV export.
- Edge mirror — Every change is reconciled to Fastly KV via a durable sync queue and an hourly cron, so edge reads stay consistent with the D1 source of truth.
- Recoverable account deletion — Username release uses an owner-authenticated prepare/rollback lifecycle and a service-authenticated permanent finalize step.
- One owned name per pubkey — A partial unique index guarantees each pubkey holds at most one active or pending-release username; claiming a new one auto-revokes the old one.
The Divine Name Server is a single Cloudflare Worker (src/index.ts, built on Hono) that behaves differently depending on the hostname it is serving:
| Hostname | Purpose |
|---|---|
divine.video |
Root NIP-05 (/.well-known/nostr.json), WebFinger, NodeInfo |
alice.divine.video |
Per-user subdomain: profile page + subdomain NIP-05 |
names.divine.video |
Public landing page, reservation flow, email confirmation |
names.admin.divine.video |
Admin UI (React SPA) and admin API |
- Cloudflare D1 (
DB) — The source of truth: theusernamesregistry,reserved_words, reservations, tags, and the Fastly sync queue. - Cloudflare KV (
SESSION_KV) — Keycast OAuth admin sessions. - Fastly KV — An edge mirror of the active username read model, consumed by
divine-routerto answer NIP-05 and ATProto handle lookups close to the user. D1 writes are pushed to Fastly on the request path and reconciled hourly by cron.
- Root NIP-05 —
GET https://divine.video/.well-known/nostr.json?name=alicereturns the single mapping foralice. The endpoint requires anameparameter (it does not dump the full registry) and falls back to a dot-stripped lookup for legacy dotted handles. - Subdomain NIP-05 —
GET https://alice.divine.video/.well-known/nostr.jsonreturns the user's pubkey under the reserved_name, with relay hints when present. - Subdomain profile —
GET https://alice.divine.video/fetches the Divine web app shell, injectswindow.__DIVINE_USER__(pubkey, npub, display name, avatar pulled fromrelay.divine.video), rewrites the OG/Twitter meta tags, and returns the HTML so the SPA can render the profile. Static assets pass through to the origin. - ActivityPub —
GET /.well-known/webfinger?resource=acct:alice@divine.videoreturns a JRD pointing at the user's profile and ActivityPub actor URL./.well-known/nodeinfoand/nodeinfo/2.1advertise the instance.
Service subdomains (names, www, login, pds, feed, labeler, relay, media) are excluded from user-profile routing.
Admin API routes are guarded on two axes:
- Hostname guard — Admin routes only activate on
names.admin.divine.video(orlocalhost/admin.localhostin development), so the same Worker onnames.divine.videocannot reach them. - Authentication — A request must carry either a Cloudflare Access JWT (
Cf-Access-Jwt-Assertion, injected at the edge) or a Keycast OAuth session cookie whose pubkey is in theADMIN_PUBKEYSallowlist.
A scheduled handler runs hourly (0 * * * *):
- Expires unconfirmed reservations older than 48 hours.
- Returns deletion-held names to circulation after one year.
- Restores abandoned pending username releases after their recorded 72-hour recovery deadline; expiry never burns a name.
- Reconciles usernames changed in the last six hours, plus anything left in the durable Fastly sync queue, into Fastly KV — syncing active names and deleting revoked, burned, pending-release, or held names. Versioned queue entries prevent an older edge operation from clearing newer desired state.
- Node.js 18+
- A Cloudflare account with Workers and D1 enabled
- (Optional) Bun for the Vine import scripts
# Install Worker dependencies
npm install
# Apply database migrations to the local D1 database
npx wrangler d1 migrations apply divine-name-server-db --local# Install and build the admin UI (first run, and after admin-ui/ changes)
cd admin-ui && npm install && npm run build && cd ..
# Start the Worker
npm run dev
# Worker: http://localhost:8787
# Admin UI: http://admin.localhost:8787/Admin auth is enforced against the real Cloudflare Access / Keycast paths even locally. To bypass it during development, set BYPASS_LOCAL_AUTH=true in .dev.vars.
npm test # Vitest, watch mode
npm run test:once # single runBindings and variables live in wrangler.toml.
| Binding | Type | Purpose |
|---|---|---|
DB |
D1 database (divine-name-server-db) |
Username registry and all persistent state |
SESSION_KV |
KV namespace | Keycast OAuth admin sessions |
ASSETS |
Static assets (./admin-ui/dist) |
Admin UI SPA, served with run_worker_first |
| Name | Kind | Purpose |
|---|---|---|
KEYCAST_URL, KEYCAST_CLIENT_ID |
var | Keycast OAuth admin login (login.divine.video) |
ADMIN_PUBKEYS |
var | Comma-separated hex pubkeys allowed to use Keycast admin sessions |
FASTLY_STORE_ID |
var | Fastly KV store the edge mirror writes to |
FASTLY_API_TOKEN |
secret | Auth for Fastly KV sync (wrangler secret put) |
ATPROTO_SYNC_TOKEN |
secret | Bearer token for the internal ATProto sync endpoint |
SENDGRID_API_KEY |
secret | Sends reservation-confirmation and assignment emails |
ALLOWED_MINTS |
var | Comma-separated Cashu mint allowlist for paid reservations |
NAME_PRICE_JSON |
var | Overrides the tiered reservation pricing (JSON of length tier → sats) |
INVITE_FAUCET_URL |
var | Base URL of the invite-code faucet |
AP_ACTOR_BASE_URL |
var | Base URL for ActivityPub actor links in WebFinger responses |
Routes (Worker + zones) and the cron trigger are also declared in wrangler.toml.
# Apply migrations to the production database
npx wrangler d1 migrations apply divine-name-server-db --remote
# Build the admin UI and deploy the Worker
npm run deploy # == npm run build:admin && wrangler deployDeploy this Worker after the GKE identity services are healthy: it publishes the public read model that divine-router and ATProto handle discovery consume.
All endpoints send permissive CORS headers so the Divine web and Flutter clients can call them.
| Method | Path | Description |
|---|---|---|
GET |
/api/username/check/:name |
Check availability and validate format. No auth. |
GET |
/api/username/by-pubkey/:pubkey |
Look up the active username for a pubkey. No auth. |
POST |
/api/username/reserve |
Reserve a name with email + Cashu payment or invite code. |
GET |
/api/username/confirm |
JSON email-confirmation callback for a reservation token. |
POST |
/api/username/claim |
Claim a name with NIP-98 auth. |
POST |
/api/username/release/prepare |
Hide and hold an owned name for an opaque deletion attempt. NIP-98 auth. |
GET |
/api/username/release/attempt |
Read the caller's latest durable release-attempt state. NIP-98 auth. |
POST |
/api/username/release/rollback |
Restore a recoverable release and return its stored terminal state. NIP-98 auth. |
POST |
/api/username/release |
Legacy immediate permanent burn; retained while callers migrate, with removal tracked in #78. |
Claim a username by proving key ownership.
Authentication: a NIP-98 event (kind 27235) sent as Authorization: Nostr <base64-event>, with a u tag matching the request URL, a method tag matching POST, and a timestamp within 300 seconds.
Request body:
{
"name": "alice",
"relays": ["wss://relay.damus.io", "wss://nos.lol"]
}name(required) — 3–20 lowercase alphanumeric characters, not a reserved word.relays(optional) — up to 50wss://relay hints.
Success (200):
{
"ok": true,
"name": "alice",
"pubkey": "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
"profile_url": "https://alice.divine.video/",
"nip05": {
"main_domain": "alice@divine.video",
"underscore_subdomain": "_@alice.divine.video",
"host_style": "@alice.divine.video"
}
}Errors: 400 invalid username/relays · 401 bad NIP-98 signature · 403 reserved or burned · 409 claimed by another pubkey · 500 internal error.
The deletion coordinator supplies one opaque 16–128 character attempt ID across services. Preparing changes the owned row from active to pending-release; the name stops resolving but remains owned and unavailable to claims. The authenticated owner may roll it back to active. Finalizing returns protected words to reserved; every other name becomes non-recyclable held for one year, then moves to recyclable revoked. Finalization clears the deleted owner's public resolution identity and records a pubkey-free release-history breadcrumb. Replays of the same transition are safe. A finalized attempt cannot be rolled back.
active --prepare--> pending-release --rollback--------> active / cancelled
|--72h expiry restore---> active / expired-restored
`--service finalize-----> held or reserved / finalized
|
`--1 year--> revoked / claimable
The deletion coordinator uses Authorization: Bearer <DELETION_COORDINATOR_TOKEN> for three internal operations. All three resolve the owning pubkey and name from the stored attempt, so a caller cannot substitute its own ownership data:
GET /api/internal/username/release/attempt/:attemptIdreturns the attempt'sstate,username,pubkey, andexpires_at, so the coordinator can check the account binding and the recovery deadline itself.404when the attempt is unknown.POST /api/internal/username/release/rollbackaccepts{ "attempt_id": "..." }, restores the pending-release name toactive, and cancels the attempt. It also succeeds idempotently when the same owner and name were already restored by cancellation or the 72h expiry sweeper.409withattempt_finalizedonce finalization has completed, orattempt_conflictfor another incompatible state.POST /api/internal/username/release/finalizeaccepts{ "attempt_id": "..." }, returns protected words to the reserve, and places other names on a one-year hold.409withattempt_expiredpast the recovery deadline,attempt_cancelledonce it was rolled back, orattempt_conflictwhen the name is no longerpending-release.
The legacy POST /api/username/release compatibility endpoint still performs an immediate permanent burn for clients that have not migrated to the recoverable lifecycle. Its removal is tracked by TODO(#78) in the route.
Set the credential with wrangler secret put DELETION_COORDINATOR_TOKEN; it is intentionally separate from ATPROTO_SYNC_TOKEN.
Hold a name before claiming it. Requires an email plus either a cashu_token (validated against ALLOWED_MINTS and the tiered price for the name length) or an invite_code. Rate-limited to 5 reservations per email per hour. On success the Worker emails a confirmation link; the hold expires in 48 hours if unconfirmed.
{
"name": "alice",
"email": "alice@example.com",
"cashu_token": "cashuA...",
"invite_code": "optional-instead-of-payment"
}Reservation pricing is tiered by name length (shorter names cost more) with a curated premium-name list; defaults live in src/utils/pricing.ts and can be overridden with NAME_PRICE_JSON.
| Method | Path | Description |
|---|---|---|
GET |
/.well-known/nostr.json?name=<name> |
Root NIP-05 (requires name, returns one mapping) |
GET |
/.well-known/nostr.json (on a subdomain) |
Subdomain NIP-05, returns the _ mapping |
GET |
/.well-known/webfinger?resource=acct:<user>@divine.video |
WebFinger JRD |
GET |
/.well-known/nodeinfo |
NodeInfo discovery document |
GET |
/nodeinfo/2.1 |
NodeInfo 2.1 document (active user count) |
Subdomain NIP-05 (200):
{
"names": { "_": "3bf0c63f...aefa459d" },
"relays": { "3bf0c63f...aefa459d": ["wss://relay.damus.io", "wss://nos.lol"] }
}Root NIP-05 (200):
{
"names": { "alice": "3bf0c63f...aefa459d" },
"relays": { "3bf0c63f...aefa459d": ["wss://relay.damus.io"] }
}All identity responses send Cache-Control: public, max-age=60.
Guarded by the hostname + auth rules above. Highlights:
| Method | Path | Description |
|---|---|---|
GET |
/api/admin/usernames/search |
Search by name, pubkey, or status |
GET |
/api/admin/usernames/stats |
Registry counts |
GET |
/api/admin/username/:name |
Name detail, including tags |
GET |
/api/admin/username/:name/release-history |
Read pubkey-free deletion release history |
POST |
/api/admin/username/:name/release-hold |
Return a held name to circulation early |
POST |
/api/admin/username/reserve · /reserve-bulk |
Reserve one or many names |
POST |
/api/admin/username/revoke |
Revoke (recyclable) or burn (permanent) a name |
POST |
/api/admin/username/restore |
Re-bind a revoked/burned name to a pubkey |
POST |
/api/admin/username/assign · /assign-bulk |
Directly assign names to pubkeys |
POST |
/api/admin/username/set-atproto |
Set a name's did:plc: and handle-resolution state |
GET |
/api/admin/username/:name/nip05-status |
Compare D1 vs Fastly KV for a name |
POST |
/api/admin/username/:name/sync-to-fastly · /api/admin/sync/fastly |
Force edge re-sync |
GET |
/api/admin/export/csv |
Export the registry as CSV |
GET/POST/DELETE |
/api/admin/reserved-words[/:word] |
Manage reserved words |
POST/DELETE |
/api/admin/username/:name/tags[/:tag] |
Manage per-name tags |
POST |
/api/admin/notify-assignment |
Email a user their assigned name |
Admin sessions are established via the Keycast OAuth flow under /api/admin/auth/ (start, callback, status, logout).
revoke preserves the pubkey on revoked and burned rows so the moderation service can later find a banned user's names (search?q=<pubkey>&status=burned) and restore them if a ban is reversed. restore sets the name back to active, clears revoked_at, re-binds the pubkey (revoking any other active name that pubkey holds), appends an audited note, and re-syncs to Fastly KV. Restoring an already-active name returns 409 rather than silently overwriting the current owner.
set-atproto records a did:plc: and a lifecycle atproto_state (pending, ready, failed, disabled, or null). The record is mirrored to Fastly KV, and divine-router serves /.well-known/atproto-did only when the name is active, the state is ready, and a DID is present. The user's PDS-managed DID document must also list alsoKnownAs: ["at://username.divine.video"] for bidirectional verification. This Worker does not mint or manage DIDs.
POST /api/internal/username/set-atproto — service-to-service variant of the ATProto linking endpoint, authenticated with the ATPROTO_SYNC_TOKEN bearer token.
The deletion coordinator endpoints under /api/internal/username/release/ are authenticated with DELETION_COORDINATOR_TOKEN; their status, rollback, and finalize contracts are documented in Recoverable release lifecycle.
Migrations under migrations/ define and evolve the schema (0001_initial_schema.sql onward). The core tables:
| Column | Type | Description |
|---|---|---|
id |
INTEGER | Primary key |
name / username_display |
TEXT | Display form of the username |
username_canonical |
TEXT | Canonical (lowercased, punycode) form used for lookups |
pubkey |
TEXT | Hex Nostr public key |
relays |
TEXT | JSON array of relay hints (max 50) |
status |
TEXT | active, reserved, revoked, burned, pending-confirmation, pending-release, held |
recyclable |
INTEGER | Whether a freed name can be reclaimed |
atproto_did / atproto_state |
TEXT | ATProto handle linkage |
created_at / updated_at / claimed_at / revoked_at |
INTEGER | Unix timestamps |
reserved_reason / admin_notes |
TEXT | Admin metadata |
A partial unique index enforces one owned (active or pending-release) name per pubkey.
Durable audit records keyed by opaque deletion-attempt ID. Each record stores the canonical name, owner pubkey, state (pending, cancelled, finalized, or expired-restored), timestamps, explicit expiry, and service finalizer identity. Admins can inspect recent attempts through GET /api/admin/username-release-attempts.
Append-only, pubkey-free breadcrumbs recording when a name was released by account deletion. Entries remain after the name is reissued and are visible only through the authenticated admin API.
Protected words (system routes, brand and protocol terms) that cannot be claimed, seeded by 0002_seed_reserved_words.sql and editable via the admin API.
Additional tables cover reservations, spent Cashu proofs, username tags, and the Fastly sync queue — see the corresponding migrations.
Usernames: 3–20 characters, lowercase letters and digits only, not a reserved word, unique per active pubkey. Claiming a new name auto-revokes the old one.
Relay hints: optional; when present, each must be a valid wss:// URL of at most 200 characters, with at most 50 per name.
The service exposes the same pubkey in three forms:
alice@divine.video— standard NIP-05, resolved at the root domain._@alice.divine.video— subdomain NIP-05 using the reserved_name.@alice.divine.video— Bluesky-style display form, verified via the subdomain format.
- Cryptographic claims — Every claim requires a valid, time-bound NIP-98 signature; there is no session state to hijack.
- Gated reservations — Public reservations require payment or an invite code, are rate-limited per email, and are confirmed by email before activation.
- Admin defense in depth — A hostname guard plus Cloudflare Access or an allowlisted Keycast session protect every admin route.
- Namespace protection — Reserved words and service subdomains keep system routes and brand names unclaimable; burned names are permanently unavailable, while deletion-held names remain unavailable for one year.
- No hijacking — A partial unique index prevents taking over a name owned by another pubkey.
Design and rollout notes live under docs/ (see docs/plans/ and docs/superpowers/). AGENTS.md documents contributor and PR conventions.
MIT
Part of Divine — your playground for human creativity · Brand guidelines