Skip to content

Commit 4fd4fe4

Browse files
committed
Add owner-managed 301 and 302 URL redirects to network records
1 parent cea8e97 commit 4fd4fe4

6 files changed

Lines changed: 133 additions & 11 deletions

File tree

‎docs/w3bs-net.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,18 @@ W3BS is an independent Moshpit instance: W3BS names, owner ids and DNS writes st
3838
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.
3939

4040
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.
41+
42+
### URL redirects
43+
44+
At `/net/records`, each active name you own has a URL redirect form. Enter a complete HTTPS URL (including a path), choose **301 permanent** or **302 temporary**, then save. Existing registration destinations appear here as 302 redirects. Saving/removing gives inline feedback without refreshing the page. A 301 can be remembered by browsers; use 302 while testing.
45+
46+
Authenticated owner API:
47+
48+
```http
49+
PUT /api/net/v1/names/blue.example/redirect
50+
Content-Type: application/json
51+
52+
{"target":"https://example.com/page","status":301}
53+
```
54+
55+
`DELETE` at that same endpoint removes the destination. Only the local owner of an active name in an active namespace can change it. Redirect changes update the resolver record and publish a federation activation event. Redirects and AAAA/CNAME records are mutually exclusive, since those records route visitors away from the gateway. TXT/MX records can coexist. Redirects require working gateway DNS and HTTPS; setting a URL does not provision a public certificate or change registrar DNS.

‎public/net-records.js‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,35 @@ function render(){
5050
const button=el('button','Publish record',{type:'submit',className:'button primary'});form.append(button);
5151
type.addEventListener('change',()=>{value.placeholder=help[type.value];priorityLabel.hidden=type.value!=='MX';});
5252
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)})}));});
53-
card.append(form,feedback);root.append(card);
53+
card.append(form,feedback);
54+
const redirectForm=el('form',null,{className:'net-dns-form'});
55+
const target=el('input',null,{name:'target',type:'url',required:true,maxLength:2048,value:item.target||'',placeholder:'https://example.com/your-page'});
56+
target.setAttribute('aria-label','Destination URL');
57+
const targetLabel=el('label','Destination URL');targetLabel.append(target);
58+
const code=el('select',null,{name:'status'});code.setAttribute('aria-label','Redirect type');
59+
code.append(el('option','301 — Permanent',{value:'301'}),el('option','302 — Temporary',{value:'302'}));code.value=String(item.redirect_status||301);
60+
if(!item.target)code.value='301';
61+
const codeLabel=el('label','Redirect type');codeLabel.append(code);
62+
const save=el('button','Save redirect',{type:'submit',className:'button primary'});
63+
const remove=el('button','Remove redirect',{type:'button',className:'button'});remove.hidden=!item.target;
64+
const message=el('p','',{className:'net-status'});message.setAttribute('role','status');message.setAttribute('aria-live','polite');
65+
const summary=el('p',item.target?`${item.redirect_status} → ${item.target}`:'No URL redirect set.',{className:'net-help'});
66+
const redirectPath=`/api/net/v1/names/${encodeURIComponent(item.name)}/redirect`;
67+
const changeRedirect=async(clear)=>{
68+
save.disabled=true;remove.disabled=true;message.textContent='Saving…';message.classList.remove('net-error');
69+
try{
70+
const data=await api(redirectPath,clear?{method:'DELETE'}:{method:'PUT',body:JSON.stringify({target:target.value,status:Number(code.value)})});
71+
item.target=data.target;item.redirect_status=data.redirectStatus;remove.hidden=!data.target;
72+
target.value=data.target||'';summary.textContent=data.target?`${data.redirectStatus} → ${data.target}`:'No URL redirect set.';
73+
message.textContent=data.target?'URL redirect saved.':'URL redirect removed.';
74+
}catch(error){message.textContent=error.message;message.classList.add('net-error');}
75+
finally{save.disabled=false;remove.disabled=false;}
76+
};
77+
redirectForm.append(targetLabel,codeLabel,save,remove);
78+
redirectForm.addEventListener('submit',event=>{event.preventDefault();changeRedirect(false);});
79+
remove.addEventListener('click',()=>changeRedirect(true));
80+
card.append(el('h3','URL redirect'),el('p','Send visitors to a complete HTTPS URL, including its path. Choose 301 for a permanent move or 302 for a temporary destination. Browsers may remember permanent redirects. AAAA/CNAME records must be removed to use the redirect gateway. The hostname must resolve to the gateway and have a valid HTTPS certificate.',{className:'net-help'}),summary,redirectForm,message);
81+
root.append(card);
5482
}
5583
}
5684
document.querySelector('#net-dns-filter').addEventListener('submit',event=>{event.preventDefault();render();});

