# Signed records manifest (BNS records v1)

**Status:** shipped v2 — gateway `GET/POST /api/records/<name>` and
verified `GET /api/dns/<name>` live on `silentmode.st/navigate.st`;
owner-tracking in the indexer live; portal DNS editor + wallet-side
`signMessage` + `signRecordsManifest` shipped. Native DNS server
(UDP :53 → BCDN name → verified records) is a v2 follow-up.
**Related:** `DESIGN-tld-registry.md`, `PROTOCOL.md`, public explainer at
`silentmode.st/sirius-x/docs/#signed-records`. Mirrored to
`site-sirius-x/docs/DESIGN-signed-records-manifest.md` so it rides the
next push-split to the sirius forge.

## Problem

Every records edit today is a chain UPD:
- costs ~1,300 sat + service fee
- caps records at ~150 bytes of payload
- rules out DNS-style records (A/AAAA/MX/TXT/CNAME/NS) at any useful scale
- makes TXT-verification churn (ACME, DKIM, DMARC) actively expensive

We want records to be:
- **free to edit** — no chain tx per change
- **unlimited size** — accommodate whole DNS record sets
- **trustless** — no operator can substitute their own records for the owner's
- **backwards compatible** — existing on-chain `h` / `s3` / `p` / `u` records keep working

## Design

The chain says *who owns the name*. A signed manifest says *what the owner's
records are*. Resolvers verify both.

### The manifest — `_records.json`

Lives at the Sia bucket the name's on-chain `records.s3` already points at,
under the fixed filename `_records.json`. Shape:

```json
{
  "v": 1,
  "name": "bitcoin.cash",
  "seq": 42,
  "updated_at": "2026-09-12T00:00:00Z",
  "dns": {
    "A":     ["1.2.3.4"],
    "AAAA":  ["2001:db8::1"],
    "MX":    [{"pref": 10, "host": "mail.example.com"}],
    "TXT":   ["v=spf1 include:_spf.silentmode.st -all"],
    "CNAME": null,
    "NS":    []
  },
  "meta":  { "note": "optional free-form" },
  "sig":   "H3n… (BCH message signature, base64)"
}
```

**Rules:**
- `v`: schema version, currently `1`.
- `name`: the fully-qualified name the manifest belongs to. Resolver rejects
  a manifest whose `name` field doesn't match the URL it was fetched from.
- `seq`: monotonic integer. Resolvers cache the highest seen; blobs with an
  older or equal `seq` are rejected as replays.
- `updated_at`: ISO-8601 UTC. Human-readable only — verification is on `seq`.
- `dns.*`: DNS record slots. `null` or `[]` means "not set". `A`/`AAAA`/`TXT`
  are string arrays. `MX` is objects `{pref, host}`. `NS` is a string array
  of host names. `CAA` and `SRV` are accepted too.
- `hosts`: optional, v2 — per-subdomain routing and records. See below.
- `meta`: unstructured; ignored by DNS resolvers, useful for portal UI.
- `sig`: BCH-style message signature (same format `libauth.signMessage`
  produces) over the canonical bytes of the envelope (see below).

### v2 — per-host entries (`hosts`)

v1 described the apex and nothing else. A name's subdomains served the folder
their labels spelled out, with no way to change it and no way to give one its
own records. v2 adds one optional block:

```json
"hosts": {
  "www":   { "path": "" },
  "shop":  { "path": "store/v2" },
  "blog":  { "u": "https://blog.example.com" },
  "old":   { "block": true },
  "mail":  { "dns": { "A": ["5.6.7.8"] } },
  "*":     { "u": "https://example.x/" }
}
```

**Keys** are the labels ABOVE the registered name, in DNS order: `www`,
`a.b`, or `*` for everything not named explicitly. Exact match wins over the
wildcard, so a named host opts out of a blanket rule. The apex is never a key
— its content comes from the chain, and letting an off-chain blob redirect
the main site would move the name's trust boundary without the owner spending
a transaction. At most 200 hosts, at most 8 labels deep.

**Fields**, any combination:
- `path` — the folder inside the bucket this host serves, replacing the
  derived one. `""` means the bucket root, which is how `www` is made to
  mirror the apex. Relative, no leading `/`, no `..`.
- `u` — redirect the host to this URL (302). http(s) only, ≤2048 chars.
- `block` — this host returns 404 even if a folder of that name exists.
- `dns` — the same slots as the apex `dns`, for this host.

**Precedence within an entry:** `block` > `u` > `path`. The most restrictive
intent wins, so a host that is somehow both blocked and redirected stays
blocked.

**The default when no entry matches** is unchanged and needs no manifest at
all: the labels above the name, REVERSED, become the folder path — DNS reads
most specific first, a path reads least specific first, so
`chipnet.explorer.aegis.x` and `aegis.x/explorer/chipnet/` are the same page.

