diff --git a/docs/w3bs-net.md b/docs/w3bs-net.md index f6e514f..e51c6b3 100644 --- a/docs/w3bs-net.md +++ b/docs/w3bs-net.md @@ -30,3 +30,11 @@ This event feed is a W3BS Net protocol surface inspired by relay and repository ## Domain routing An active name like `riotcoder.agent` resolves through `riotcoder.agent.w3bs.org`. Configure `W3BS_NET_HOSTED_TLDS` and a DNS wildcard, edge proxy host and TLS certificate for every suffix served here (the launch includes `*.whatever.w3bs.org`, `*.agent.w3bs.org` and `*.hacker.w3bs.org` for Moshpit backfill). If a name has a public HTTPS target, W3BS redirects to it. Otherwise the host serves the W3BS name record. Imported Moshpit names redirect to their source gateway when no local W3BS registration overrides them. + +## Instance DNS records + +W3BS is an independent Moshpit instance: W3BS names, owner ids and DNS writes stay in its database. Primary Moshpit records are read-only discovery backfill, not a grant to edit W3BS names. `/net/records` follows the primary Moshpit records workflow: filter names owned by the signed-in local account, publish AAAA/CNAME/MX/TXT with TTL and MX priority, inspect public values and remove individual records. Pending or suspended names/namespaces cannot publish. CNAME is exclusive with other types. The public read API is `/api/net/v1/dns/records?name=label.namespace`; authenticated mutations are `POST /api/net/v1/names/:name/records` and `DELETE /api/net/v1/names/:name/records/:id`. The existing W3BS session or OAuth bearer authenticates writes. + +A configured Moshpit resolver maps the W3BS realm `label.namespace.w3bs.org` to this instance, querying its resolve and records APIs. It does not treat bare `label.namespace` on the primary network as the same ownership. Active instance records are authoritative within that explicitly configured realm, including over public parking answers; inactive local names cannot be revived through primary-network fallback. A name with no custom address records goes to the W3BS gateway so its existing HTTPS destination can redirect there. Custom AAAA or CNAME records override the gateway address; TXT/MX coexist with its default address. DNS changes publish immediately in the instance; cached resolver/browser answers still follow TTL. + +DNS answers and HTTPS certificates are separate rollout gates. Standard browser access to every new `*.namespace.w3bs.org` requires public DNS and a publicly trusted certificate for that suffix. A generic `*.w3bs.org` certificate does not cover two labels. The primary Moshpit CA intentionally refuses public TLDs and must not be changed to mint arbitrary `.org` certificates to work around missing registrar credentials. Resolver routing is not evidence that public DNS or HTTPS is ready. Preserve that distinction when reporting a new name live. diff --git a/public/net-records.js b/public/net-records.js new file mode 100644 index 0000000..141792b --- /dev/null +++ b/public/net-records.js @@ -0,0 +1,57 @@ +const root = document.querySelector('#dns-names'); +const status = document.querySelector('#dns-status'); +let names = []; +const el = (tag,text,props={}) => Object.assign(document.createElement(tag),{...(text===null?{}:{textContent:text}),...props}); +const api = async(path,init={}) => { + const response=await fetch(path,{...init,headers:{accept:'application/json','content-type':'application/json'},cache:'no-store'}); + const body=await response.json(); + if(!response.ok)throw Error(body.error?.message||'The request failed. Please retry.'); + return body; +}; +const help={AAAA:'IPv6 address, for example 2606:4700:4700::1111',CNAME:'Hostname only, for example box.example.com',MX:'Mail hostname, for example mx.example.com; lower priority wins',TXT:'Verification text or a key, up to 1024 bytes'}; +function render(){ + root.replaceChildren(); + const query=document.querySelector('#dns-filter').value.trim().toLowerCase(); + const match=n=>query.endsWith('.*')?n.name.startsWith(query.slice(0,-1)):n.name.includes(query); + const visible=names.filter(match); + status.textContent=`${visible.length} name${visible.length===1?'':'s'} in your W3BS account.`; + for(const item of visible){ + const card=el('article',null,{className:'net-dns-card'}); + card.append(el('h2',item.name),el('p',`${item.name}.w3bs.org`,{className:'net-help'})); + if(item.status!=='active'||item.namespace_status!=='active'){ + card.append(el('p',`DNS editing waits until both the name and namespace are active. Name: ${item.status}; namespace: ${item.namespace_status}.`,{className:'net-status'}));root.append(card);continue; + } + const table=el('table',null,{className:'net-dns-table'}),head=el('tr',null); + for(const title of ['Type','Value','TTL','Priority',''])head.append(el('th',title)); + const thead=el('thead',null);thead.append(head);table.append(thead); + const tbody=el('tbody',null);table.append(tbody); + const feedback=el('p','',{className:'net-status'});feedback.setAttribute('role','status');feedback.setAttribute('aria-live','polite'); + const perform=async(button,fn)=>{button.disabled=true;feedback.textContent='Saving…';feedback.classList.remove('net-error');try{const data=await fn();item.records=data.records;drawRows();feedback.textContent='Published to this W3BS instance. Resolver caches follow the record TTL.';}catch(error){feedback.textContent=error.message;feedback.classList.add('net-error');}finally{button.disabled=false;}}; + const path=`/api/net/v1/names/${encodeURIComponent(item.name)}/records`; + const drawRows=()=>{ + tbody.replaceChildren(); + for(const record of item.records){ + const row=el('tr',null);for(const value of [record.type,record.value,`${record.ttl}s`,record.type==='MX'?record.priority:'—'])row.append(el('td',String(value))); + const td=el('td',null),button=el('button','Remove',{type:'button',className:'button'}); + button.setAttribute('aria-label',`Remove ${record.type} ${record.value} from ${item.name}`); + button.addEventListener('click',()=>perform(button,()=>api(`${path}/${encodeURIComponent(record.id)}`,{method:'DELETE'}))); + td.append(button);row.append(td);tbody.append(row); + } + if(!item.records.length){const row=el('tr',null),td=el('td','No custom records. The W3BS gateway serves this name by default.');td.colSpan=5;row.append(td);tbody.append(row);} + }; + drawRows(); + const scroll=el('div',null,{className:'net-dns-scroll'});scroll.append(table);card.append(scroll); + const form=el('form',null,{className:'net-dns-form'}); + const field=(label,input)=>{input.setAttribute('aria-label',label);const wrapper=el('label',label);wrapper.append(input);form.append(wrapper);return wrapper;}; + const type=el('select',null,{name:'type'});for(const t of Object.keys(help))type.append(el('option',t,{value:t}));field('Type',type); + const value=el('input',null,{name:'value',required:true,maxLength:1024,placeholder:help.AAAA});field('Value',value); + const ttl=el('input',null,{name:'ttl',type:'number',value:'300',min:'60',max:'86400',required:true});field('TTL (seconds)',ttl); + const priority=el('input',null,{name:'priority',type:'number',value:'10',min:'0',max:'65535'});const priorityLabel=field('MX priority',priority);priorityLabel.hidden=true; + const button=el('button','Publish record',{type:'submit',className:'button primary'});form.append(button); + type.addEventListener('change',()=>{value.placeholder=help[type.value];priorityLabel.hidden=type.value!=='MX';}); + form.addEventListener('submit',async event=>{event.preventDefault();await perform(button,()=>api(path,{method:'POST',body:JSON.stringify({type:type.value,value:value.value,ttl:Number(ttl.value),priority:Number(priority.value)})}));}); + card.append(form,feedback);root.append(card); + } +} +document.querySelector('#net-dns-filter').addEventListener('submit',event=>{event.preventDefault();render();}); +try{names=(await api('/api/net/v1/dns/mine')).names;render();}catch(error){status.textContent=error.message;status.classList.add('net-error');} diff --git a/public/style.css b/public/style.css index c7c0a9d..fc70413 100644 --- a/public/style.css +++ b/public/style.css @@ -2966,3 +2966,5 @@ footer.pfs-footer p { padding: 18px; } } + +.net-tabs{display:flex;gap:20px;flex-wrap:wrap;border-bottom:1px solid var(--line,#333);padding:18px 0}.net-tabs [aria-current=page]{font-weight:700;text-decoration:underline;text-underline-offset:8px}.net-dns-card{border-top:1px solid #424a3b;margin-top:28px;padding:24px 0}.net-dns-scroll{overflow-x:auto}.net-dns-table{border-collapse:collapse;width:100%;text-align:left;font-size:13px}.net-dns-table th,.net-dns-table td{padding:12px 8px;border-bottom:1px solid #424a3b;overflow-wrap:anywhere}.net-dns-table td:nth-child(2){min-width:140px;max-width:420px}.net-dns-form{display:grid;grid-template-columns:110px minmax(150px,1fr) 120px 120px;align-items:end;gap:12px;margin-top:24px}.net-dns-form label{display:grid;gap:8px;font-size:12px}.net-dns-form input,.net-dns-form select{min-width:0;width:100%;padding:12px;font:inherit;background:transparent;color:inherit;border:1px solid #606952}.net-dns-form button{grid-column:1/-1;justify-self:start}.net-dns-form [hidden]{display:none}.net-block pre{max-width:100%;overflow-x:auto}@media(max-width:680px){.net-dns-form{grid-template-columns:1fr}.net-dns-table{font-size:12px}.net-dns-table th,.net-dns-table td{padding:10px 6px}} diff --git a/src/net-records.mjs b/src/net-records.mjs new file mode 100644 index 0000000..0a49a0e --- /dev/null +++ b/src/net-records.mjs @@ -0,0 +1,84 @@ +import { isIP } from 'node:net'; +import { randomUUID } from 'node:crypto'; +import { W3bsError } from './core.mjs'; + +export const RECORD_TYPES = ['AAAA', 'CNAME', 'MX', 'TXT']; +const host = (value) => { + const text = String(value ?? '').trim().toLowerCase().replace(/\.$/, ''); + return text.length <= 253 && text.split('.').length >= 2 && text.split('.').every(l => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(l)) && !isIP(text) ? text : null; +}; +export function normalizeNetRecord(input, name) { + const type = String(input?.type ?? '').toUpperCase(); + if (!RECORD_TYPES.includes(type)) throw new W3bsError('INVALID_RECORD', 'Choose AAAA, CNAME, MX or TXT.', 400); + let value = String(input?.value ?? '').trim(); + if (!value || Buffer.byteLength(value) > 1024) throw new W3bsError('INVALID_RECORD', 'Enter a record value of at most 1024 bytes.', 400); + const ttl = Number(input.ttl ?? 300); + if (!Number.isInteger(ttl) || ttl < 60 || ttl > 86400) throw new W3bsError('INVALID_TTL', 'TTL must be 60–86400 seconds.', 400); + let priority = 0; + if (type === 'AAAA') { + value = value.replace(/^\[|\]$/g, '').toLowerCase(); + if (isIP(value) !== 6 || value.includes('%')) throw new W3bsError('INVALID_RECORD', 'AAAA needs an IPv6 address. Use CNAME for a hostname.', 400); + value = new URL(`http://[${value}]/`).hostname.slice(1,-1); + if (/^(?:::(?:1)?$|::ffff:|f[cd]|fe[89ab]|ff)/i.test(value)) throw new W3bsError('INVALID_RECORD', 'Use a public IPv6 address, not a local, mapped or multicast address.', 400); + } else if (type === 'CNAME' || type === 'MX') { + value = host(value); + if (!value) throw new W3bsError('INVALID_RECORD', `${type} needs a hostname, without https://, a path or a port.`, 400); + if (type === 'CNAME' && [name, `${name}.w3bs.org`].includes(value)) throw new W3bsError('INVALID_RECORD', 'A CNAME cannot point at itself.', 400); + if (type === 'MX') { + priority = Number(input.priority ?? 10); + if (!Number.isInteger(priority) || priority < 0 || priority > 65535) throw new W3bsError('INVALID_PRIORITY', 'MX priority must be 0–65535.', 400); + } + } + return { type, value, ttl, priority }; +} + +export function createNetRecords(db) { + let writes = Promise.resolve(); + const transaction = (fn) => { + const next = writes.then(() => db.transaction(fn)); + writes = next.catch(() => {}); + return next; + }; + const parts = (name) => { + const p = String(name).toLowerCase().split('.'); + if (p.length !== 2 || !p.every(l=>/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(l))) throw new W3bsError('INVALID_NAME','Use label.namespace.',400); + return [p[1],p[0]]; + }; + const rows = async (tx, tld, label) => (await tx.all('SELECT id,type,value,ttl,priority FROM net_dns_records WHERE tld=? AND label=? ORDER BY type,value,priority',[tld,label])); + async function authorize(tx, name, ownerId) { + const [tld,label] = parts(name); + const row = await tx.get('SELECT n.owner_id,n.status,t.status AS namespace_status FROM net_names n JOIN net_tlds t ON t.tld=n.tld WHERE n.tld=? AND n.label=?' + (db.dialect === 'postgres' ? ' FOR UPDATE OF n' : ''),[tld,label]); + if (!row || row.owner_id !== ownerId) throw new W3bsError('NAME_OWNER_REQUIRED','Only the W3BS owner of this name can change its DNS records.',403); + if (row.status !== 'active' || row.namespace_status !== 'active') throw new W3bsError('NAME_NOT_ACTIVE','The name and namespace must be active before publishing DNS records.',409); + return [tld,label]; + } + return { + async owned(ownerId) { + return db.all('SELECT n.label,n.tld,n.status,n.target,t.status AS namespace_status FROM net_names n JOIN net_tlds t ON t.tld=n.tld WHERE n.owner_id=? ORDER BY n.tld,n.label',[ownerId]); + }, + async list(name) { + const [tld,label] = parts(name); + const active = await db.get("SELECT n.label FROM net_names n JOIN net_tlds t ON t.tld=n.tld WHERE n.tld=? AND n.label=? AND n.status='active' AND t.status='active'",[tld,label]); + return active ? rows(db,tld,label) : []; + }, + async put(name, ownerId, input) { + const record = normalizeNetRecord(input,name); + return transaction(async tx => { + const [tld,label] = await authorize(tx,name,ownerId); + const existing = await rows(tx,tld,label); + if (existing.some(r=>r.type==='CNAME' && (record.type!=='CNAME' || r.value!==record.value)) || (record.type==='CNAME' && existing.some(r=>r.type!=='CNAME'))) + throw new W3bsError('CNAME_CONFLICT','CNAME must be the only record on a name. Remove the existing records first.',409); + if (existing.length >= 50 && !existing.some(r=>r.type===record.type && r.value===record.value && r.priority===record.priority)) throw new W3bsError('RECORD_LIMIT','A name can have at most 50 records.',409); + await tx.run('INSERT INTO net_dns_records(id,tld,label,type,value,ttl,priority,created_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(tld,label,type,value,priority) DO UPDATE SET ttl=excluded.ttl',[randomUUID(),tld,label,record.type,record.value,record.ttl,record.priority,new Date().toISOString()]); + return rows(tx,tld,label); + }); + }, + async remove(name, ownerId, id) { + return transaction(async tx => { + const [tld,label] = await authorize(tx,name,ownerId); + await tx.run('DELETE FROM net_dns_records WHERE id=? AND tld=? AND label=?',[String(id),tld,label]); + return rows(tx,tld,label); + }); + }, + }; +} diff --git a/src/net-routes.mjs b/src/net-routes.mjs index 18ac382..13ab861 100644 --- a/src/net-routes.mjs +++ b/src/net-routes.mjs @@ -74,7 +74,7 @@ function registryMarkup(names, { query = '', moshpitError = '' } = {}) { }) .join('')}` : '

