Cloudflare Worker that enables username-based Nostr identities at Divine.Video with NIP-05 verification and subdomain profile routing.
- Username Claiming: Users claim usernames via NIP-98 signed HTTP requests proving key ownership
- Subdomain Profiles:
https://alice.divine.video/serves user profiles by proxying to main app - NIP-05 Verification: Nostr identity verification at both root and subdomain
/.well-known/nostr.jsonendpoints - Admin Management: Reserve, revoke, burn, or assign usernames with status tracking
- Relay Hints: Store and serve up to 50 relay hints per user for better discoverability
- One Username Per Pubkey: Database constraints ensure each pubkey has only one active username
- Recyclable Usernames: Revoked usernames can be reclaimed; burned usernames are permanent
- Hono: Lightweight web framework optimized for Cloudflare Workers
- Cloudflare D1: SQLite-based edge database for username registry
- Cloudflare Workers Assets: Static file serving for admin UI
- React + Vite: Admin UI for username management
- NIP-98: HTTP authentication via Nostr event signatures using
@noble/secp256k1 - TypeScript: Type-safe implementation with Cloudflare Workers types
- Node.js 18+
- npm or similar package manager
- Cloudflare account with Workers and D1 enabled
# Install dependencies
npm install
# Apply database migrations locally
npx wrangler d1 migrations apply divine-name-server-db --local# Install admin UI dependencies (first time only)
cd admin-ui && npm install && cd ..
# Build admin UI
npm run build:admin
# Start development server
npm run dev
# Server runs at http://localhost:8787
# Admin UI accessible at http://localhost:8787/admin (no authentication required locally)Note: Rebuild the admin UI (npm run build:admin) after making changes to admin-ui/ code.
# Run tests in watch mode
npm test
# Run tests once
npm test:once# Apply migrations to production database
npx wrangler d1 migrations apply divine-name-server-db --remote
# Deploy worker to Cloudflare
npx wrangler deployClaim a username with NIP-98 authentication.
Authentication: NIP-98 signed HTTP request
Headers:
Authorization: Nostr <base64-encoded-event>
The NIP-98 event must be kind 27235 with:
methodtag matchingPOSTutag matching the full request URL- Timestamp within 60 seconds of current time
Request Body:
{
"name": "alice",
"relays": ["wss://relay.damus.io", "wss://nos.lol"]
}Fields:
name(required): Username to claim (3-20 chars, lowercase alphanumeric)relays(optional): Array of relay URLs (max 50, must be wss:// protocol)
Success Response (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"
}
}Error Responses:
400: Invalid username format or relay validation failed401: Missing or invalid NIP-98 signature403: Username is reserved or burned409: Username already claimed by another pubkey500: Internal server error
NIP-05 identity verification endpoint. Behavior differs based on hostname.
When accessed via subdomain (e.g., https://alice.divine.video/.well-known/nostr.json):
Response (200):
{
"names": {
"_": "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
},
"relays": {
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d": [
"wss://relay.damus.io",
"wss://nos.lol"
]
}
}Returns a single user mapping with underscore (_) name for NIP-05 subdomain verification.
When accessed via root domain (e.g., https://divine.video/.well-known/nostr.json):
Response (200):
{
"names": {
"alice": "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
"bob": "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2"
},
"relays": {
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d": ["wss://relay.damus.io"],
"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2": ["wss://relay.primal.net"]
}
}Returns all active username mappings for the domain.
Headers:
Cache-Control: public, max-age=60
Subdomain profile routing. Proxies to the main Divine.Video application's profile page.
Behavior:
- Active username: Proxies request to
https://divine.video/profile/<npub> - Inactive/missing username: Returns 404 with user-friendly message
- Converts hex pubkey to npub (Bech32) format for profile URL
Example:
- Request:
https://alice.divine.video/ - Proxies to:
https://divine.video/profile/npub180c...
All admin endpoints require Cloudflare Access authentication configured at the edge.
The admin interface is available at /admin and provides a web UI for username management.
Local Development: Access directly at http://localhost:8787/admin (no authentication)
Production: Protected by Cloudflare Access. To add authorized emails:
- Go to Cloudflare Dashboard → Zero Trust → Access → Applications
- Find the application protecting your admin routes
- Edit the policy → Add include → Select "Emails"
- Enter email addresses to authorize
- Save application
Authorized users receive a one-time code via email when accessing the admin UI.
Reserve a username to prevent user claims (e.g., brand protection).
Request Body:
{
"name": "brandname",
"reason": "Brand protection"
}Response (200):
{
"ok": true,
"name": "brandname",
"status": "reserved"
}Revoke or permanently burn a username.
Request Body:
{
"name": "badname",
"burn": true
}Fields:
name(required): Username to revokeburn(optional): If true, permanently burns the name; if false, makes it recyclable
Response (200):
{
"ok": true,
"name": "badname",
"status": "burned",
"recyclable": false
}Directly assign a username to a pubkey, bypassing normal claim flow.
Request Body:
{
"name": "famousviner",
"pubkey": "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
}Response (200):
{
"ok": true,
"name": "famousviner",
"pubkey": "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
"status": "active"
}See migrations/0001_initial_schema.sql for complete schema definition.
Primary table mapping usernames to Nostr pubkeys.
| Column | Type | Description |
|---|---|---|
| id | INTEGER | Primary key, auto-increment |
| name | TEXT | Unique username (3-20 lowercase alphanumeric chars) |
| pubkey | TEXT | Hex-encoded Nostr public key |
| relays | TEXT | JSON array of relay URLs (max 50) |
| status | TEXT | Status: 'active', 'reserved', 'revoked', 'burned' |
| recyclable | INTEGER | Whether name can be reclaimed (0 or 1) |
| created_at | INTEGER | Unix timestamp of creation |
| updated_at | INTEGER | Unix timestamp of last update |
| claimed_at | INTEGER | Unix timestamp when claimed by user |
| revoked_at | INTEGER | Unix timestamp when revoked |
| reserved_reason | TEXT | Admin reason for reservation |
| admin_notes | TEXT | Admin notes about username |
Indexes:
idx_usernames_pubkey_active: Unique partial index ensuring one active username per pubkeyidx_usernames_status: Index on status for fast filtered queries
Protected words that cannot be claimed as usernames.
| Column | Type | Description |
|---|---|---|
| word | TEXT | Reserved word (primary key) |
| category | TEXT | Category: 'system', 'brand', 'protocol', 'app', 'subdomain' |
| reason | TEXT | Human-readable reason for reservation |
| created_at | INTEGER | Unix timestamp of creation |
Indexes:
idx_reserved_words_category: Index on category for fast lookups
See migrations/0002_seed_reserved_words.sql for the initial list of 30+ reserved words.
- active: Currently claimed and in use
- reserved: Admin-reserved, cannot be claimed by users
- revoked: Freed up and reclaimable (recyclable = 1)
- burned: Permanently unavailable (recyclable = 0)
Usernames must meet these requirements:
- Length: 3-20 characters
- Characters: Lowercase letters (a-z) and numbers (0-9) only
- Reserved words: Cannot use system routes, brand names, or protocol terms
- Uniqueness: Each username can only be active for one pubkey at a time
- One per pubkey: Each pubkey can only have one active username
- Auto-revocation: Claiming a new username automatically revokes the old one
Valid examples: alice, bob123, user2024
Invalid examples:
ab(too short)thisusernameiswaytoolong(too long)Alice(uppercase letters)alice_bob(special characters)api(reserved word)
Relay hints are optional but must meet these requirements when provided:
- Protocol: Must use
wss://(secure WebSocket) - Count: Maximum 50 relays per username
- Length: Each relay URL must be ≤200 characters
- Format: Must be valid URLs per URL standard
Valid examples:
wss://relay.damus.iowss://nos.lolwss://relay.primal.net
Invalid examples:
https://relay.com(wrong protocol)ws://relay.com(insecure WebSocket)not-a-url(invalid format)
The Divine Name Server is a standalone Cloudflare Worker that handles three main flows:
User → NIP-98 Signed Request → Worker
↓
Verify Signature
↓
Validate Username
↓
Check Reserved Words
↓
Query D1 for Conflicts
↓
Auto-revoke Old Username
↓
Insert/Update New Claim
↓
Return Profile URLs
User → alice.divine.video/ → Worker
↓
Extract Subdomain
↓
Query D1 by Name
↓
Convert Hex to Npub
↓
Proxy to divine.video/profile/<npub>
↓
Return Profile Page
Nostr Client → /.well-known/nostr.json → Worker
↓
Detect Hostname
↓
Subdomain? → Query Single User
OR
Root? → Query All Active Users
↓
Format NIP-05 Response
↓
Cache for 60 seconds, Return
- Standalone Worker: Independent from main Divine.Video application for scalability
- Edge Database: D1 database for low-latency username lookups
- NIP-98 Auth: Cryptographic proof of key ownership, no session state needed
- Proxy Pattern: Subdomain routing proxies to existing profile pages, avoiding duplication
- Reserved Words: Pre-seeded list protects system routes and brand names
- Status State Machine: Clear state transitions (active → revoked → recyclable)
The Divine Name Server is a single Cloudflare Worker that serves both the Hono API and a React-based admin UI:
How It Works:
- The Worker handles API routes via Hono (
/api/username,/api/admin, etc.) - Static admin UI files are served automatically via Cloudflare Workers Assets
- Configuration in
wrangler.tomlspecifies the assets directory:[assets] directory = "./admin-ui/dist"
Routing Priority:
- Hono routes match first - API endpoints and custom routes
- Static files - If no route matches, serve from
admin-ui/dist/ - SPA fallback - For client-side routing, falls back to
index.html
Request Examples:
GET /→ Hono route → Returns JSON service infoGET /api/username/claim→ Hono route → API endpointGET /admin→ Static assets → Serves React SPAGET /admin/settings→ Static assets → Serves React SPA (client-side routing)
This architecture allows deploying the entire system (API + admin UI) as a single Worker with no separate static hosting needed.
The service provides three NIP-05 identity formats:
-
Standard format:
alice@divine.video- Resolved via
divine.video/.well-known/nostr.json - Works in all NIP-05 compatible clients
- Resolved via
-
Subdomain format:
_@alice.divine.video- Resolved via
alice.divine.video/.well-known/nostr.json - NIP-05 spec compliant using underscore name
- Resolved via
-
Display format:
@alice.divine.video- Clean Bluesky-style display (not directly resolvable)
- Maps to subdomain format for verification
All formats identify the same pubkey and support optional relay hints.
For complete technical design, architecture decisions, and implementation details, see:
docs/plans/2025-11-15-divine-name-server-implementation.md
This plan includes:
- Detailed task breakdown with acceptance criteria
- NIP-98 verification implementation
- Database migration steps
- API endpoint specifications
- Testing strategies
- Deployment procedures
- Cryptographic Authentication: All username claims require valid NIP-98 signatures proving key ownership
- Admin Protection: Admin endpoints protected by Cloudflare Access at the edge
- No Session State: Stateless authentication eliminates session hijacking risks
- Namespace Protection: Reserved words prevent claiming system routes and brand names
- Permanent Burning: Offensive or abusive names can be permanently disabled
- No Hijacking: Database constraints prevent claiming names owned by other pubkeys
- Time-bound Requests: NIP-98 events expire after 60 seconds to prevent replay attacks
MIT