‎src/net-records.mjs‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export function normalizeNetRecord(input, name) {
3232
return { type, value, ttl, priority };
3333
}
3434

35-
export function createNetRecords(db) {
35+
export function createNetRecords(db, announceRedirect = async () => {}) {
3636
let writes = Promise.resolve();
3737
const transaction = (fn) => {
3838
const next = writes.then(() => db.transaction(fn));
@@ -54,7 +54,29 @@ export function createNetRecords(db) {
5454
}
5555
return {
5656
async owned(ownerId) {
57-
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]);
57+
return db.all('SELECT n.label,n.tld,n.status,n.target,t.status AS namespace_status,COALESCE(r.status,302) AS redirect_status FROM net_names n JOIN net_tlds t ON t.tld=n.tld LEFT JOIN net_url_redirects r ON r.tld=n.tld AND r.label=n.label WHERE n.owner_id=? ORDER BY n.tld,n.label',[ownerId]);
58+
},
59+
async setRedirect(name, ownerId, target, status = 301) {
60+
if (target !== null) {
61+
let url;
62+
try { url = new URL(target); } catch {}
63+
if (typeof target !== 'string' || target.length > 2048 || !url || url.protocol !== 'https:' || url.username || url.password)
64+
throw new W3bsError('INVALID_TARGET','Enter a complete HTTPS destination URL.',400);
65+
if ([name, `${name}.w3bs.org`].includes(url.hostname.toLowerCase()))
66+
throw new W3bsError('REDIRECT_LOOP','A name cannot redirect to itself.',400);
67+
target = url.href;
68+
if (![301,302].includes(status)) throw new W3bsError('INVALID_REDIRECT_STATUS','Choose 301 permanent or 302 temporary.',400);
69+
}
70+
return transaction(async tx => {
71+
const [tld,label] = await authorize(tx,name,ownerId);
72+
if (target && (await rows(tx,tld,label)).some(r=>['AAAA','CNAME'].includes(r.type)))
73+
throw new W3bsError('REDIRECT_DNS_CONFLICT','Remove AAAA or CNAME records first so this name uses the W3BS redirect gateway.',409);
74+
await tx.run('UPDATE net_names SET target=? WHERE tld=? AND label=?',[target,tld,label]);
75+
if (target) await tx.run('INSERT INTO net_url_redirects(tld,label,status) VALUES(?,?,?) ON CONFLICT(tld,label) DO UPDATE SET status=excluded.status',[tld,label,status]);
76+
else await tx.run('DELETE FROM net_url_redirects WHERE tld=? AND label=?',[tld,label]);
77+
await announceRedirect(tx,name,target,target ? status : 302,ownerId);
78+
return { target, redirectStatus: target ? status : 302 };
79+
});
5880
},
5981
async list(name) {
6082
const [tld,label] = parts(name);
@@ -65,6 +87,8 @@ export function createNetRecords(db) {
6587
const record = normalizeNetRecord(input,name);
6688
return transaction(async tx => {
6789
const [tld,label] = await authorize(tx,name,ownerId);
90+
if (['AAAA','CNAME'].includes(record.type) && (await tx.get('SELECT target FROM net_names WHERE tld=? AND label=?',[tld,label]))?.target)
91+
throw new W3bsError('REDIRECT_DNS_CONFLICT','Remove the URL redirect first before pointing this name at another server.',409);
6892
const existing = await rows(tx,tld,label);
6993
if (existing.some(r=>r.type==='CNAME' && (record.type!=='CNAME' || r.value!==record.value)) || (record.type==='CNAME' && existing.some(r=>r.type!=='CNAME')))
7094
throw new W3bsError('CNAME_CONFLICT','CNAME must be the only record on a name. Remove the existing records first.',409);

‎src/net-routes.mjs‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,10 @@ export function createNet({
417417
`<section class="page-head compact"><div class="eyebrow">W3BS NET · ${e(record.status)}</div><h1>${e(name)}</h1><p>This local W3BS record is not active, so the federation backfill is not used for it.</p><a class="button" href="https://w3bs.org/net">W3BS Net</a></section>`,
418418
));
419419
}
420-
if (record.target) return res.redirect(302, record.target);
420+
if (record.target) {
421+
res.set('Cache-Control', 'no-store');
422+
return res.redirect(record.redirectStatus === 301 ? 301 : 302, record.target);
423+
}
421424
res.set('X-W3BS-Net-Source', record.source === 'w3bs' ? 'w3bs-primary' : 'w3bs-federation');
422425
return res.type('html').send(shell(
423426
name,
@@ -477,19 +480,37 @@ export function createNet({
477480
if (!name) throw new W3bsError('INVALID_NAME','Use label.namespace.',400);
478481
res.json({ name, records:await network.dns.remove(name,user.id,req.params.id) });
479482
});
483+
app.put('/api/net/v1/names/:name/redirect', async (req,res) => {
484+
const user = await requireAccount(req);
485+
const name = safeName(req.params.name);
486+
if (!name) throw new W3bsError('INVALID_NAME','Use label.namespace.',400);
487+
res.set('Cache-Control','no-store');
488+
res.json({ name, ...await network.dns.setRedirect(name,user.id,req.body?.target,req.body?.status ?? 301) });
489+
});
490+
app.delete('/api/net/v1/names/:name/redirect', async (req,res) => {
491+
const user = await requireAccount(req);
492+
const name = safeName(req.params.name);
493+
if (!name) throw new W3bsError('INVALID_NAME','Use label.namespace.',400);
494+
res.json({ name, ...await network.dns.setRedirect(name,user.id,null) });
495+
});
480496
app.get('/net/records', async (req,res) => {
481497
const { user } = await authorized(req);
482498
res.set('Cache-Control','no-store');
483499
res.type('html').send(shell('DNS Records · W3BS Net','Publish DNS records on names you own in this Moshpit instance.','/net',`
484-
<section class="page-head compact"><div class="eyebrow">W3BS NET · MOSHPIT INSTANCE</div><h1>DNS <em>records.</em></h1>
485-
<p>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.</p>
500+
<section class="page-head compact"><div class="eyebrow">W3BS NET · MOSHPIT INSTANCE</div><h1>DNS &amp; <em>redirects.</em></h1>
501+
<p>Send a name to any HTTPS URL with a 301 permanent or 302 temporary URL redirect. Or publish DNS 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.</p>
486502
<p class="net-help">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 <code>name.namespace.w3bs.org</code>.</p>
487503
<nav class="net-tabs" aria-label="Network"><a href="/net">Network</a><a href="/net/register">Register</a><a href="/net/records" aria-current="page">DNS Records</a></nav></section>
488504
${user ? `<section class="net-block"><form id="net-dns-filter" role="search"><label for="dns-filter">Filter your names</label><input id="dns-filter" name="q" placeholder="dumb.fuck, fuck, dumb.*" autocomplete="off"><button class="button" type="submit">Filter</button></form><p id="dns-status" class="net-status" role="status" aria-live="polite">Loading your names…</p><div id="dns-names"></div></section><script type="module" src="/assets/net-records.js"></script>` : `<section class="net-block"><p>Sign in to publish records on names you own in W3BS.</p><a class="button primary" href="/login?next=%2Fnet%2Frecords">Sign in →</a></section>`}
489505
<section class="net-block"><h2>Publish from a script</h2><p>Use the existing W3BS OAuth bearer from <code>w3bs login</code>. Primary-network API keys do not grant W3BS ownership.</p><pre>POST /api/net/v1/names/blue.example/records
490506
{ "type": "AAAA", "value": "2606:4700:4700::1111", "ttl": 300 }
491507
492-
GET /api/net/v1/dns/records?name=blue.example</pre></section>`, 'noindex'));
508+
GET /api/net/v1/dns/records?name=blue.example
509+
510+
PUT /api/net/v1/names/blue.example/redirect
511+
{ "target": "https://example.com/path", "status": 301 }
512+
513+
DELETE /api/net/v1/names/blue.example/redirect</pre></section>`, 'noindex'));
493514
});
494515
app.get('/api/net/v1/resolve/:name', async (req, res) => {
495516
res.set('Access-Control-Allow-Origin', '*');

‎src/net-store.mjs‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ const TABLES = (sequence) => `
4444
UNIQUE(tld,label,type,value,priority)
4545
);
4646
CREATE INDEX IF NOT EXISTS net_dns_name ON net_dns_records(tld,label);
47+
CREATE TABLE IF NOT EXISTS net_url_redirects (
48+
tld TEXT NOT NULL, label TEXT NOT NULL, status INTEGER NOT NULL,
49+
PRIMARY KEY(tld,label)
50+
);
4751
CREATE TABLE IF NOT EXISTS net_events (
4852
seq ${sequence},
4953
event_id TEXT NOT NULL UNIQUE,
@@ -230,8 +234,9 @@ export function createNetworkStore(db, { instance = 'https://w3bs.org' } = {}) {
230234
const safeLimit = Math.max(1, Math.min(200, Number(limit) || 100));
231235
const local = await db.all(
232236
`SELECT n.tld, n.label, n.target, n.status, n.source, n.created_at,
233-
t.policy, n.owner_id
237+
t.policy, n.owner_id, COALESCE(r.status,302) AS redirect_status
234238
FROM net_names n JOIN net_tlds t ON t.tld = n.tld
239+
LEFT JOIN net_url_redirects r ON r.tld=n.tld AND r.label=n.label
235240
WHERE n.status = 'active' AND t.status = 'active'
236241
AND (? = '' OR lower(n.label || '.' || n.tld) LIKE ?)
237242
ORDER BY n.tld, n.label LIMIT ?`,
@@ -281,6 +286,7 @@ export function createNetworkStore(db, { instance = 'https://w3bs.org' } = {}) {
281286
.map(([name, value]) => ({
282287
name,
283288
target: publicTarget(value.event.data?.target),
289+
redirectStatus: value.event.data?.redirectStatus === 301 ? 301 : 302,
284290
source: value.source,
285291
importedAt: value.importedAt,
286292
status: 'federated',
@@ -317,6 +323,7 @@ export function createNetworkStore(db, { instance = 'https://w3bs.org' } = {}) {
317323
tld: row.tld,
318324
label: row.label,
319325
target: row.target,
326+
redirectStatus: row.redirect_status,
320327
status: row.status,
321328
source: 'w3bs',
322329
claimed: Boolean(row.owner_id),
@@ -330,8 +337,9 @@ export function createNetworkStore(db, { instance = 'https://w3bs.org' } = {}) {
330337
async function resolveName(name) {
331338
const [label, tld] = String(name ?? '').toLowerCase().split('.');
332339
const local = await db.get(
333-
`SELECT n.tld, n.label, n.target, n.status, n.owner_id, n.created_at, t.policy
340+
`SELECT n.tld, n.label, n.target, n.status, n.owner_id, n.created_at, t.policy, COALESCE(r.status,302) AS redirect_status
334341
FROM net_names n JOIN net_tlds t ON t.tld = n.tld
342+
LEFT JOIN net_url_redirects r ON r.tld=n.tld AND r.label=n.label
335343
WHERE n.tld = ? AND n.label = ? AND n.status = 'active' AND t.status = 'active'`,
336344
[tld, label],
337345
);
@@ -341,6 +349,7 @@ export function createNetworkStore(db, { instance = 'https://w3bs.org' } = {}) {
341349
source: 'w3bs',
342350
status: 'active',
343351
target: local.target,
352+
redirectStatus: local.redirect_status,
344353
claimed: Boolean(local.owner_id),
345354
createdAt: local.created_at,
346355
};
@@ -374,6 +383,7 @@ export function createNetworkStore(db, { instance = 'https://w3bs.org' } = {}) {
374383
source: remote.source,
375384
status: 'federated',
376385
target: publicTarget(remote.event.data?.target),
386+
redirectStatus: remote.event.data?.redirectStatus === 301 ? 301 : 302,
377387
claimed: Boolean(remote.event.data?.claimed),
378388
importedAt: remote.importedAt,
379389
};
@@ -524,7 +534,7 @@ export function createNetworkStore(db, { instance = 'https://w3bs.org' } = {}) {
524534
}
525535
if (kind !== 'name') return { error: 'invalid_kind' };
526536
const [label, tld] = String(subject).toLowerCase().split('.');
527-
const row = await db.get('SELECT * FROM net_names WHERE tld = ? AND label = ?', [tld, label]);
537+
const row = await db.get('SELECT n.*, COALESCE(r.status,302) AS redirect_status FROM net_names n LEFT JOIN net_url_redirects r ON r.tld=n.tld AND r.label=n.label WHERE n.tld = ? AND n.label = ?', [tld, label]);
528538
if (!row) return { error: 'not_found' };
529539
if (status === 'active') {
530540
const namespace = await db.get('SELECT status FROM net_tlds WHERE tld = ?', [tld]);
@@ -542,6 +552,7 @@ export function createNetworkStore(db, { instance = 'https://w3bs.org' } = {}) {
542552
data: {
543553
name: `${label}.${tld}`,
544554
target: row.target,
555+
redirectStatus: row.redirect_status,
545556
status,
546557
claimed: Boolean(row.owner_id),
547558
},
@@ -820,7 +831,10 @@ export function createNetworkStore(db, { instance = 'https://w3bs.org' } = {}) {
820831
}
821832

822833
return {
823-
dns: createNetRecords(db),
834+
dns: createNetRecords(db, (tx, name, target, redirectStatus, actor) => announce(tx, {
835+
type: 'name.activated', subject: name, actor,
836+
data: { name, target, redirectStatus, status: 'active', claimed: true },
837+
})),
824838
ensureDefaults,
825839
registry,
826840
resolveName,

0 commit comments

Comments
 (0)