No names match this search.

'; - return `
W3BS NET · OPEN DOMAIN FEDERATION

Your namespaces.
Your network.

W3BS Net is an independent registry and event relay. Names registered here are primary on W3BS; opted-in peers receive announcements. Public Moshpit records are imported as backfill and stay attributed to their source.

Register a namespace or nameOperator consoleRegistry API ↗Event relay ↗
LOCAL · PRIMARY

W3BS registrations

Requests are held for moderation. Active namespaces and names are free, never sold, and never reserved for pre-buy.

${rows(local)}
FEDERATION · BACKFILL

Imported names

Moshpit records fill gaps in discovery. W3BS registrations always take precedence when a name exists in both registries.

${moshpitError ? `

${e(moshpitError)}

` : ''}${rows(imported, true)}
`; + return `
W3BS NET · OPEN DOMAIN FEDERATION

Your namespaces.
Your network.

W3BS Net is a separate Moshpit instance with its own registry and event relay. Names registered here are primary on W3BS; opted-in peers receive announcements. Public Moshpit records are imported as backfill and stay attributed to their source.

Register a namespace or nameDNS RecordsOperator consoleRegistry API ↗Event relay ↗
LOCAL · PRIMARY

W3BS registrations

Requests are held for moderation. Active namespaces and names are free, never sold, and never reserved for pre-buy.