**DNS inheritance.** A host with its own `dns` block owns its record set
outright — `www` does not inherit the apex's `MX`, the way classic DNS works.
A host without one inherits the apex, the way everything else on BNS does.
`GET /api/dns/<host>` applies this and reports which host it answered for.

**Compatibility.** A v1 manifest stays valid forever and needs no
republishing; it simply has no `hosts`. A manifest that fails to verify is
treated exactly like no manifest — the name keeps serving from its chain
records rather than failing shut.

**Where it is enforced.** `lib/manifest.js` (`verifyManifest`,
`validateHosts`) is the only definition of validity, shared by the read path,
the write path and `serve()`. `lib/record-picker.js` (`hostOverride`,
`hostAction`, `subFolderFor`) decides which entry governs a request and which
folder it resolves to; it carries the parity invariant with the inline copy in
`TheseusNavigator/main.js`. Tests: `test/manifest-hosts.test.mjs`.

### Canonicalisation and signing

The signable bytes are the JSON serialisation of the manifest **without the
`sig` field**, with:
- keys sorted lexicographically at every level
- no insignificant whitespace (`JSON.stringify` with no `space` arg)
- `\u`-escape every non-ASCII character
- `null` fields OMITTED (`"CNAME": null` becomes absent)

The signature is `sign(sha256(canonical_bytes))` with the wallet key whose
address currently holds the name's NFT certificate.

Verification, at the resolver:
1. Read the name's chain record. Follow `records.s3` to the Sia bucket.
2. Fetch `_records.json` from that bucket.
3. Extract `sig`, recompute `sha256(canonical_bytes)` over the rest.
4. Recover the signing pubkey → derive the CashAddress → compare to the
   current NFT owner address from the chain.
5. Compare `seq` to the cached last-seen `seq` for this name. Reject if not
   strictly greater.
6. If everything matches: cache the new `seq`, apply the DNS records.

Any step failing means "no records" — the resolver falls back to whatever
was already on the chain (`h`, `s3` for content, etc.).

### Endpoints (gateway)

**GET `/api/records/<name>`** — read-through of the signed manifest.
Returns 200 with the JSON if present, 404 if missing, 5xx on Sia errors.
Cacheable for a minute. Anyone can call this; it's public.

**POST `/api/records/<name>`** — write. Body IS the manifest JSON.
The gateway:
1. Verifies the signature matches the current on-chain NFT owner.
2. Verifies `seq` is strictly greater than the last-seen `seq` (from
   `GET`ing the current manifest if any).
3. Uploads the JSON to the name's Sia bucket at `_records.json`.
4. Returns 200 with `{seq, bytes, sia_key}`.

Rate-limit: 1 write / 5 s per name. Refuses if the on-chain record has no
`s3` pointer yet — the name has no storage location, no place to write.

### Precedence and interaction with existing records

The signed manifest **complements** the on-chain records — it doesn't replace
them:
- **Content** (`h`, `s3`, `p`, `u`, `ip`) still comes from chain.
- **DNS records** come from the manifest — chain has never carried them.

A future extension (v2) could allow the manifest to override `h`/`s3`/etc.
for owners who want everything off-chain. Not shipped in v1 because the
existing content model already works.

### Threat model

- **Operator tampers with the manifest.** Signature verification catches it —
  the resolver rejects and falls back to on-chain records.
- **Replay of an old manifest.** `seq` monotonicity catches it — resolvers
  keep the highest-seen `seq`.
- **Owner's key compromised.** Same as chain-side compromise: attacker
  controls the NFT and can sign whatever. Move the name to a new key.
- **Sia object deleted.** The name still resolves via chain records; DNS
  records disappear until the manifest is republished.
- **Gateway hostility.** The gateway signs nothing itself — it only relays
  and enforces `seq` monotonicity. A hostile gateway can refuse to accept
  writes but cannot forge them; users can bypass it entirely by writing
  directly to Sia with their own credentials (v2 flow).

### What ships in v1

- The spec (this doc).
- Gateway `/api/records/<name>` (GET + POST) with signature verification and
  Sia read-through.
- Portal DNS editor that composes a manifest, asks the wallet to sign, and
  POSTs to the gateway.
- Resolver support in Theseus + Ariadne staged behind a flag until the
  portal-side has been in production for a week.

### Shipped in v2

- `hosts`: per-subdomain routing (`path`, `u`, `block`) and per-host `dns`.
- CAA/SRV records.
- Portal editor for both halves, on one signed manifest and one publish.

### Still open

- Direct-to-Sia write flow for users who hold their own S3 credentials.
- Owner-controlled manifest that overrides `h`/`s3`/etc. for the apex.
- HTTPS/SVCB records.
- The public authoritative DNS bridge. Until it exists the `dns` half of the
  manifest is write-only: the daemon's own server (`lib/dns-server.js`)
  answers `A` with the local resolver address and NODATA for everything else,
  and the only consumer of a manifest `A` record is Theseus, as a last resort
  when a name carries no content record.
- Federated resolver caches for `seq` (mitigates the case where two writes
  race and each thinks its `seq` is winning).
