OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop commits / 89a2005

restrict named and navigated targets to hyper content
89a20051a9a78236b196858dfa4be744097c2432
by bigdestiny2 <33146784+bigdestiny2@users.noreply.github.com> · 2026-07-22T23:35:18.000Z

Changed files (9) +54 -76

backend/catalog-safety.cjs +1 -1
……@@ -288,7 +288,7 @@ function sanitizePersonalCatalogEntry (app) {
288288 }
289289 for (const k of Object.keys(draft)) if (draft[k] === undefined || draft[k] === '') delete draft[k]
290290 const out = normalizeCatalogApp(draft)
291 if (!out) throw new Error('App needs a valid 64-hex drive key, hyper:// drive link, pear:// link, or file:// link.')
291 if (!out) throw new Error('App needs a valid 64-hex drive key or hyper:// drive link.')
292292 return {
293293 id: out.id,
294294 name: out.name,
backend/name-record.cjs +5 -5
……@@ -38,9 +38,9 @@ function normalizeRecordTarget ({ driveKey, link, target } = {}) {
3838}
3939
4040// Wire shape: { v, n, k?, s, l? } — version, name, optional drive key
41// (64-hex), monotonic seq, and optional target/launch link. Old key-only records
42// still decode; newer link-only records are accepted when the link scheme is one
43// of the shared app-link schemes. Unknown shapes decode to null.
41// (64-hex), monotonic seq, and optional browsable Hyper link. Old key-only
42// records still decode; link-only records must be Hyper content. Unknown shapes
43// decode to null.
4444function decodeNameRecord (buf) {
4545 if (buf == null) return null
4646 let rec
……@@ -57,7 +57,7 @@ function encodeNameRecord ({ name, driveKey, seq, link, target } = {}) {
5757 if (typeof name !== 'string' || !name) throw new Error('name required')
5858 if (!Number.isInteger(seq) || seq < 0) throw new Error('seq must be a non-negative integer')
5959 const resolved = normalizeRecordTarget({ driveKey, link, target })
60 if (!resolved) throw new Error('target must be a Hyperdrive key or pear://, hyper://, file:// link')
60 if (!resolved) throw new Error('target must be a Hyperdrive key or hyper:// link')
6161 const rec = { v: NAME_RECORD_VERSION, n: name, s: seq }
6262 if (resolved.driveKey) rec.k = resolved.driveKey
6363 if (resolved.link) rec.l = resolved.link
……@@ -92,7 +92,7 @@ const NAME_BINDING_SCHEMA = {
9292 properties: {
9393 name: { type: 'string', maxLength: 253 },
9494 driveKey: { type: 'string', pattern: '^[0-9a-f]{64}$' },
95 link: { type: 'string', maxLength: 300, pattern: '^(?:hyper|pear|file)://.+' },
95 link: { type: 'string', maxLength: 300, pattern: '^hyper://.+' },
9696 binderPubkey: { type: 'string', pattern: '^[0-9a-f]{64}$' },
9797 bindingSig: { type: 'string', pattern: '^[0-9a-f]{128}$' },
9898 seq: { type: 'integer' },
backend/name-registry-ops.cjs +4 -6
……@@ -31,8 +31,7 @@ const HEX64 = /^[0-9a-f]{64}$/i
3131const HEX128 = /^[0-9a-f]{128}$/i
3232const MAX_NAME = 253
3333const MAX_TARGET = 300
34const TARGET_ERROR = 'target must be a Hyperdrive key or pear://, hyper://, file:// link'
35const SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i
34const TARGET_ERROR = 'target must be a Hyperdrive key or hyper:// link'
3635// Total serialized op cap (defense-in-depth vs a writer bloating the log — a real
3736// op is ~700 bytes: name≤253 + normalized/skeleton + a few 64-hex fields). Mirrors
3837// MAX_OP_BYTES in browser-state-ops / autobee-catalog-ops. Oversized → dropped on apply.
……@@ -42,10 +41,9 @@ function normalizeTarget (target) {
4241 const s = String(target || '').trim()
4342 if (!s) return null
4443
45 // Links remain link-shaped so hyper:// paths, pear:// launch targets, and
46 // file:// targets survive intact. For hyper:// links, also surface the drive
47 // key when the shared catalogue normalizer can safely derive it.
48 if (SCHEME_RE.test(s)) {
44 // Only browsable Hyper content can be named. Native application delivery is
45 // explicitly outside the registry and requires a verified local package.
46 if (/^hyper:\/\//i.test(s)) {
4947 const link = normalizeCatalogLink(s)
5048 if (!link || link.length > MAX_TARGET) return null
5149 const key = driveKeyFromHyperLink(link) || null
test/catalog-manager-safety.test.js +17 -23
……@@ -82,21 +82,14 @@ test('catalog JSON normalizer accepts relay items and entries envelopes', () =>
8282 assert.equal(legacyCatalog.apps[0].id, entryKey)
8383})
8484
85test('catalog app normalizer drops unsafe targets and keeps allowed app links', () => {
85test('catalog app normalizer drops unsafe and executable targets', () => {
8686 const driveKey = 'c'.repeat(64)
8787
8888 assert.equal(normalizeCatalogApp({ id: 'bad-key', driveKey: 'not-a-key', name: 'Bad key' }), null)
8989 assert.equal(normalizeCatalogApp({ id: 'bad-link', link: 'javascript:alert(1)', name: 'Bad link' }), null)
9090 assert.equal(normalizeCatalogApp({ id: 'targetless', name: 'No target' }), null)
9191
92 assert.deepEqual(normalizeCatalogApp({ id: 'linked', driveKey: 'not-a-key', link: 'pear://keet', name: 'Keet' }), {
93 id: 'linked',
94 link: 'pear://keet',
95 name: 'Keet',
96 version: '',
97 categories: [],
98 verification: 'unverified'
99 })
92 assert.equal(normalizeCatalogApp({ id: 'linked', driveKey: 'not-a-key', link: 'pear://keet', name: 'Keet' }), null)
10093
10194 const hyper = normalizeCatalogApp({ link: `hyper://${driveKey}/app`, name: 'Site' })
10295 assert.equal(hyper.driveKey, driveKey)
……@@ -109,7 +102,7 @@ test('shared catalog app normalizer builds stable target keys', () => {
109102 id: 'custom-id',
110103 name: ' Demo ',
111104 appKey: driveKey.toUpperCase(),
112 link: ' PEAR://Demo ',
105 link: ` hyper://${driveKey}/demo `,
113106 version: 2,
114107 categories: [' tools ', '', 'p2p']
115108 }, { source: 'hiveindex', catalogKey: 'hiveindex:cat', catalogName: 'Relay Index' })
……@@ -117,14 +110,14 @@ test('shared catalog app normalizer builds stable target keys', () => {
117110 assert.equal(app.id, 'custom-id')
118111 assert.equal(app.name, 'Demo')
119112 assert.equal(app.driveKey, driveKey)
120 assert.equal(app.link, 'pear://Demo')
113 assert.equal(app.link, `hyper://${driveKey}/demo`)
121114 assert.equal(app.version, '2')
122115 assert.deepEqual(app.categories, ['tools', 'p2p'])
123116 assert.equal(app.verification, 'unverified')
124117 assert.equal(app.source, 'hiveindex')
125118 assert.equal(catalogAppStableKey(app), `drive:${driveKey}`)
126119
127 assert.equal(catalogAppStableKey({ link: ' PEAR://Demo ' }), 'link:pear://Demo')
120 assert.equal(catalogAppStableKey({ link: ` hyper://${key('e')}/demo ` }), `drive:${key('e')}`)
128121 assert.equal(catalogAppStableKey({ id: 'only-id' }), 'id:only-id')
129122 assert.equal(catalogAppStableKey({ id: 'site', link: `hyper://${driveKey}/app` }), `drive:${driveKey}`)
130123})
……@@ -134,10 +127,10 @@ test('catalog app normalizer keeps only supported launch types', () => {
134127 assert.equal(normalizeAppType('HYPERSITE'), 'hypersite')
135128 assert.equal(normalizeAppType('desktop'), '')
136129
137 const standalone = normalizeCatalogApp({ name: 'Window App', type: 'standalone', link: 'pear://app' })
130 const standalone = normalizeCatalogApp({ name: 'Hyper App', type: 'standalone', link: `hyper://${key('f')}/app` })
138131 assert.equal(standalone.type, 'standalone')
139132
140 const invalid = normalizeCatalogApp({ name: 'Bad Type App', type: 'desktop', link: 'pear://app' })
133 const invalid = normalizeCatalogApp({ name: 'Bad Type App', type: 'desktop', link: `hyper://${key('e')}/app` })
141134 assert.equal(invalid.type, undefined)
142135})
143136
……@@ -145,13 +138,14 @@ test('personal catalog entry sanitizer accepts link-only apps and rejects target
145138 assert.deepEqual(sanitizePersonalCatalogEntry({
146139 id: 'keet',
147140 name: ' Keet ',
148 link: ' PEAR://keet ',
141 link: ` hyper://${key('c')}/keet `,
149142 categories: [' chat ', '']
150143 }), {
151144 id: 'keet',
152145 name: 'Keet',
153146 description: '',
154 link: 'pear://keet',
147 driveKey: key('c'),
148 link: `hyper://${key('c')}/keet`,
155149 version: '',
156150 author: '',
157151 categories: ['chat']
……@@ -200,7 +194,7 @@ test('backend aggregation dedupes by stable target across all catalog sources',
200194 data: {
201195 name: 'Sheets Cat',
202196 apps: [
203 { id: 'link-low', name: 'Link Low', link: 'PEAR://Same', version: '1.0.0', verification: 'relay-listed' }
197 { id: 'link-low', name: 'Link Low', link: `hyper://${key('1')}/same`, version: '1.0.0', verification: 'relay-listed' }
204198 ]
205199 }
206200 })
……@@ -209,7 +203,7 @@ test('backend aggregation dedupes by stable target across all catalog sources',
209203 data: {
210204 name: 'Hiveindex Cat',
211205 apps: [
212 { id: 'link-high', name: 'Link High', link: 'pear://Same', version: '2.0.0', verification: 'relay-listed' },
206 { id: 'link-high', name: 'Link High', link: `hyper://${key('1')}/same`, version: '2.0.0', verification: 'relay-listed' },
213207 { id: 'same-id', name: 'Second Same Id Different Drive', driveKey: otherDriveKey, version: '1.0.0' }
214208 ]
215209 }
……@@ -225,7 +219,7 @@ test('backend aggregation dedupes by stable target across all catalog sources',
225219 assert.equal(byName['Drive Signed'].iconData, 'data:image/png;base64,aaa')
226220 assert.deepEqual(byName['Drive Signed']._sources.sort(), ['Autobee Cat', 'Hyperbee Cat', 'Hyperdrive Cat'])
227221
228 assert.equal(byName['Link High'].link, 'pear://Same')
222 assert.equal(byName['Link High'].link, `hyper://${key('1')}/same`)
229223 assert.equal(byName['Link High'].catalogKey, 'hiveindex:cat')
230224 assert.deepEqual(byName['Link High']._sources.sort(), ['Hiveindex Cat', 'Sheets Cat'])
231225
……@@ -241,7 +235,7 @@ test('backend aggregation collapses link-only duplicates with different catalog
241235 data: {
242236 name: 'Signed Catalog',
243237 apps: [
244 { id: 'signed-row', name: 'Signed Link App', link: 'PEAR://shared-app', type: 'standalone', version: '1.0.0', verification: 'author-signed' }
238 { id: 'signed-row', name: 'Signed Link App', link: `hyper://${key('2')}/shared-app`, type: 'hypersite', version: '1.0.0', verification: 'author-signed' }
245239 ]
246240 }
247241 })
……@@ -250,7 +244,7 @@ test('backend aggregation collapses link-only duplicates with different catalog
250244 data: {
251245 name: 'Community Catalog',
252246 apps: [
253 { id: 'sheet-row-uuid', name: 'Community Link App', link: 'pear://shared-app', type: 'standalone', version: '9.0.0', verification: 'unverified' }
247 { id: 'sheet-row-uuid', name: 'Community Link App', link: `hyper://${key('2')}/shared-app`, type: 'hypersite', version: '9.0.0', verification: 'unverified' }
254248 ]
255249 }
256250 })
……@@ -259,8 +253,8 @@ test('backend aggregation collapses link-only duplicates with different catalog
259253 assert.equal(apps.length, 1)
260254 assert.equal(apps[0].id, 'signed-row')
261255 assert.equal(apps[0].name, 'Signed Link App')
262 assert.equal(apps[0].link, 'pear://shared-app')
263 assert.equal(apps[0].type, 'standalone')
256 assert.equal(apps[0].link, `hyper://${key('2')}/shared-app`)
257 assert.equal(apps[0].type, 'hypersite')
264258 assert.equal(apps[0].verification, 'author-signed')
265259 assert.deepEqual(apps[0]._sources.sort(), ['Community Catalog', 'Signed Catalog'])
266260})
test/keys.test.js +5 -4
……@@ -63,7 +63,8 @@ test('normalizeUrl canonicalizes URL-bar input', () => {
6363 assert.equal(normalizeUrl(''), null)
6464 assert.equal(normalizeUrl(' '), null)
6565 assert.equal(normalizeUrl('hyper://abc/'), 'hyper://abc/')
66 assert.equal(normalizeUrl('pear://abc'), 'pear://abc')
66 assert.equal(normalizeUrl('pear://abc'), null)
67 assert.equal(normalizeUrl('file:///tmp/app'), null)
6768 assert.equal(normalizeUrl(CATALOG_HEX), `hyper://${CATALOG_HEX}/`)
6869 const z = z32FromHex(CATALOG_HEX)
6970 assert.equal(normalizeUrl(z), `hyper://${z}/`)
……@@ -105,11 +106,11 @@ test('driveKeyFromHyperRef accepts hyper refs, bare keys, and z32 keys only', ()
105106 assert.equal(driveKeyFromHyperRef('pear://app'), null)
106107})
107108
108test('normalizeNameTarget accepts drive keys and allowed app links only', () => {
109test('normalizeNameTarget accepts drive keys and browsable Hyper links only', () => {
109110 assert.equal(normalizeNameTarget(CATALOG_HEX.toUpperCase()), CATALOG_HEX)
110 assert.equal(normalizeNameTarget(' pear://keet '), 'pear://keet')
111111 assert.equal(normalizeNameTarget(`hyper://${CATALOG_HEX}/app`), `hyper://${CATALOG_HEX}/app`)
112 assert.equal(normalizeNameTarget('file:///tmp/app'), 'file:///tmp/app')
112 assert.equal(normalizeNameTarget('pear://keet'), null)
113 assert.equal(normalizeNameTarget('file:///tmp/app'), null)
113114 assert.equal(normalizeNameTarget('javascript:alert(1)'), null)
114115 assert.equal(normalizeNameTarget('https://example.com'), null)
115116 assert.equal(normalizeNameTarget('pear://' + 'x'.repeat(301)), null)
test/name-record.test.js +6 -18
……@@ -11,34 +11,22 @@ test('encode → decode round-trips', () => {
1111 assert.deepEqual(decodeNameRecord(buf), { name: 'keet', driveKey: KEY, seq: 3, link: null, target: KEY })
1212})
1313
14test('decode: valid launch links are normalized and unsafe links are dropped when a key remains', () => {
14test('decode: executable links are dropped when a drive key remains', () => {
1515 const withLink = encodeNameRecord({ name: 'keet', driveKey: KEY, seq: 1, link: 'PEAR://abc' })
16 assert.deepEqual(decodeNameRecord(withLink), {
17 name: 'keet',
18 driveKey: KEY,
19 seq: 1,
20 link: 'pear://abc',
21 target: 'pear://abc'
22 })
16 assert.deepEqual(decodeNameRecord(withLink), { name: 'keet', driveKey: KEY, seq: 1, link: null, target: KEY })
2317 // an unsafe link is ignored (→ key-only), not trusted
2418 const bad = Buffer.from(JSON.stringify({ v: 1, n: 'keet', k: KEY, s: 1, l: 'https://evil.example' }))
2519 assert.deepEqual(decodeNameRecord(bad), { name: 'keet', driveKey: KEY, seq: 1, link: null, target: KEY })
2620})
2721
28test('encode → decode accepts link-only app targets', () => {
29 const pear = decodeNameRecord(encodeNameRecord({ name: 'keet', target: 'pear://abc', seq: 1 }))
30 assert.equal(pear.driveKey, null)
31 assert.equal(pear.link, 'pear://abc')
32 assert.equal(pear.target, 'pear://abc')
33
22test('encode → decode accepts link-only Hyper targets', () => {
3423 const hyper = decodeNameRecord(encodeNameRecord({ name: 'site', target: `hyper://${KEY}/app`, seq: 2 }))
3524 assert.equal(hyper.driveKey, KEY)
3625 assert.equal(hyper.link, `hyper://${KEY}/app`)
3726 assert.equal(hyper.target, `hyper://${KEY}/app`)
3827
39 const file = decodeNameRecord(encodeNameRecord({ name: 'local', target: 'file:///tmp/app', seq: 3 }))
40 assert.equal(file.driveKey, null)
41 assert.equal(file.link, 'file:///tmp/app')
28 assert.throws(() => encodeNameRecord({ name: 'legacy', target: 'pear://abc', seq: 3 }), /target must/)
29 assert.throws(() => encodeNameRecord({ name: 'local', target: 'file:///tmp/app', seq: 3 }), /target must/)
4230})
4331
4432test('decode: rejects wrong version', () => {
……@@ -66,7 +54,7 @@ test('encodeNameRecord validates inputs', () => {
6654
6755test('NAME_BINDING_SCHEMA mirrors the catalogue schema contract', () => {
6856 assert.deepEqual(NAME_BINDING_SCHEMA.properties.verification.enum, ['unverified', 'relay-listed', 'author-signed'])
69 assert.equal(NAME_BINDING_SCHEMA.properties.link.pattern, '^(?:hyper|pear|file)://.+')
57 assert.equal(NAME_BINDING_SCHEMA.properties.link.pattern, '^hyper://.+')
7058 assert.deepEqual(NAME_BINDING_SCHEMA.anyOf, [{ required: ['driveKey'] }, { required: ['link'] }])
7159 assert.ok(NAME_BINDING_SCHEMA.required.includes('binderPubkey'))
7260 assert.ok(NAME_BINDING_SCHEMA.required.includes('bindingSig'))
test/name-registry.test.js +7 -12
……@@ -16,9 +16,7 @@ const owner = (kp) => hex(kp.publicKey)
1616const TARGET_A = 'aa'.repeat(32)
1717const TARGET_B = 'bb'.repeat(32)
1818const TARGET_C = 'cc'.repeat(32)
19const PEAR_LINK = 'pear://keet'
2019const HYPER_LINK = `hyper://${TARGET_A}/app`
21const FILE_LINK = 'file:///tmp/pear-app'
2220// a confusable squat of "paypal": latin p, CYRILLIC а (U+0430), y, p, cyr а, l →
2321// skeleton folds to "paypal" but its normalized form differs.
2422const PAYPAL_SQUAT = 'pаypаl'
……@@ -38,19 +36,12 @@ test('first-claim-wins: the earlier claim in linear order owns the name', () =>
3836 assert.equal(resolve(view, 'alice').target, TARGET_A)
3937})
4038
41test('claims can target allowed app links, while unsafe schemes are rejected', () => {
39test('claims can target browsable Hyper links, while other schemes are rejected', () => {
4240 const a = crypto.keyPair()
4341 assert.deepEqual(ops.normalizeTarget(HYPER_LINK), { target: HYPER_LINK, key: TARGET_A, link: HYPER_LINK, kind: 'link' })
4442 assert.deepEqual(ops.targetToResolution(HYPER_LINK), { key: TARGET_A, link: HYPER_LINK })
45 assert.equal(ops.normalizeTarget(' PEAR://keet ').target, PEAR_LINK)
46 assert.equal(ops.normalizeTarget(FILE_LINK).link, FILE_LINK)
47
48 const view = applyView([
49 ops.claimOp({ name: 'keet', target: PEAR_LINK, owner: owner(a) }, signer(a)),
50 ])
51 const r = resolveFromNames(view.names, 'keet')
52 assert.equal(r.target, PEAR_LINK)
53 assert.equal(ops.targetToResolution(r.target).link, PEAR_LINK)
43 assert.equal(ops.normalizeTarget(' PEAR://keet '), null)
44 assert.equal(ops.normalizeTarget('file:///tmp/pear-app'), null)
5445
5546 assert.throws(
5647 () => ops.claimOp({ name: 'bad', target: 'javascript:alert(1)', owner: owner(a) }, signer(a)),
……@@ -60,6 +51,10 @@ test('claims can target allowed app links, while unsafe schemes are rejected', (
6051 () => ops.claimOp({ name: 'web', target: 'https://example.com', owner: owner(a) }, signer(a)),
6152 /target must be/
6253 )
54 assert.throws(
55 () => ops.claimOp({ name: 'legacy', target: 'pear://keet', owner: owner(a) }, signer(a)),
56 /target must be/
57 )
6358 const badRemote = {
6459 type: ops.CLAIM,
6560 name: 'bad',
ui/lib/keys.js +5 -3
……@@ -121,7 +121,7 @@ export function shortKey (k) {
121121
122122/**
123123 * Normalize URL-bar input to a navigable absolute URL.
124 * Accepts hyper:// keys, pear:// links, https?/http URLs, and bare hostnames
124 * Accepts Hyper content keys, https?/http URLs, and bare hostnames
125125 * (example.com → https://example.com). Bare words without a dot still map
126126 * to hyper:// for the name-resolver path.
127127 */
……@@ -130,13 +130,15 @@ export function normalizeUrl (raw) {
130130 if (!s) return null
131131 if (/^hyper:\/\//i.test(s)) return s
132132 if (/^https?:\/\//i.test(s)) return s
133 // Remote native-app and local-file launch schemes are not browser targets.
134 if (/^(?:pear|file):\/\//i.test(s)) return null
133135 if (/^[0-9a-f]{64}$/i.test(s)) return `hyper://${s.toLowerCase()}/`
134136 if (/^[13-9a-km-uw-z]{52}$/i.test(s)) return `hyper://${s}/`
135137 // Public hostname (optionally with path/port) → https clearnet
136138 if (/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?::\d{1,5})?(?:[/?#].*)?$/i.test(s)) {
137139 return `https://${s.replace(/^\/+/, '')}`
138140 }
139 if (s.includes('/') || s.startsWith('pear://')) return s
141 if (s.includes('/')) return s
140142 return `hyper://${s}`
141143}
142144
……@@ -166,7 +168,7 @@ export function normalizeNameTarget (raw) {
166168 const s = String(raw || '').trim()
167169 if (!s) return null
168170 if (/^[0-9a-f]{64}$/i.test(s)) return s.toLowerCase()
169 if (s.length <= 300 && /^(?:hyper|pear|file):\/\/.+/i.test(s)) return s
171 if (s.length <= 300 && /^hyper:\/\/.+/i.test(s)) return s
170172 return null
171173}
172174
ui/shell.js +4 -4
……@@ -4614,7 +4614,7 @@ function NostrFeedSection ({ rpc, C }) {
46144614 `
46154615}
46164616
4617// --- Name registry (Phase N5) — claim memorable names → drives/app links ----
4617// --- Name registry (Phase N5) — claim memorable names → browsable content ---
46184618// Owner-signed, multi-writer, first-claim-wins with a confusable/homograph guard.
46194619// One form claims a new name or updates (rotates) one you already own; each name
46204620// is shareable as pearname://<name> and resolves in the URL bar.
……@@ -4640,7 +4640,7 @@ function NameRegistrySection ({ rpc, C }) {
46404640 const submit = async () => {
46414641 const n = name.trim()
46424642 const t = normalizeNameTarget(target)
4643 if (!t) { setErr('Enter a 64-hex drive key or pear://, hyper://, file:// link.'); return }
4643 if (!t) { setErr('Enter a 64-hex drive key or hyper:// link.'); return }
46444644 const owned = list.find((e) => e.normalized === n.toLowerCase() || (e.name || '').toLowerCase() === n.toLowerCase())
46454645 setBusy('submit'); setErr('')
46464646 try {
……@@ -4666,12 +4666,12 @@ function NameRegistrySection ({ rpc, C }) {
46664666 <div className="settings-row">
46674667 <div>
46684668 <div className="settings-label">Claim or update a name</div>
4669 <div className="settings-subtle">A memorable name → a drive key or app link. First claim wins; confusable look-alikes are rejected. Re-submitting a name you own updates its target.</div>
4669 <div className="settings-subtle">A memorable name → browsable P2P content. First claim wins; confusable look-alikes are rejected. Re-submitting a name you own updates its target.</div>
46704670 </div>
46714671 </div>
46724672 <div className="tp-row">
46734673 <input className="profile-input" placeholder="name (e.g. alice)" value=${name} onInput=${(e) => setName(e.target.value)} />
4674 <input className="profile-input" placeholder="64-hex key, pear://, hyper://, file://" value=${target} onInput=${(e) => setTarget(e.target.value)} />
4674 <input className="profile-input" placeholder="64-hex key or hyper:// link" value=${target} onInput=${(e) => setTarget(e.target.value)} />
46754675 <button className="btn small primary" onClick=${submit} disabled=${busy != null || !name.trim() || !targetValid}>${busy === 'submit' ? 'Saving…' : 'Save'}</button>
46764676 </div>
46774677 ${list.length > 0 && html`<div className="namereg-list">