${rows(local)}
FEDERATION · BACKFILL

Imported names

Moshpit records fill gaps in discovery. W3BS registrations always take precedence when a name exists in both registries.

${moshpitError ? `

${e(moshpitError)}

` : ''}${rows(imported, true)}
`; } function searchMarkup(query, local, backfill, webResults, error) { @@ -432,6 +432,10 @@ export function createNet({ app.get('/.well-known/w3bs-net.json', (_req, res) => res.json({ specVersion: 'W3BS-NET-1-draft-1', instance: root, + instanceType: 'moshpit', + realm: 'w3bs.org', + dnsRecords: `${root}/api/net/v1/dns/records?name={name}`, + recordTypes: ['AAAA','CNAME','MX','TXT'], primaryRegistry: `${root}/api/net/v1/registry`, resolution: `${root}/api/net/v1/resolve/{name}`, events: `${root}/api/net/v1/events`, @@ -448,6 +452,45 @@ export function createNet({ const registry = await network.registry({ query: req.query.q ?? '', limit: req.query.limit }); res.json(registry); }); + app.get('/api/net/v1/dns/records', async (req,res) => { + res.set('Access-Control-Allow-Origin','*'); + res.set('Cache-Control','no-store'); + const name = safeName(req.query.name); + if (!name) throw new W3bsError('INVALID_NAME','Use label.namespace.',400); + res.json({ instance:root, name, records:await network.dns.list(name) }); + }); + app.get('/api/net/v1/dns/mine', async (req,res) => { + const user = await requireAccount(req); + res.set('Cache-Control','no-store'); + const names = await network.dns.owned(user.id); + res.json({ instance:root, names:await Promise.all(names.map(async n=>({ ...n, name:`${n.label}.${n.tld}`, records:await network.dns.list(`${n.label}.${n.tld}`) }))) }); + }); + app.post('/api/net/v1/names/:name/records', async (req,res) => { + const user = await requireAccount(req); + const name = safeName(req.params.name); + if (!name) throw new W3bsError('INVALID_NAME','Use label.namespace.',400); + res.json({ name, records:await network.dns.put(name,user.id,req.body) }); + }); + app.delete('/api/net/v1/names/:name/records/:id', async (req,res) => { + const user = await requireAccount(req); + const name = safeName(req.params.name); + if (!name) throw new W3bsError('INVALID_NAME','Use label.namespace.',400); + res.json({ name, records:await network.dns.remove(name,user.id,req.params.id) }); + }); + app.get('/net/records', async (req,res) => { + const { user } = await authorized(req); + res.set('Cache-Control','no-store'); + res.type('html').send(shell('DNS Records · W3BS Net','Publish DNS records on names you own in this Moshpit instance.','/net',` +
W3BS NET · MOSHPIT INSTANCE

