Name Zone is a multi-namespace DNS gateway: the verified owner of a blockchain-native name
(currently an Avian Name System name like bob.avn) can manage public DNS records for its
corresponding zone. bob.avn maps 1:1 to bob.avn.zone; the owner of bob.avn may manage
bob.avn.zone and any child hostname under it (e.g. test.bob.avn.zone).
The app never owns names. It only verifies ownership (by signature + a chain-specific ownership lookup) and then writes A/AAAA/CNAME records (plus narrowly-scoped ACME TXT challenges) to a PowerDNS Authoritative server on the owner's behalf.
The DNS management, PowerDNS integration, record validation, transfer handling, and abuse
controls are chain-agnostic. Only ownership verification, signature verification, and branding
differ per chain, so those are the only pieces implemented per namespace — everything else
is shared. Both Avian (.avn → avn.zone) and Radiant (.rxd → rxd.zone, Wave Names)
are fully-working namespaces, each backed by real on-chain ownership lookups and real
signed-message verification (see "Adding a new namespace" below for how a namespace plugs in).
- Next.js 15 (App Router) + TypeScript
- Tailwind CSS + shadcn/ui (Radix primitives)
- Prisma ORM, SQLite by default (Postgres-compatible schema)
- PowerDNS Authoritative HTTP API (server-side only, shared across all namespaces)
- Zod for API input validation
- Vitest for unit tests
A namespace is a static, code-configured chain integration — not a database row. Each one bundles everything that's chain-specific:
interface NamespaceConfig {
key: string; // "avian" | "radiant" - URL segment + internal id
displayName: string; // "Avian Name Zone"
chainName: string; // "Avian"
tld: string; // "avn"
dnsZone: string; // "avn.zone"
logoPath: string;
logoPathDark?: string; // optional separate logo for dark mode
faviconPath: string;
brandColor: string;
addressPlaceholder: string; // e.g. "RAddressOwningYourName" vs. "1AddressOwningYourName"
exampleNames: NamespaceExample[];
enabled: boolean; // false hides/404s the namespace's routes entirely
adapter: OwnershipAdapter;
}src/lib/namespaces/registry.ts exports NAMESPACES and getNamespace(key) (throws
NamespaceNotFoundError for an unknown or disabled key — route handlers turn that into a 404).
All chain-agnostic code (DNS validation, PowerDNS client, reconciliation, the ownership watcher,
the UI) takes a NamespaceConfig as a parameter rather than reading a single global DNS_ZONE.
Everything chain-specific — ownership lookups and signed-message verification — lives behind one interface:
interface OwnershipAdapter {
getNamesByOwner(address: string): Promise<string[]>;
getOwnerAddress(name: string): Promise<string | null>;
verifyOwner(name: string, address: string): Promise<boolean>;
isNameActive(name: string): Promise<boolean>;
buildLoginChallengeMessage(params: { address: string; nonce: string; issuedAt: Date; expiresAt: Date }): string;
verifySignedMessage(address: string, message: string, signatureBase64: string): boolean;
}Signing is part of the adapter (not split out separately) because different chains may use different message-signing schemes, not just different ownership RPCs.
Routing is path-based and namespace-scoped throughout: pages live under /[namespace]/... and
APIs under /api/[namespace]/.... A namespace-segment layout
(src/app/[namespace]/layout.tsx) resolves the namespace, 404s on an unknown/disabled key, sets
data-namespace="<key>" on a wrapper div for CSS theming (see globals.css), and drives
per-namespace metadata (title/favicon/OG image). The root / is a neutral portal listing every
namespace, with disabled ones shown as "coming soon". Session JWTs carry namespace alongside
address, and getSession(namespace) rejects a session minted for a different namespace — an
Avian-authenticated cookie can't be replayed against Radiant; switching namespaces requires a
fresh sign-in.
One shared PowerDNS instance/credential set serves every namespace; each namespace just manages
its own zone on it (ns.dnsZone, passed as a parameter to every PowerDnsClient call).
- Implement
OwnershipAdapterfor the chain (ownership lookups + signed-message verification), undersrc/lib/ownership/<chain>/. Seesrc/lib/ownership/radiant/for a complete example:adapter.tscomposes an ownership-lookup implementation (mock or real) with a signing scheme (message.ts) into a fullOwnershipAdapter, picking a real backend (electrum-provider.ts/wave.ts, an RXinDexer ElectrumX client) over the mock (mock-provider.ts) based on env vars — the same pattern as Avian's RPC vs. mock provider. - Add a
NamespaceConfigundersrc/lib/namespaces/<chain>.tswiring up that adapter, and register it insrc/lib/namespaces/registry.ts. Leaveenabled: falseuntil the adapter is real and branding assets are in place. - Add a
[data-namespace="<key>"](and.dark [data-namespace="<key>"]) override block insrc/app/globals.cssfor its brand palette. - Flip
enabled: trueonce the adapter is real and branding assets are in place. No other code needs to change — pages, API routes, DNS validation, PowerDNS integration, transfer handling, and rate limiting are all already namespace-generic.
- Client requests a login challenge for an address within a namespace
(
POST /api/[namespace]/auth/challenge). The server generates a nonce, stores it, and returns a message to sign, built by that namespace'sadapter.buildLoginChallengeMessage. - The user signs that exact message with the wallet that controls the address (Avian uses a
Bitcoin-derived signed-message scheme —
signmessage <address> <message>). - The client submits the address, message, and base64 signature
(
POST /api/[namespace]/auth/verify). The server verifies the signature viaadapter.verifySignedMessage, then issues an HttpOnly JWT session cookie scoped to that namespace. - Every subsequent DNS API call re-checks, server-side, that the authenticated address
currently owns the name being managed (
adapter.verifyOwner) before touching PowerDNS.
Two opt-in protections guard against a session left open on a public/shared machine (where an attacker has the cookie but not the wallet):
- "This is a shared computer" at sign-in issues a 30-minute session in a browser-session
cookie (no
Max-Age, so it also dies when the browser closes) instead of the usual 12-hour persistent session — seeSHORT_SESSION_DURATION_SECONDSinsrc/lib/auth/session.ts. - "Require a fresh signature before changes" (per-address setting on the Settings page,
stored in
AddressSetting.requireSignedWrites) makes every DNS write additionally require a short-lived step-up cookie, minted only after a fresh wallet signature viaPOST /api/[namespace]/auth/step-up(valid 10 minutes —src/lib/auth/step-up.ts). Write routes callcheckStepUpForWrite, returning403 STEP_UP_REQUIRED; the client (StepUpProvider) opens a confirm-with-wallet dialog and transparently retries. Turning the setting off also requires a fresh signature, so a hijacked session can't simply disable it. The step-up proof is a separate JWT cookie (not a DB timestamp) so it's bound to the browser that actually signed and expires on its own.
There is no standardized browser wallet extension for either chain today, so both
/avian/connect and /radiant/connect offer a "Manual Verification" flow: sign the challenge
with any wallet's Sign Message tool and paste the resulting signature back.
/radiant/connect additionally offers an "Open in Photonic wallet" deep
link (src/lib/ownership/radiant/connect-link.ts), built as a base64url-encoded
SignRequest envelope (protocol, v, t, challenge, origin, app, address) passed to
https://photonic-wallet.com/#/connect?req=... — Photonic's own structured "connect" protocol,
not just a bare signable string. The challenge message itself
(src/lib/ownership/radiant/message.ts) is a single line with no control characters (Photonic's
signer rejects \n/other characters below 0x20) and is prefixed
radiant:wallet-connect:v1:<nonce>:... to match Photonic's "recognized challenge" cosmetic
badge shape.
Only single-label names (<label>.<tld>) can be claimed and managed. Everything deeper is
a hostname the name's owner manages under their zone:
bob.avn (claimable) -> bob.avn.zone
hostname "www" -> www.bob.avn.zone
hostname "api.test" -> api.test.bob.avn.zone
Sub-shaped on-chain names are rejected outright (validateSourceName): whoever registers the
asset APP.BOB.AVN or wave name app.bob on-chain can never manage app.bob.avn.zone through
the app - that hostname belongs exclusively to bob.avn's owner. Without this rule, a stranger
could register a sub-shaped name and plant records inside an existing owner's zone.
The owner of bob.avn may manage bob.avn.zone and any *.bob.avn.zone child, and may
never manage avn.zone itself, the reserved operator hostnames (ns1, ns2, www -
see RESERVED_ROOT_HOSTS in src/lib/dns/constants.ts), or any other owner's zone
(e.g. alice.avn.zone). Reserved labels are also blocked as source names outright: whoever
registers www.avn or www.rxd on-chain still can't manage www.avn.zone/www.rxd.zone
through the app. See src/lib/dns/validation.ts (authorizeFqdnForName) for the
enforcement and src/lib/dns/validation.test.ts for the test coverage of these rules. The same
rules apply verbatim to any other namespace (e.g. Radiant's bob.rxd → bob.rxd.zone) since
they're parameterized on DnsNamespace { tld, dnsZone }.
Supported record types, matching real-world hosting needs (GitHub Pages, Vercel, Netlify, Cloudflare Pages, Firebase, etc. all expect a CNAME) while keeping the zone from becoming a general-purpose DNS free-for-all:
| Type | Allowed? | Notes |
|---|---|---|
| A | Yes | Strict IPv4 |
| AAAA | Yes | Strict IPv6 |
| CNAME | Yes, with rules | See below |
| TXT | _acme-challenge.*, or SPF/DKIM/DMARC on allowlisted names |
ACME auto-expires; email TXT is shape-validated (see "Email records") |
| MX | Allowlisted names only | EMAIL_ALLOWED_NAMES - see "Email records" |
| Others | No | NS, SRV, PTR, CAA, SOA, wildcards - never |
CNAME rules (validateCnameTarget, wouldCreateCnameLoop in src/lib/dns/validation.ts):
- A hostname has either up to one A and one AAAA record, or a single CNAME - never both
kinds at once (
checkRecordLimitsinsrc/lib/dns/limits.ts). - The target must be a syntactically valid hostname, not an IP address, and not
localhost. - If the target falls within the namespace's DNS zone, it must stay inside the caller's own
name's namespace - it can never point at another owner's zone, the root zone apex, or a
reserved hostname (
ns1/ns2/www). - Direct self-reference (
www.bob.avn.zone CNAME www.bob.avn.zone.) is always rejected. Multi-hop loops within the zone are also detected by walking the existing CNAME chain (up toMAX_CNAME_CHAIN_HOPShops) before allowing a new target. - No wildcard CNAMEs.
ACME TXT rules (src/app/api/[namespace]/dns/[name]/acme/route.ts): only under
_acme-challenge.<host>, created through a dedicated "Add SSL Challenge" flow rather than the
general record editor. Multiple values may coexist at the same challenge name (ACME clients
sometimes need more than one during renewals). Every challenge value gets its own expiresAt
(default 24h, selectable up to 7 days) and is deleted automatically - both from PowerDNS and
locally - the next time anything reconciles that name's records (src/lib/dns/reconcile.ts).
General TXT records (anywhere other than _acme-challenge.* or the email shapes below) are
always rejected.
Owning a name lets you host a website; running email under the shared parent zone is a
separate, opt-in capability, because sending mail stakes the whole zone's reputation
(blocklists and mail providers largely score the registrable domain, not the subdomain). So MX
and email-shaped TXT (SPF/DKIM/DMARC) are operator-granted to known names, not
permissionless. The allowlist is EMAIL_ALLOWED_NAMES (comma-separated full source names, e.g.
craigd.rxd,art.rxd); unset means no name can create email records and the DNS surface stays
A/AAAA/CNAME + ACME TXT exactly as before. See src/lib/dns/email.ts.
For an allowlisted name, the general Add Record flow additionally offers:
- MX —
"<priority> <mail-host>"(e.g.10 mail.example.com). Target validated like a CNAME target: a real hostname, not an IP/localhost, and if it falls inside our own zone it must stay within the caller's namespace. Multi-value (up toMAX_MX_PER_HOSTNAME): providers like Migadu publish two MX hosts, so several MX coexist at one hostname. - TXT, host-guided and multi-value (up to
MAX_EMAIL_TXT_PER_HOSTNAME, so SPF can sit alongside several verification tokens at one host; a secondv=spf1is rejected since only one SPF record is valid):v=DMARC1; …only under_dmarcand DKIMp=…only under<selector>._domainkey(both shape-checked); a plain host takes SPF plus the provider verification tokens onboarding needs (hosted-email-verify=,google-site-verification=,MS=, …) — too many formats to allowlist, and email names are operator-trusted, so the plain host is permissive (non-allowlisted names still can't create any TXT here). The two underscore host shapes are the only additions to the underscore-label ban (which otherwise still blocks everything but_acme-challenge). DKIM public keys longer than a single 255-byte DNS string are automatically split into multiple quoted strings. - DKIM via CNAME — some providers (Migadu) delegate DKIM with a CNAME at
<selector>._domainkeypointing to a…._domainkey.<provider>host rather than a TXT key; CNAME targets accept underscore-labelled hostnames for this. Because MX and TXT are multi-value while a CNAME node can hold nothing else, MX/TXT/DKIM-CNAME are managed as add/delete (no in-place edit), like ACME challenges.
Everything else still applies unchanged: the record is still ownership-checked, still can't touch
the apex/reserved names (ns1/ns2/www) or another owner's zone, still passes through the
PowerDNS write guard, is audit-logged, and is disabled on ownership transfer. Email TXT is
permanent (unlike auto-expiring ACME TXT — reconcile distinguishes the two by host).
Operator prerequisite — do this once per zone before enabling anyone, including yourself.
Publish a hard-fail SPF and a reject DMARC at each zone apex so every non-allowlisted subdomain
can't be spoofed by default (allowlisted names override with their own _dmarc):
pdnsutil add-record rxd.zone rxd.zone TXT 3600 '"v=spf1 -all"'
pdnsutil add-record rxd.zone _dmarc.rxd.zone TXT 3600 '"v=DMARC1; p=reject; sp=reject;"'
pdnsutil increase-serial rxd.zone && pdns_control notify rxd.zone
# repeat for avn.zoneNote the app only publishes DNS — an actual mailbox at craig@craigd.rxd.zone still needs a mail
server (mailcow/Mailu/etc.) that the MX points at, with clean reverse DNS on its sending IP.
/[namespace]/lookup (e.g. /avian/lookup, /radiant/lookup) is a public, no-login page for
looking up the current active DNS records of any name in that namespace — a plain HTML <form>
submitting ?name=... back to itself (no client JS required), rendering whatever ACTIVE,
non-ACME-challenge DnsRecord rows exist for that claimed name. Linked from the nav bar
regardless of session state.
Every subdomain is run by whoever owns the name on-chain, so an official-sounding name
(brand.avn) isn't automatically the project's. VERIFIED_TEAM_NAMES (JSON map of full source
name → project-held owner address — see .env.example) marks the genuinely official ones. A name
counts as verified only while its current owner matches the configured address, checked
against the ClaimedName cache (kept honest by per-visit ownership checks plus the background
watcher) — selling the asset drops the badge automatically, within one watcher sweep at worst.
(The badge says "Official", deliberately not "Core team" — Radiant is a loose group of
contributors rather than a formal team, and "official" describes the project's endorsement
without implying an org chart. A bare "Verified" would be worse: every name here is
ownership-verified; this badge marks the project's own names.)
Surfaces (src/lib/verified-names.ts): an "Official" badge on the lookup page, a public
registry at /[namespace]/official (nav item + landing-page card appear once configured), and
GET /api/[namespace]/verified (JSON, cacheable) for explorers/wallets. Deliberately no
embeddable badge widget — a badge rendered by the site being verified is just copyable pixels;
the help page instead documents linking to the name's own lookup page ("verify this site"),
where the badge is rendered by this service and can't be faked.
Each namespace's own DNS zone apex (avn.zone, rxd.zone by default, or whatever
AVIAN_DNS_ZONE/RADIANT_DNS_ZONE are set to) can serve that namespace directly at / instead
of requiring the /avian or /radiant path segment — visiting avn.zone/connect transparently
serves what's at /avian/connect, with no visible redirect. This is handled by
src/middleware.ts, which reads the Host header and rewrites unprefixed paths to
/<namespace>/<path>. It deliberately maintains its own small host→namespace map rather than
importing src/lib/namespaces directly, since that module composes each namespace's full
OwnershipAdapter at import time — for Radiant that pulls in RadiantElectrumClient's
node:net TCP socket, a Node built-in unsupported in the Edge Runtime middleware executes in.
Keep NAMESPACE_BY_HOST in middleware.ts in sync by hand if AVIAN_DNS_ZONE/RADIANT_DNS_ZONE
ever change.
Routes under /api/internal/* are for co-located callers only and must not be reachable from
the public internet. Three things use them:
| Endpoint | Caller | Auth |
|---|---|---|
GET /api/internal/tls/authorize |
Caddy's on_demand_tls ask |
optional REDIRECT_TLS_AUTH_SECRET |
GET /api/internal/names |
a companion app | INTERNAL_API_SECRET (required) |
GET/POST/DELETE /api/internal/sites |
a companion app | INTERNAL_API_SECRET (required) |
Every one of those addresses the app directly — 127.0.0.1:3000, or its private address over a
LAN/VPN — so nothing legitimate reaches these routes through a public hostname. sites writes
DNS. The shared secret is meant to be the inner layer, not the only one.
deploy/Caddyfile ships a (block_internal) snippet, and deploy/nginx.conf.example the
equivalent location ^~ /api/internal/. Use whichever matches your setup — and use one in each
front end you run.
It is entirely reasonable to split them:
rxd.zone, avn.zone -> nginx -> 127.0.0.1:3000 # management app
x.craigd.rxd.zone -> Caddy -> 127.0.0.1:3000 # on-demand redirect hosts
possibly on different IP addresses. In that arrangement a block in Caddy protects only the
hostnames Caddy serves and says nothing whatsoever about nginx's. The failure is quiet and
convincing: the Caddy config is correct, caddy validate passes, the route is present in
localhost:2019/config/, and the path stays open — because those requests never reach Caddy.
Two things worth knowing before debugging that:
- A Caddy snippet does nothing until it is imported. Defining
(block_internal)and forgettingimport block_internalin a site block leaves that host unprotected, and Caddy starts cleanly with no warning. caddy validateonly reads the file. Applying it takescaddy reload --config /etc/caddy/Caddyfile.
Identify who is really answering before editing anything further:
curl -sI https://rxd.zone/api/internal/nonexistent | grep -i '^server:'
curl -s -o /dev/null -w '%{http_code}
' https://rxd.zone/api/internal/nonexistent # want 403Blocking the path publicly never breaks on-demand TLS — Caddy's ask goes straight to
127.0.0.1:3000, bypassing every site block.
The zone apex (rxd.zone) has no UrlRedirect row, so the authorize endpoint denies
certificate issuance for it — correctly, since that endpoint exists to gate redirect hostnames.
If your own hostnames fall through to the on-demand catch-all instead of having their own site
block, TLS works until the certificate needs re-issuing and then fails. Serve them from a block
with normal ACME (the {$MANAGEMENT_HOSTS} block in deploy/Caddyfile, or an nginx server block
with a certbot certificate).
An app using both the sign-in handoff and the internal API needs two addresses: the public URL
the browser is redirected to for sign-in, and a private address for its own server-to-server
calls. Collapsing them into one works only while /api/internal/* is still publicly reachable —
and then breaks the moment that is fixed. See NAMEZONE_INTERNAL_URL in the Wave Creator repo.
- Hostnames beginning with
_are rejected, with narrow exceptions:_acme-challenge(and_acme-challenge.<host>) paired with TXT; and, for email-allowlisted names only, a leading_dmarcand_domainkeyas the second label (DKIM) - see "Email records". - MX and SPF/DKIM/DMARC TXT records can only be created by names on the
EMAIL_ALLOWED_NAMESallowlist; every other name is A/AAAA/CNAME + ACME TXT only. - A namespace's own DNS zone apex and its reserved hostnames (
ns1/ns2/www) can never be modified, and the corresponding source names (www.avn,www.rxd, ...) can't be claimed or managed at all - even by their genuine on-chain owner (RESERVED_ROOT_HOSTSinsrc/lib/dns/constants.ts). Enforced twice, independently: at the API authorization layer (authorizeFqdnForName) and again inside the PowerDNS client itself, whosepatchZonethrows on any write targeting the apex, a reserved name (or child of one), or anything outside the zone being patched - so even a bug upstream can't produce such a write. - One name owner can never touch another owner's namespace (including as a CNAME target), even across different namespaces.
- IPv4/IPv6 values are strictly validated; Unicode/punycode names are rejected for MVP.
- Empty labels, double dots, labels over 63 chars, and full names over 253 chars are rejected.
- A/AAAA/CNAME records use a fixed TTL of 300 seconds; ACME TXT records use a short 60s TTL and an expiry (default 24h, max 7 days).
- Per-name limits: max 10 hostnames; per hostname either a single CNAME, or a mix of up to 4 A + 4 AAAA (multiple IPs for apex hosts like GitHub Pages) plus (for email-allowlisted names) up to 5 MX and 10 email TXT; max 10 active ACME TXT challenges. A CNAME is mutually exclusive with all other types at the same hostname.
- On ownership transfer, every DNS record for that name is disabled (removed from PowerDNS, kept locally only for audit history) before the new owner can manage anything - see "Ownership transfer handling" below.
- DNS writes are rate-limited per namespace + address (
RATE_LIMIT_DNS_WRITES_PER_MINUTE, default 20/min). Auth challenge/verify requests are rate-limited both per address and per IP (the address alone is attacker-chosen and trivially rotated); expired login challenges and closed rate-limit windows are purged by the cron sweep so unauthenticated endpoints can't grow the database without bound. - Login challenges are single-use (claimed with an atomic conditional write, so concurrent verifies of the same challenge can't both succeed), expire after 5 minutes, and must match a server-issued message exactly.
- All responses carry
frame-ancestors 'none'/X-Frame-Options: DENY(anti-clickjacking),X-Content-Type-Options: nosniff, and a strict referrer policy (seenext.config.ts). - Every create/update/delete is written to an audit log (namespace, address, name, action, old/new value, IP, user agent, timestamp).
- The PowerDNS API key is only ever read server-side (
src/lib/powerdns/client.ts); it is never sent to the browser.
DNS records are tied to current on-chain ownership, not to whoever originally created them. If
craigd.avn transfers to someone else, every DNS record under craigd.avn.zone (A/AAAA/CNAME,
plus any live ACME TXT challenges) is disabled - removed from PowerDNS so it stops
resolving immediately - not silently handed to the new owner. This matters because the old
owner's records might point at their GitHub Pages/Vercel account, their own servers, or active
SSL challenges; auto-inheriting them would be a real security/trust problem for the new owner's
visitors. The new owner starts from a clean slate and must explicitly recreate whatever they
need.
Disable, not delete: disabled records are removed from PowerDNS (publicly indistinguishable
from deleted - no DNS response either way) but the local rows are kept with
status: DISABLED and disabledReason: OWNERSHIP_CHANGED for audit/history, rather than
hard-deleted. Every audit log entry for a disable event uses a dedicated DISABLE action,
distinct from a user-initiated DELETE. (Genuine user-initiated single-record deletes and
expired-ACME-challenge cleanup remain simple hard deletes - the audit log already captures
their history.)
Three enforcement points, matching how this is normally described for this kind of system:
-
On every authenticated visit -
requireClaimedNameOwnership(src/lib/ownership/sync.ts) re-verifies the session address against the namespace's liveOwnershipAdapteron every dashboard/API request, not just at login. If the cached owner differs from the verified current owner,syncClaimedNameOwnershipdisables the previous owner's records before anything else happens, then reactivates the name (statusACTIVE) for the verified new owner. Because reaching this code at all already required a valid signed session and a fresh on-chain ownership check, this reactivation doesn't need a separate "claim" step - logging in and visiting the page already proves it. -
Before every DNS write - the same
requireClaimedNameOwnershipcheck runs at the top of every record-mutating API route, so a stale open browser session can't be used to edit DNS after the name has actually changed hands. -
A background watcher, for the case nobody ever revisits the app after a transfer -
sweepAllClaimedNamesForOwnershipChanges(src/lib/dns/ownership-watcher.ts) re-checks every tracked name's current on-chain owner across all enabled namespaces (viaadapter.getOwnerAddress) and disables records proactively when it finds a mismatch. Rows belonging to an unknown or disabled namespace are skipped defensively rather than erroring. Since there's no signed session driving this path, the name is markedTRANSFERRED(notACTIVE) - the new owner still has to log in normally to manage DNS, which is what flips it back toACTIVEvia the samesyncClaimedNameOwnershipused by the live path.This isn't a background process baked into the app - Next.js doesn't have a built-in long-running worker, and an in-process
setIntervalwould misbehave across multiple instances/serverless. Instead, it's exposed asPOST /api/cron/verify-ownership(one global endpoint sweeping every namespace, not one per namespace), protected by a shared secret (CRON_SECRET, compared with a timing-safe check), meant to be triggered every 5-10 minutes by whatever scheduler your deployment already has: system cron, a systemd timer, Vercel Cron, a GitHub Actions schedule, etc. Example crontab entry:*/10 * * * * curl -fsS -X POST https://your-domain.example/api/cron/verify-ownership \ -H "Authorization: Bearer $CRON_SECRET"The route returns 503 if
CRON_SECRETisn't configured, so it can't accidentally run unauthenticated.The same sweep also does database housekeeping: it purges expired unverified login challenges (and verified ones older than 30 days) plus closed rate-limit windows, both of which are minted by unauthenticated endpoints and would otherwise grow without bound. This is another reason to actually wire the cron up, even with only one namespace in use.
Not implemented: the on-chain block height/txid of the ownership-changing transaction isn't
recorded. Surfacing that would need additional Avian RPC calls this scaffold doesn't make yet
(e.g. resolving which transaction moved the <NAME>.AVN! owner token) - transferredAt /
previousOwnerAddress capture when and from whom, just not the on-chain proof, which would
be a reasonable follow-up for a real audit trail.
npm install
cp .env.example .env # then fill in AUTH_SECRET, AVIAN_MOCK_OWNERS, etc.
npx prisma migrate dev
npm run devOpen http://localhost:3000 for the namespace portal, or go straight to
http://localhost:3000/avian. With POWERDNS_API_URL/POWERDNS_API_KEY left unset, the
PowerDNS client runs in dry-run mode: it logs the PATCH payload it would have sent instead
of making a real HTTP call, so you can exercise the full app without a PowerDNS instance.
createDefaultAvianLookup() (src/lib/ownership/avian/adapter.ts) picks the provider
automatically:
-
If
AVIAN_RPC_URL,AVIAN_RPC_USER, andAVIAN_RPC_PASSWORDare all set, it usesAviandRpcAnsOwnershipProvider(src/lib/ownership/avian/rpc-provider.ts) — real on-chain lookups against anaviand(Avian Core) node. -
Otherwise it falls back to
MockAnsOwnershipProvider, resolved viaAVIAN_MOCK_OWNERS, a JSON map of ANS name to owning Avian address:AVIAN_MOCK_OWNERS={"bob.avn":"RAddressOwningBobDotAvn","alice.avn":"RAddressOwningAliceDotAvn"}To sign in as
bob.avn's owner locally, you need a keypair whose address matches the value you configured, and the ability to produce asignmessage-style signature for it (e.g. via Avian Core'ssignmessageRPC, orsignBitcoinStyleMessageinsrc/lib/ownership/signmessage.tswith prefixRaven Signed Message:\n— see "Known scaffolding limitations" below).
Both implement the same AvianOwnershipLookup interface
(src/lib/ownership/avian/adapter.ts), which createAvianAdapter wraps into a full
OwnershipAdapter (adding Avian's signing scheme) — swapping the underlying lookup requires no
changes anywhere else in the app.
ANS names are Ravencoin-style assets. Registering bob.avn issues the on-chain asset
BOB.AVN, which — per standard Ravencoin/Avian asset semantics — automatically mints a paired,
always-quantity-1, non-divisible owner token asset BOB.AVN! to the issuer. Whoever holds
the <NAME>.AVN! owner token controls DNS for that name (confirmed convention — not the base
asset):
verifyOwner/isNameActivecalllistaddressesbyasset "<NAME>.AVN!"and check the (single, protocol-guaranteed-unique) current holder.getNamesByOwnercallslistassetbalancesbyaddress <address>and returns every<name>.AVN!owner token the address holds, filtered to only DNS-safe names.
The node must run with -assetindex=1 (or assetindex=1 in avian.conf) for these RPCs
to exist. Configure the connection via AVIAN_RPC_URL / AVIAN_RPC_USER /
AVIAN_RPC_PASSWORD (see .env.example).
createDefaultRadiantLookup() (src/lib/ownership/radiant/adapter.ts) picks the provider the
same way Avian does:
- If
RADIANT_ELECTRUMX_HOSTandRADIANT_ELECTRUMX_PORTare both set, it usesElectrumRadiantOwnershipLookup(src/lib/ownership/radiant/electrum-provider.ts) — real lookups against an RXinDexer instance (an ElectrumX fork/extension understanding Radiant's Wave/Glyphs name protocol) over a plain TCP, newline-delimited JSON-RPC 2.0 socket (not HTTP) — seeelectrum-client.ts. - Otherwise it falls back to
MockRadiantOwnershipLookup, resolved viaRADIANT_MOCK_OWNERS(same JSON-map shape asAVIAN_MOCK_OWNERS, but with Bitcoin-style addresses).
Radiant addresses are standard Bitcoin-style base58check P2PKH (src/lib/ownership/radiant/address.ts).
RXinDexer identifies an address by its own hashX — the first 11 bytes of
sha256(<P2PKH scriptPubKey>), hex-encoded, NOT the reversed 32-byte scripthash format used by
mainline Electrum wallets, and NOT invertible back to an address
(hashXFromRadiantAddress in src/lib/ownership/radiant/wave.ts).
getNamesByOwnercallswave.reverse_lookup(hashX, limit)and extracts each hit's name, falling back to aglyph.get_tokencall per-hit when a hit's name isn't present directly (some hits only carry a glyphref, not a resolved name).getOwnerAddress/verifyOwner/isNameActivecallwave.resolve(name)— note this raw JSON-RPC method wants the bare label without the.rxdsuffix (wave.resolve("craigd"), not"craigd.rxd"— the suffixed form errors withInvalid character: .), unlike RXinDexer's REST API mirror of the same method, which silently strips the suffix itself. The resolved owner's address comes back astarget, notzone.address(a separate, arbitrary user-settable DNS-style field on the name, not guaranteed to match the current owner).
Mirroring AviandRpcAnsOwnershipProvider's conventions: getOwnerAddress never catches errors
(a thrown error must not be conflated with a confirmed "no owner" — see "Ownership transfer
handling" for why that distinction matters to the background watcher), while verifyOwner /
isNameActive fail closed (return false) on any error, since denying access under uncertainty
is the safe default.
Edit prisma/schema.prisma:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}Set DATABASE_URL to a Postgres connection string and run npx prisma migrate dev.
npm run test # vitest: DNS validation, hostname rules, record limits, authorization, namespace registry
npm run build # production build + typecheckUnit tests live in src/lib/**/*.test.ts and cover: the source-name-to-FQDN mapping, hostname
label rules (underscore/wildcard/length/double-dot rejection, plus the narrow _acme-challenge
exception), strict IPv4/IPv6 validation, CNAME target validation and loop detection, namespace
authorization (an owner's own zone and children vs. root zone / ns1 / ns2 / other owners), the
per-name record/hostname/ACME limits, the namespace registry (resolving both namespaces, 404-ing
on unknown/disabled ones), Avian signature verification against a real captured signature, and
Radiant's address validation, message-challenge shape, and Wave name lookups (including
regression fixtures captured from a real RXinDexer instance — see
src/lib/ownership/radiant/wave.test.ts).
src/
middleware.ts Custom-domain routing (avn.zone/rxd.zone -> /avian, /radiant)
app/
page.tsx Neutral portal listing all namespaces
[namespace]/
layout.tsx Resolves namespace, sets data-namespace, per-namespace metadata
page.tsx Namespace landing page
connect/ Wallet connect / manual signature verification
lookup/ Public, no-login DNS record lookup
dashboard/ List of owned names in this namespace
dashboard/[name]/ DNS record management for one name
settings/ Session info + recent audit log
api/
[namespace]/
auth/challenge, verify, session, logout
names
dns/[name] GET records (A/AAAA/CNAME/ACME TXT, reconciled with PowerDNS)
dns/[name]/records POST (create/replace A/AAAA/CNAME), DELETE
dns/[name]/acme POST (add ACME TXT challenge), DELETE (remove one value)
audit
cron/verify-ownership POST - background ownership watcher, all namespaces (CRON_SECRET-protected)
lib/
namespaces/ NamespaceConfig/OwnershipAdapter types, registry, avian.ts, radiant.ts
dns/ validation.ts, limits.ts, constants.ts (the security core),
reconcile.ts (PowerDNS sync, ACME expiry, transfer disable),
ownership-watcher.ts (periodic transfer sweep, all namespaces)
ownership/
avian/ Avian OwnershipAdapter: RPC provider, mock provider, message signing
radiant/ Radiant OwnershipAdapter: RXinDexer/ElectrumX (wave.ts,
electrum-client.ts, electrum-provider.ts), mock provider,
address validation, message signing, Photonic connect link
sync.ts, names-for-owner.ts Namespace-generic ownership sync / dashboard listing
auth/ challenge/session (JWT, namespace-scoped) helpers
powerdns/ PowerDNS Authoritative API client (zone passed per call)
rate-limit.ts, audit.ts, api-schemas.ts, api-error.ts
prisma/
schema.prisma UserSession, ClaimedName (namespace + status/transfer tracking),
DnsRecord (namespace + status/disabledReason), AuditLog, RateLimitBucket
- Avian signature verification uses a Bitcoin-derived
signmessagescheme with prefixRaven Signed Message:\n(src/lib/ownership/avian/message.ts) — Avian is a Ravencoin fork that kept the original message magic string rather than rebranding it (confirmed against Avian Core'ssrc/common/signmessage.cpp). Override viaAVIAN_MESSAGE_PREFIXif a future Avian Core release changes this. Signing/verification is implemented in-house on@noble/curves(src/lib/ownership/signmessage.ts, shared with Radiant) rather than the unmaintainedbitcoinjs-message→ellipticchain; compatibility is regression-pinned against a real signature captured from an Avian Core node (src/lib/ownership/avian/message.test.ts) plus magic-hash vectors captured frombitcoinjs-messagebefore its removal. - No browser wallet extension integration — neither Avian nor Radiant has a standardized browser wallet extension today, so manual verification (paste a signed message) is the only supported sign-in path for both, plus Radiant's Photonic deep link.
- The aviand-RPC ownership provider makes two RPC round trips per DNS API request
(
isNameActive+verifyOwner, each callinglistaddressesbyasset); fine for a local node, but worth caching if the node is remote or under load. - Rate limiting uses a simple database-backed fixed window, sufficient for a single instance; swap for a Redis-backed limiter if scaling out.
- PowerDNS reads are served from the local Prisma-mirrored
DnsRecordtable (kept in sync on every write) rather than querying PowerDNS live, to avoid a round trip on every dashboard load. NEXT_PUBLIC_APP_URLis a single fixed value (used only formetadataBaseinsrc/app/layout.tsx, to resolve relative OG image/icon paths into absolute URLs) — it isn't derived per-request from the custom domain that served the page, so bothavn.zoneandrxd.zoneresolve their OG images against whichever one URL is configured here. Harmless today since the underlying static assets are identical either way, but worth knowing if that ever needs to vary by domain.