| … | … | @@ -288,7 +288,7 @@ function sanitizePersonalCatalogEntry (app) { |
| 288 | 288 | } |
| 289 | 289 | for (const k of Object.keys(draft)) if (draft[k] === undefined || draft[k] === '') delete draft[k] |
| 290 | 290 | 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.') | |
| 292 | 292 | return { |
| 293 | 293 | id: out.id, |
| 294 | 294 | name: out.name, |
| … | … | @@ -38,9 +38,9 @@ function normalizeRecordTarget ({ driveKey, link, target } = {}) { |
| 38 | 38 | } |
| 39 | 39 | |
| 40 | 40 | // 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. | |
| 44 | 44 | function decodeNameRecord (buf) { |
| 45 | 45 | if (buf == null) return null |
| 46 | 46 | let rec |
| … | … | @@ -57,7 +57,7 @@ function encodeNameRecord ({ name, driveKey, seq, link, target } = {}) { |
| 57 | 57 | if (typeof name !== 'string' || !name) throw new Error('name required') |
| 58 | 58 | if (!Number.isInteger(seq) || seq < 0) throw new Error('seq must be a non-negative integer') |
| 59 | 59 | 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') | |
| 61 | 61 | const rec = { v: NAME_RECORD_VERSION, n: name, s: seq } |
| 62 | 62 | if (resolved.driveKey) rec.k = resolved.driveKey |
| 63 | 63 | if (resolved.link) rec.l = resolved.link |
| … | … | @@ -92,7 +92,7 @@ const NAME_BINDING_SCHEMA = { |
| 92 | 92 | properties: { |
| 93 | 93 | name: { type: 'string', maxLength: 253 }, |
| 94 | 94 | 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://.+' }, | |
| 96 | 96 | binderPubkey: { type: 'string', pattern: '^[0-9a-f]{64}$' }, |
| 97 | 97 | bindingSig: { type: 'string', pattern: '^[0-9a-f]{128}$' }, |
| 98 | 98 | seq: { type: 'integer' }, |
| … | … | @@ -31,8 +31,7 @@ const HEX64 = /^[0-9a-f]{64}$/i |
| 31 | 31 | const HEX128 = /^[0-9a-f]{128}$/i |
| 32 | 32 | const MAX_NAME = 253 |
| 33 | 33 | const MAX_TARGET = 300 |
| 34 | const TARGET_ERROR = 'target must be a Hyperdrive key or pear://, hyper://, file:// link' | |
| 35 | const SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i | |
| 34 | const TARGET_ERROR = 'target must be a Hyperdrive key or hyper:// link' | |
| 36 | 35 | // Total serialized op cap (defense-in-depth vs a writer bloating the log — a real |
| 37 | 36 | // op is ~700 bytes: name≤253 + normalized/skeleton + a few 64-hex fields). Mirrors |
| 38 | 37 | // MAX_OP_BYTES in browser-state-ops / autobee-catalog-ops. Oversized → dropped on apply. |
| … | … | @@ -42,10 +41,9 @@ function normalizeTarget (target) { |
| 42 | 41 | const s = String(target || '').trim() |
| 43 | 42 | if (!s) return null |
| 44 | 43 | |
| 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)) { | |
| 49 | 47 | const link = normalizeCatalogLink(s) |
| 50 | 48 | if (!link || link.length > MAX_TARGET) return null |
| 51 | 49 | const key = driveKeyFromHyperLink(link) || null |
| … | … | @@ -82,21 +82,14 @@ test('catalog JSON normalizer accepts relay items and entries envelopes', () => |
| 82 | 82 | assert.equal(legacyCatalog.apps[0].id, entryKey) |
| 83 | 83 | }) |
| 84 | 84 | |
| 85 | test('catalog app normalizer drops unsafe targets and keeps allowed app links', () => { | |
| 85 | test('catalog app normalizer drops unsafe and executable targets', () => { | |
| 86 | 86 | const driveKey = 'c'.repeat(64) |
| 87 | 87 | |
| 88 | 88 | assert.equal(normalizeCatalogApp({ id: 'bad-key', driveKey: 'not-a-key', name: 'Bad key' }), null) |
| 89 | 89 | assert.equal(normalizeCatalogApp({ id: 'bad-link', link: 'javascript:alert(1)', name: 'Bad link' }), null) |
| 90 | 90 | assert.equal(normalizeCatalogApp({ id: 'targetless', name: 'No target' }), null) |
| 91 | 91 | |
| 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) | |
| 100 | 93 | |
| 101 | 94 | const hyper = normalizeCatalogApp({ link: `hyper://${driveKey}/app`, name: 'Site' }) |
| 102 | 95 | assert.equal(hyper.driveKey, driveKey) |
| … | … | @@ -109,7 +102,7 @@ test('shared catalog app normalizer builds stable target keys', () => { |
| 109 | 102 | id: 'custom-id', |
| 110 | 103 | name: ' Demo ', |
| 111 | 104 | appKey: driveKey.toUpperCase(), |
| 112 | link: ' PEAR://Demo ', | |
| 105 | link: ` hyper://${driveKey}/demo `, | |
| 113 | 106 | version: 2, |
| 114 | 107 | categories: [' tools ', '', 'p2p'] |
| 115 | 108 | }, { source: 'hiveindex', catalogKey: 'hiveindex:cat', catalogName: 'Relay Index' }) |
| … | … | @@ -117,14 +110,14 @@ test('shared catalog app normalizer builds stable target keys', () => { |
| 117 | 110 | assert.equal(app.id, 'custom-id') |
| 118 | 111 | assert.equal(app.name, 'Demo') |
| 119 | 112 | assert.equal(app.driveKey, driveKey) |
| 120 | assert.equal(app.link, 'pear://Demo') | |
| 113 | assert.equal(app.link, `hyper://${driveKey}/demo`) | |
| 121 | 114 | assert.equal(app.version, '2') |
| 122 | 115 | assert.deepEqual(app.categories, ['tools', 'p2p']) |
| 123 | 116 | assert.equal(app.verification, 'unverified') |
| 124 | 117 | assert.equal(app.source, 'hiveindex') |
| 125 | 118 | assert.equal(catalogAppStableKey(app), `drive:${driveKey}`) |
| 126 | 119 | |
| 127 | assert.equal(catalogAppStableKey({ link: ' PEAR://Demo ' }), 'link:pear://Demo') | |
| 120 | assert.equal(catalogAppStableKey({ link: ` hyper://${key('e')}/demo ` }), `drive:${key('e')}`) | |
| 128 | 121 | assert.equal(catalogAppStableKey({ id: 'only-id' }), 'id:only-id') |
| 129 | 122 | assert.equal(catalogAppStableKey({ id: 'site', link: `hyper://${driveKey}/app` }), `drive:${driveKey}`) |
| 130 | 123 | }) |
| … | … | @@ -134,10 +127,10 @@ test('catalog app normalizer keeps only supported launch types', () => { |
| 134 | 127 | assert.equal(normalizeAppType('HYPERSITE'), 'hypersite') |
| 135 | 128 | assert.equal(normalizeAppType('desktop'), '') |
| 136 | 129 | |
| 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` }) | |
| 138 | 131 | assert.equal(standalone.type, 'standalone') |
| 139 | 132 | |
| 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` }) | |
| 141 | 134 | assert.equal(invalid.type, undefined) |
| 142 | 135 | }) |
| 143 | 136 | |
| … | … | @@ -145,13 +138,14 @@ test('personal catalog entry sanitizer accepts link-only apps and rejects target |
| 145 | 138 | assert.deepEqual(sanitizePersonalCatalogEntry({ |
| 146 | 139 | id: 'keet', |
| 147 | 140 | name: ' Keet ', |
| 148 | link: ' PEAR://keet ', | |
| 141 | link: ` hyper://${key('c')}/keet `, | |
| 149 | 142 | categories: [' chat ', ''] |
| 150 | 143 | }), { |
| 151 | 144 | id: 'keet', |
| 152 | 145 | name: 'Keet', |
| 153 | 146 | description: '', |
| 154 | link: 'pear://keet', | |
| 147 | driveKey: key('c'), | |
| 148 | link: `hyper://${key('c')}/keet`, | |
| 155 | 149 | version: '', |
| 156 | 150 | author: '', |
| 157 | 151 | categories: ['chat'] |
| … | … | @@ -200,7 +194,7 @@ test('backend aggregation dedupes by stable target across all catalog sources', |
| 200 | 194 | data: { |
| 201 | 195 | name: 'Sheets Cat', |
| 202 | 196 | 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' } | |
| 204 | 198 | ] |
| 205 | 199 | } |
| 206 | 200 | }) |
| … | … | @@ -209,7 +203,7 @@ test('backend aggregation dedupes by stable target across all catalog sources', |
| 209 | 203 | data: { |
| 210 | 204 | name: 'Hiveindex Cat', |
| 211 | 205 | 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' }, | |
| 213 | 207 | { id: 'same-id', name: 'Second Same Id Different Drive', driveKey: otherDriveKey, version: '1.0.0' } |
| 214 | 208 | ] |
| 215 | 209 | } |
| … | … | @@ -225,7 +219,7 @@ test('backend aggregation dedupes by stable target across all catalog sources', |
| 225 | 219 | assert.equal(byName['Drive Signed'].iconData, 'data:image/png;base64,aaa') |
| 226 | 220 | assert.deepEqual(byName['Drive Signed']._sources.sort(), ['Autobee Cat', 'Hyperbee Cat', 'Hyperdrive Cat']) |
| 227 | 221 | |
| 228 | assert.equal(byName['Link High'].link, 'pear://Same') | |
| 222 | assert.equal(byName['Link High'].link, `hyper://${key('1')}/same`) | |
| 229 | 223 | assert.equal(byName['Link High'].catalogKey, 'hiveindex:cat') |
| 230 | 224 | assert.deepEqual(byName['Link High']._sources.sort(), ['Hiveindex Cat', 'Sheets Cat']) |
| 231 | 225 | |
| … | … | @@ -241,7 +235,7 @@ test('backend aggregation collapses link-only duplicates with different catalog |
| 241 | 235 | data: { |
| 242 | 236 | name: 'Signed Catalog', |
| 243 | 237 | 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' } | |
| 245 | 239 | ] |
| 246 | 240 | } |
| 247 | 241 | }) |
| … | … | @@ -250,7 +244,7 @@ test('backend aggregation collapses link-only duplicates with different catalog |
| 250 | 244 | data: { |
| 251 | 245 | name: 'Community Catalog', |
| 252 | 246 | 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' } | |
| 254 | 248 | ] |
| 255 | 249 | } |
| 256 | 250 | }) |
| … | … | @@ -259,8 +253,8 @@ test('backend aggregation collapses link-only duplicates with different catalog |
| 259 | 253 | assert.equal(apps.length, 1) |
| 260 | 254 | assert.equal(apps[0].id, 'signed-row') |
| 261 | 255 | 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') | |
| 264 | 258 | assert.equal(apps[0].verification, 'author-signed') |
| 265 | 259 | assert.deepEqual(apps[0]._sources.sort(), ['Community Catalog', 'Signed Catalog']) |
| 266 | 260 | }) |
| … | … | @@ -63,7 +63,8 @@ test('normalizeUrl canonicalizes URL-bar input', () => { |
| 63 | 63 | assert.equal(normalizeUrl(''), null) |
| 64 | 64 | assert.equal(normalizeUrl(' '), null) |
| 65 | 65 | 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) | |
| 67 | 68 | assert.equal(normalizeUrl(CATALOG_HEX), `hyper://${CATALOG_HEX}/`) |
| 68 | 69 | const z = z32FromHex(CATALOG_HEX) |
| 69 | 70 | assert.equal(normalizeUrl(z), `hyper://${z}/`) |
| … | … | @@ -105,11 +106,11 @@ test('driveKeyFromHyperRef accepts hyper refs, bare keys, and z32 keys only', () |
| 105 | 106 | assert.equal(driveKeyFromHyperRef('pear://app'), null) |
| 106 | 107 | }) |
| 107 | 108 | |
| 108 | test('normalizeNameTarget accepts drive keys and allowed app links only', () => { | |
| 109 | test('normalizeNameTarget accepts drive keys and browsable Hyper links only', () => { | |
| 109 | 110 | assert.equal(normalizeNameTarget(CATALOG_HEX.toUpperCase()), CATALOG_HEX) |
| 110 | assert.equal(normalizeNameTarget(' pear://keet '), 'pear://keet') | |
| 111 | 111 | 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) | |
| 113 | 114 | assert.equal(normalizeNameTarget('javascript:alert(1)'), null) |
| 114 | 115 | assert.equal(normalizeNameTarget('https://example.com'), null) |
| 115 | 116 | assert.equal(normalizeNameTarget('pear://' + 'x'.repeat(301)), null) |
| … | … | @@ -11,34 +11,22 @@ test('encode → decode round-trips', () => { |
| 11 | 11 | assert.deepEqual(decodeNameRecord(buf), { name: 'keet', driveKey: KEY, seq: 3, link: null, target: KEY }) |
| 12 | 12 | }) |
| 13 | 13 | |
| 14 | test('decode: valid launch links are normalized and unsafe links are dropped when a key remains', () => { | |
| 14 | test('decode: executable links are dropped when a drive key remains', () => { | |
| 15 | 15 | 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 }) | |
| 23 | 17 | // an unsafe link is ignored (→ key-only), not trusted |
| 24 | 18 | const bad = Buffer.from(JSON.stringify({ v: 1, n: 'keet', k: KEY, s: 1, l: 'https://evil.example' })) |
| 25 | 19 | assert.deepEqual(decodeNameRecord(bad), { name: 'keet', driveKey: KEY, seq: 1, link: null, target: KEY }) |
| 26 | 20 | }) |
| 27 | 21 | |
| 28 | test('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 | ||
| 22 | test('encode → decode accepts link-only Hyper targets', () => { | |
| 34 | 23 | const hyper = decodeNameRecord(encodeNameRecord({ name: 'site', target: `hyper://${KEY}/app`, seq: 2 })) |
| 35 | 24 | assert.equal(hyper.driveKey, KEY) |
| 36 | 25 | assert.equal(hyper.link, `hyper://${KEY}/app`) |
| 37 | 26 | assert.equal(hyper.target, `hyper://${KEY}/app`) |
| 38 | 27 | |
| 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/) | |
| 42 | 30 | }) |
| 43 | 31 | |
| 44 | 32 | test('decode: rejects wrong version', () => { |
| … | … | @@ -66,7 +54,7 @@ test('encodeNameRecord validates inputs', () => { |
| 66 | 54 | |
| 67 | 55 | test('NAME_BINDING_SCHEMA mirrors the catalogue schema contract', () => { |
| 68 | 56 | 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://.+') | |
| 70 | 58 | assert.deepEqual(NAME_BINDING_SCHEMA.anyOf, [{ required: ['driveKey'] }, { required: ['link'] }]) |
| 71 | 59 | assert.ok(NAME_BINDING_SCHEMA.required.includes('binderPubkey')) |
| 72 | 60 | assert.ok(NAME_BINDING_SCHEMA.required.includes('bindingSig')) |
| … | … | @@ -16,9 +16,7 @@ const owner = (kp) => hex(kp.publicKey) |
| 16 | 16 | const TARGET_A = 'aa'.repeat(32) |
| 17 | 17 | const TARGET_B = 'bb'.repeat(32) |
| 18 | 18 | const TARGET_C = 'cc'.repeat(32) |
| 19 | const PEAR_LINK = 'pear://keet' | |
| 20 | 19 | const HYPER_LINK = `hyper://${TARGET_A}/app` |
| 21 | const FILE_LINK = 'file:///tmp/pear-app' | |
| 22 | 20 | // a confusable squat of "paypal": latin p, CYRILLIC а (U+0430), y, p, cyr а, l → |
| 23 | 21 | // skeleton folds to "paypal" but its normalized form differs. |
| 24 | 22 | const PAYPAL_SQUAT = 'pаypаl' |
| … | … | @@ -38,19 +36,12 @@ test('first-claim-wins: the earlier claim in linear order owns the name', () => |
| 38 | 36 | assert.equal(resolve(view, 'alice').target, TARGET_A) |
| 39 | 37 | }) |
| 40 | 38 | |
| 41 | test('claims can target allowed app links, while unsafe schemes are rejected', () => { | |
| 39 | test('claims can target browsable Hyper links, while other schemes are rejected', () => { | |
| 42 | 40 | const a = crypto.keyPair() |
| 43 | 41 | assert.deepEqual(ops.normalizeTarget(HYPER_LINK), { target: HYPER_LINK, key: TARGET_A, link: HYPER_LINK, kind: 'link' }) |
| 44 | 42 | 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) | |
| 54 | 45 | |
| 55 | 46 | assert.throws( |
| 56 | 47 | () => 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', ( |
| 60 | 51 | () => ops.claimOp({ name: 'web', target: 'https://example.com', owner: owner(a) }, signer(a)), |
| 61 | 52 | /target must be/ |
| 62 | 53 | ) |
| 54 | assert.throws( | |
| 55 | () => ops.claimOp({ name: 'legacy', target: 'pear://keet', owner: owner(a) }, signer(a)), | |
| 56 | /target must be/ | |
| 57 | ) | |
| 63 | 58 | const badRemote = { |
| 64 | 59 | type: ops.CLAIM, |
| 65 | 60 | name: 'bad', |
| … | … | @@ -121,7 +121,7 @@ export function shortKey (k) { |
| 121 | 121 | |
| 122 | 122 | /** |
| 123 | 123 | * 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 | |
| 125 | 125 | * (example.com → https://example.com). Bare words without a dot still map |
| 126 | 126 | * to hyper:// for the name-resolver path. |
| 127 | 127 | */ |
| … | … | @@ -130,13 +130,15 @@ export function normalizeUrl (raw) { |
| 130 | 130 | if (!s) return null |
| 131 | 131 | if (/^hyper:\/\//i.test(s)) return s |
| 132 | 132 | 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 | |
| 133 | 135 | if (/^[0-9a-f]{64}$/i.test(s)) return `hyper://${s.toLowerCase()}/` |
| 134 | 136 | if (/^[13-9a-km-uw-z]{52}$/i.test(s)) return `hyper://${s}/` |
| 135 | 137 | // Public hostname (optionally with path/port) → https clearnet |
| 136 | 138 | if (/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?::\d{1,5})?(?:[/?#].*)?$/i.test(s)) { |
| 137 | 139 | return `https://${s.replace(/^\/+/, '')}` |
| 138 | 140 | } |
| 139 | if (s.includes('/') || s.startsWith('pear://')) return s | |
| 141 | if (s.includes('/')) return s | |
| 140 | 142 | return `hyper://${s}` |
| 141 | 143 | } |
| 142 | 144 | |
| … | … | @@ -166,7 +168,7 @@ export function normalizeNameTarget (raw) { |
| 166 | 168 | const s = String(raw || '').trim() |
| 167 | 169 | if (!s) return null |
| 168 | 170 | 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 | |
| 170 | 172 | return null |
| 171 | 173 | } |
| 172 | 174 |
| … | … | @@ -4614,7 +4614,7 @@ function NostrFeedSection ({ rpc, C }) { |
| 4614 | 4614 | ` |
| 4615 | 4615 | } |
| 4616 | 4616 | |
| 4617 | // --- Name registry (Phase N5) — claim memorable names → drives/app links ---- | |
| 4617 | // --- Name registry (Phase N5) — claim memorable names → browsable content --- | |
| 4618 | 4618 | // Owner-signed, multi-writer, first-claim-wins with a confusable/homograph guard. |
| 4619 | 4619 | // One form claims a new name or updates (rotates) one you already own; each name |
| 4620 | 4620 | // is shareable as pearname://<name> and resolves in the URL bar. |
| … | … | @@ -4640,7 +4640,7 @@ function NameRegistrySection ({ rpc, C }) { |
| 4640 | 4640 | const submit = async () => { |
| 4641 | 4641 | const n = name.trim() |
| 4642 | 4642 | 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 } | |
| 4644 | 4644 | const owned = list.find((e) => e.normalized === n.toLowerCase() || (e.name || '').toLowerCase() === n.toLowerCase()) |
| 4645 | 4645 | setBusy('submit'); setErr('') |
| 4646 | 4646 | try { |
| … | … | @@ -4666,12 +4666,12 @@ function NameRegistrySection ({ rpc, C }) { |
| 4666 | 4666 | <div className="settings-row"> |
| 4667 | 4667 | <div> |
| 4668 | 4668 | <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> | |
| 4670 | 4670 | </div> |
| 4671 | 4671 | </div> |
| 4672 | 4672 | <div className="tp-row"> |
| 4673 | 4673 | <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)} /> | |
| 4675 | 4675 | <button className="btn small primary" onClick=${submit} disabled=${busy != null || !name.trim() || !targetValid}>${busy === 'submit' ? 'Saving…' : 'Save'}</button> |
| 4676 | 4676 | </div> |
| 4677 | 4677 | ${list.length > 0 && html`<div className="namereg-list"> |