DNS records.

+

Real records on the names you hold: AAAA for IPv6, CNAME to another hostname, MX for mail and TXT for verification. Changes publish to this W3BS instance immediately; existing resolver caches follow the record’s TTL.

+

W3BS is a separate Moshpit instance. Your names and records belong here; imported primary-network records remain read-only backfill. The public gateway name is name.namespace.w3bs.org.

+
+ ${user ? `

Loading your names…

` : `

Sign in to publish records on names you own in W3BS.

Sign in →
`} +

Publish from a script

Use the existing W3BS OAuth bearer from w3bs login. Primary-network API keys do not grant W3BS ownership.

POST /api/net/v1/names/blue.example/records
+{ "type": "AAAA", "value": "2606:4700:4700::1111", "ttl": 300 }
+
+GET /api/net/v1/dns/records?name=blue.example
`, 'noindex')); + }); app.get('/api/net/v1/resolve/:name', async (req, res) => { res.set('Access-Control-Allow-Origin', '*'); const name = safeName(req.params.name); diff --git a/src/net-store.mjs b/src/net-store.mjs index a52103a..d275fda 100644 --- a/src/net-store.mjs +++ b/src/net-store.mjs @@ -1,3 +1,4 @@ +import { createNetRecords } from './net-records.mjs'; import { createHash, randomUUID } from 'node:crypto'; export const RESERVED_NET_TLDS = new Set(['w3bs', 'www', 'api', 'net', 'search', 'admin', 'root']); @@ -37,6 +38,12 @@ const TABLES = (sequence) => ` PRIMARY KEY (tld, label) ); CREATE INDEX IF NOT EXISTS net_names_status ON net_names(status, tld, label); + CREATE TABLE IF NOT EXISTS net_dns_records ( + id TEXT PRIMARY KEY, tld TEXT NOT NULL, label TEXT NOT NULL, type TEXT NOT NULL, + value TEXT NOT NULL, ttl INTEGER NOT NULL, priority INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, + UNIQUE(tld,label,type,value,priority) + ); + CREATE INDEX IF NOT EXISTS net_dns_name ON net_dns_records(tld,label); CREATE TABLE IF NOT EXISTS net_events ( seq ${sequence}, event_id TEXT NOT NULL UNIQUE, @@ -813,6 +820,7 @@ export function createNetworkStore(db, { instance = 'https://w3bs.org' } = {}) { } return { + dns: createNetRecords(db), ensureDefaults, registry, resolveName, diff --git a/test/net-records.test.mjs b/test/net-records.test.mjs new file mode 100644 index 0000000..36b5133 --- /dev/null +++ b/test/net-records.test.mjs @@ -0,0 +1,58 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {randomUUID} from 'node:crypto'; +import {openStore} from '../src/store.mjs'; +import {startServer} from '../src/server.mjs'; +import {normalizeNetRecord} from '../src/net-records.mjs'; +for(const databaseUrl of [null,...(process.env.W3BS_TEST_DATABASE_URL?[process.env.W3BS_TEST_DATABASE_URL]:[])]){ + test(`instance DNS records enforce local ownership and publish immediately (${databaseUrl?'postgres':'sqlite'})`,async()=>{ + const store=await openStore({path:':memory:',databaseUrl,seed:false}); + const app=await startServer({store,port:0,publicOrigin:'http://localhost:3000'}); + const accounts=store.accounts('http://localhost:3000'); + const suffix=randomUUID().slice(0,8),tld=`zone-${suffix}`,name=`dumb.${tld}`; + const signup=await accounts.startSignup({username:`dns-${suffix}`,email:`dns-${suffix}@example.com`}); + const {user}=await accounts.consumeLink(signup.link),token=await accounts.createSession(user.id); + const base=`http://localhost:${app.server.address().port}`; + const request=(path,method='GET',body,auth=true)=>fetch(base+path,{method,headers:{'content-type':'application/json',...(auth?{cookie:`w3bs_session=${token}`}:{})},...(body?{body:JSON.stringify(body)}:{})}); + try{ + await store.network.requestName({tld,label:'dumb',ownerId:user.id,requestNamespace:true}); + await assert.rejects(()=>store.network.dns.put(name,user.id,{type:'TXT',value:'pending'}),/must be active/); + await store.network.moderate({kind:'namespace',subject:tld,action:'approve',actor:user.id}); + await store.network.moderate({kind:'name',subject:name,action:'approve',actor:user.id}); + const path=`/api/net/v1/names/${name}/records`; + assert.equal((await request(path,'POST',{type:'TXT',value:'secret'},false)).status,401); + await assert.rejects(()=>store.network.dns.put(name,'someone-else',{type:'TXT',value:'hijack'}),/Only the W3BS owner/); + const made=await request(path,'POST',{type:'TXT',value:'é'.repeat(300),ttl:120}); + assert.equal(made.status,200,await made.clone().text()); + const records=(await made.json()).records;assert.equal(records.length,1); + assert.equal((await (await request(`/api/net/v1/dns/records?name=${name}`)).json()).records[0].value,'é'.repeat(300)); + assert.equal((await request(path,'POST',{type:'CNAME',value:'target.example.com'})).status,409); + await request(`${path}/${records[0].id}`,'DELETE'); + const concurrent=await Promise.allSettled([ + store.network.dns.put(name,user.id,{type:'CNAME',value:'target.example.com'}), + store.network.dns.put(name,user.id,{type:'TXT',value:'conflict'}), + ]); + assert.equal(concurrent.filter(r=>r.status==='fulfilled').length,1); + const cname=(await store.network.dns.list(name))[0]; + await request(`${path}/${cname.id}`,'DELETE'); + assert.equal((await request(path,'POST',{type:'MX',value:'mx.example.com',priority:10,ttl:60})).status,200); + assert.equal((await request(path,'POST',{type:'AAAA',value:'2606:4700:4700::1111'})).status,200); + const mine=await(await request('/api/net/v1/dns/mine')).json(); + assert.equal(mine.names.find(n=>n.name===name).records.length,2); + assert.equal((await request('/net/records')).status,200); + assert.match(await(await request('/net/records','GET',undefined,false)).text(),/Sign in to publish/); + await store.network.moderate({kind:'namespace',subject:tld,action:'suspend',actor:user.id}); + assert.deepEqual(await store.network.dns.list(name),[]); + assert.equal((await request(path,'POST',{type:'TXT',value:'suspended'})).status,409); + }finally{await app.close();} + }); +} +test('record validation rejects private addresses, URLs, invalid TTLs and self aliases',()=>{ + for(const bad of [ + {type:'A',value:'127.0.0.1'}, {type:'AAAA',value:'::1'}, {type:'AAAA',value:'::ffff:127.0.0.1'}, + {type:'AAAA',value:'fd00::1'}, {type:'AAAA',value:'fe80::1%eth0'}, {type:'AAAA',value:'fe80::1'}, {type:'CNAME',value:'https://example.com/'}, + {type:'CNAME',value:'dumb.fuck.w3bs.org'}, {type:'MX',value:'mx.example.com',priority:-1}, + {type:'TXT',value:'x',ttl:1}, {type:'TXT',value:'x'.repeat(1025)}, + ])assert.throws(()=>normalizeNetRecord(bad,'dumb.fuck')); + assert.deepEqual(normalizeNetRecord({type:'MX',value:'MX.Example.com.',priority:0},'dumb.fuck'),{type:'MX',value:'mx.example.com',ttl:300,priority:0}); +});