| … | … | @@ -15,6 +15,7 @@ import { gfmFootnote } from 'micromark-extension-gfm-footnote' |
| 15 | 15 | import { gfmFootnoteFromMarkdown } from 'mdast-util-gfm-footnote' |
| 16 | 16 | import { directive } from 'micromark-extension-directive' |
| 17 | 17 | import { directiveFromMarkdown } from 'mdast-util-directive' |
| 18 | import { parseUri } from './uri.js' | |
| 18 | 19 | |
| 19 | 20 | export const MICROMARK = { |
| 20 | 21 | // ⛔ singleTilde:false — micromark defaults it TRUE, which would consume `~x~` as strikethrough. |
| … | … | @@ -35,7 +36,20 @@ export const HASHTAG_RE = /(^|\s)(#([A-Za-z0-9_À- |
| 35 | 36 | // §3.2 — only http/https may carry a link. Everything else renders as text. |
| 36 | 37 | export const isHttp = (uri) => /^https?:\/\//i.test(String(uri || '')) |
| 37 | 38 | // §3.4 — a mention names a USER: `favela://<identity-root>`; `did:` is the legacy form. |
| 38 | export const isMention = (uri) => /^(favela:\/\/|did:)/i.test(String(uri || '')) | |
| 39 | // | |
| 40 | // ⛔ NARROWED. This was bare `/^(favela:\/\/|did:)/`, so it matched EVERY favela URI — including | |
| 41 | // `favela://blob/<oid>` (worked around below by telling every caller to test for a blob first) and | |
| 42 | // `favela://<root>/<collection>/<name>`, which was NOT worked around because the record form was | |
| 43 | // never specified. favela-surface emits one for a quote (router.js:291), so a quote was classified | |
| 44 | // as a mention of a user whose identity was actually a feed key. See lib/uri.js. | |
| 45 | // | |
| 46 | // A mention is now the USER form specifically: a favela URI with no path, or a `did:`. | |
| 47 | export const isMention = (uri) => { | |
| 48 | const s = String(uri || '') | |
| 49 | if (/^did:/i.test(s)) return true | |
| 50 | const u = parseUri(s) | |
| 51 | return !!u && u.kind === 'user' | |
| 52 | } | |
| 39 | 53 | |
| 40 | 54 | // record-vocabulary.md §3.5 — an embedded file: `favela://blob/<oid>`, where `<oid>` is the |
| 41 | 55 | // hyper-overlay content hash. A record never contains media; it names a file the overlay holds. |
| … | … | @@ -21,6 +21,7 @@ |
| 21 | 21 | // gives them as lexical rules over text (§3.6, §3.7) — a normative regex in the hashtag case — |
| 22 | 22 | // so there is no structural ambiguity to get wrong. |
| 23 | 23 | |
| 24 | import { isUserUri } from './uri.js' | |
| 24 | 25 | import { fromMarkdown } from 'mdast-util-from-markdown' |
| 25 | 26 | import { gfmStrikethrough } from 'micromark-extension-gfm-strikethrough' |
| 26 | 27 | import { gfmStrikethroughFromMarkdown } from 'mdast-util-gfm-strikethrough' |
| … | … | @@ -63,7 +64,17 @@ const isHttp = (uri) => /^https?:\/\//i.test(String(uri || '')) |
| 63 | 64 | // LEGACY form kept because §8 is forward-only and nothing is rewritten. strip() does NOT resolve |
| 64 | 65 | // either: it is a codec with no registry access, and resolution to a bsky DID belongs at |
| 65 | 66 | // PROJECTION time (identity.md §5). So the facet carries the target exactly as written. |
| 66 | const isMention = (uri) => /^(favela:\/\/|did:)/i.test(String(uri || '')) | |
| 67 | // ⛔ NARROWED, and this is the copy that matters most: strip() produces the FACETS a bridge sends. | |
| 68 | // The old test was bare `^(favela://|did:)`, so a record URI — `favela://<root>/<collection>/<name>`, | |
| 69 | // which favela-surface emits for a quote — became a MENTION facet, and every bridge then tried to | |
| 70 | // resolve a record address as if it were a person. A mention is the USER form: no path, or a `did:`. | |
| 71 | // | |
| 72 | // ⚠ This was a second, divergent copy of dialect.js's predicate. Two definitions of one normative | |
| 73 | // rule is how they drift; it delegates now. | |
| 74 | const isMention = (uri) => { | |
| 75 | const s = String(uri || '') | |
| 76 | return /^did:/i.test(s) || isUserUri(s) | |
| 77 | } | |
| 67 | 78 | |
| 68 | 79 | // §6 — the NATIVE facet feature: { type, value }, the same compact form the vectors are written in. |
| 69 | 80 | // |
| … | … | @@ -0,0 +1,107 @@ |
| 1 | // uri.js — the `favela://` scheme. | |
| 2 | // | |
| 3 | // ⛔ WHY THIS FILE EXISTS. `isMention` was `/^(favela:\/\/|did:)/` — it matched ANY favela URI. The | |
| 4 | // blob form was noticed and worked around by ORDERING: dialect.js:43 says "every caller must test | |
| 5 | // for a blob FIRST — otherwise an embed renders as a mention span and the picture silently | |
| 6 | // disappears". The RECORD form was never specified, so nobody worked around it, and | |
| 7 | // `favela://<x>/<collection>/<name>` — which favela-surface already emits for a quote | |
| 8 | // (router.js:291) — was classified as a mention of a user. A quote rendered as a mention of nobody. | |
| 9 | // | |
| 10 | // A scheme with three forms needs a parse, not a chain of predicates in the right order. | |
| 11 | // | |
| 12 | // ── The authority (hyper-social #61 §12 #1) ─────────────────────────────────────────────────── | |
| 13 | // | |
| 14 | // The authority was TWO things at once. A mention carries an IDENTITY ROOT (dialect.js:37, | |
| 15 | // favela-peer/lib/mentions.js:116). The quote path carries a FEED KEY. Both are 64 hex characters, | |
| 16 | // so no reader could tell which it had. | |
| 17 | // | |
| 18 | // ★ IT IS THE IDENTITY ROOT. One space, everywhere: | |
| 19 | // | |
| 20 | // 1. The mention form already uses it, and those records are written. Changing that would move | |
| 21 | // every existing mention. | |
| 22 | // 2. `NAMING.md` §3 — a name resolves to an identity ROOT, and only then to a room key. | |
| 23 | // 3. hyper-social #64 §1 — handle claims anchor to the root. pgh shipped the device-key version | |
| 24 | // first and needed `pgh names reanchor --all --force` to repair it; favela stored something | |
| 25 | // worse still (a manifest-derived writer key, which can never sign). | |
| 26 | // 4. #61 §2a — deriving an address from a rotatable locator is the exact failure `favela://` | |
| 27 | // exists to prevent. `roomKey = hash(NS('room-key') || did)` broke because a DID can rotate. | |
| 28 | // | |
| 29 | // A FEED KEY IS A LOCATOR, not an identity: it says where bytes are, and #61 §12 #3 leaves oplog | |
| 30 | // stability under `setLocal` writer rotation unverified. The root says who. Same split as a record's | |
| 31 | // name versus its oplog — identity is not the same question as where to fetch. | |
| 32 | // | |
| 33 | // Resolution root → feed already exists: `profiles()` returns feed → root for feeds you hold, and a | |
| 34 | // handle claim binds root → feed and REPLICATES, which is the namespace layer's whole job. | |
| 35 | // | |
| 36 | // ⚠ PERMISSIVE WHEN READING, STRICT WHEN WRITING. `parseUri` does not police the authority's shape, | |
| 37 | // because it must read bytes already written — including a conformance vector that mentions | |
| 38 | // `favela://abc123`. The 64-hex requirement is enforced in `recordUri`, where we mint. | |
| 39 | ||
| 40 | const PREFIX = 'favela://' | |
| 41 | const HEX64 = /^[0-9a-f]{64}$/i | |
| 42 | const OID = /^[0-9a-f]{8,128}$/i | |
| 43 | // One path segment, matching favela-peer's `isSafeSeg`: no separator, not a dot entry. | |
| 44 | const isSeg = (s) => typeof s === 'string' && s.length > 0 && !s.includes('/') && s !== '.' && s !== '..' | |
| 45 | ||
| 46 | /** | |
| 47 | * Parse a favela URI into exactly one of three kinds, or null. | |
| 48 | * | |
| 49 | * ⛔ BY SHAPE, never by call order: | |
| 50 | * favela://blob/<oid> → blob (authority is the literal "blob") | |
| 51 | * favela://<root> → user (no path) | |
| 52 | * favela://<root>/<collection>/<name> → record (two path segments) | |
| 53 | * | |
| 54 | * Anything else is null. A URI we cannot classify must not be guessed at — rendering an | |
| 55 | * unrecognised form as the nearest familiar one is how the picture disappeared. | |
| 56 | */ | |
| 57 | export function parseUri (uri) { | |
| 58 | const s = String(uri == null ? '' : uri) | |
| 59 | if (!s.toLowerCase().startsWith(PREFIX)) return null | |
| 60 | ||
| 61 | const rest = s.slice(PREFIX.length) | |
| 62 | if (!rest) return null | |
| 63 | const parts = rest.split('/') | |
| 64 | ||
| 65 | if (parts[0].toLowerCase() === 'blob') { | |
| 66 | if (parts.length !== 2 || !OID.test(parts[1])) return null | |
| 67 | return { kind: 'blob', root: null, collection: null, name: null, oid: parts[1].toLowerCase() } | |
| 68 | } | |
| 69 | ||
| 70 | if (parts.length === 1) { | |
| 71 | return { kind: 'user', root: parts[0], collection: null, name: null, oid: null } | |
| 72 | } | |
| 73 | ||
| 74 | if (parts.length === 3) { | |
| 75 | if (!isSeg(parts[1]) || !isSeg(parts[2])) return null | |
| 76 | return { kind: 'record', root: parts[0], collection: parts[1], name: parts[2], oid: null } | |
| 77 | } | |
| 78 | ||
| 79 | return null | |
| 80 | } | |
| 81 | ||
| 82 | export const isUserUri = (uri) => { const u = parseUri(uri); return !!u && u.kind === 'user' } | |
| 83 | export const isRecordUri = (uri) => { const u = parseUri(uri); return !!u && u.kind === 'record' } | |
| 84 | export const isBlobUri = (uri) => { const u = parseUri(uri); return !!u && u.kind === 'blob' } | |
| 85 | ||
| 86 | export const userUri = (root) => PREFIX + String(root) | |
| 87 | export const blobUri = (oid) => PREFIX + 'blob/' + String(oid).toLowerCase() | |
| 88 | ||
| 89 | /** | |
| 90 | * Mint a record URI. | |
| 91 | * | |
| 92 | * ⛔ STRICT ABOUT THE AUTHORITY, deliberately. This is the door a feed key would otherwise walk | |
| 93 | * through — favela-surface's quote path was putting one here — and once a URI is written into a | |
| 94 | * record's bytes it cannot be corrected, because SOCIAL-MODEL §8 forbids rewriting a record you did | |
| 95 | * not author. Refusing at mint time is the only place this can be enforced. | |
| 96 | */ | |
| 97 | export function recordUri ({ root, collection, name }) { | |
| 98 | if (!HEX64.test(String(root || ''))) { | |
| 99 | throw new Error('recordUri: the authority must be a 64-hex identity root, not a feed key') | |
| 100 | } | |
| 101 | if (!isSeg(collection) || !isSeg(name)) { | |
| 102 | throw new Error('recordUri: collection and name must each be one path segment') | |
| 103 | } | |
| 104 | return PREFIX + String(root).toLowerCase() + '/' + collection + '/' + name | |
| 105 | } | |
| 106 | ||
| 107 | export default { parseUri, isUserUri, isRecordUri, isBlobUri, userUri, recordUri, blobUri } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-markdown", |
| 3 | "version": "0.1.5", | |
| 3 | "version": "0.1.6", | |
| 4 | 4 | "description": "The P2P-native markdown record layer for favela. A post is a markdown document; atproto and ActivityPub are projections derived from it on read.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "license": "MIT", |
| … | … | @@ -0,0 +1,85 @@ |
| 1 | // uri.test.js — the `favela://` scheme, and what goes in the authority position. | |
| 2 | // | |
| 3 | // ⛔ THE LIVE BUG. `isMention` is `/^(favela:\/\/|did:)/` — it matches ANY favela URI. The blob form | |
| 4 | // was noticed and worked around by ordering ("every caller must test for a blob FIRST", | |
| 5 | // dialect.js:43). The RECORD form was not, because it was never specified — so | |
| 6 | // `favela://<x>/<collection>/<name>`, which favela-surface already emits for a quote | |
| 7 | // (router.js:291), is classified as a mention of a user. A quote renders as a mention of nobody. | |
| 8 | // | |
| 9 | // ⛔ AND THE AUTHORITY MEANT TWO THINGS. Mentions carry an IDENTITY ROOT (dialect.js:37, | |
| 10 | // mentions.js:116). The quote path carries a FEED KEY. Both are 64 hex characters, so nothing can | |
| 11 | // tell them apart. hyper-social #61 §12 #1 is exactly this question. | |
| 12 | // | |
| 13 | // ★ THE DECISION: the authority is the IDENTITY ROOT, one space, everywhere. | |
| 14 | // - the mention form already uses it, and those records are written | |
| 15 | // - NAMING.md §3 — a name resolves to an identity root, and only then to a room key | |
| 16 | // - hyper-social #64 §1 — handle claims anchor to the ROOT; pgh paid for the device-key version | |
| 17 | // with `pgh names reanchor --all --force` | |
| 18 | // - #61 §2a — an address derived from a rotatable locator is the failure `favela://` exists to | |
| 19 | // prevent. A feed key is a LOCATOR; the root is the identity. Same split as name vs oplog. | |
| 20 | // | |
| 21 | // ⚠ Parsing stays PERMISSIVE about the authority's shape, because it must read bytes already | |
| 22 | // written — including a vector that mentions `favela://abc123`. Strictness belongs where URIs are | |
| 23 | // MINTED, not where old records are read. | |
| 24 | ||
| 25 | import test from 'brittle' | |
| 26 | import { parseUri, userUri, recordUri, blobUri, isRecordUri } from '../lib/uri.js' | |
| 27 | import { isMention, isBlob } from '../lib/dialect.js' | |
| 28 | ||
| 29 | const ROOT = 'a'.repeat(64) | |
| 30 | ||
| 31 | test('★★ a record URI is NOT a mention', async (t) => { | |
| 32 | const uri = recordUri({ root: ROOT, collection: 'post', name: '01k1v297vo008i4cq4alj7f24p' }) | |
| 33 | t.absent(isMention(uri), 'the whole defect: a quote used to render as a mention of nobody') | |
| 34 | t.ok(isRecordUri(uri)) | |
| 35 | }) | |
| 36 | ||
| 37 | test('★★ three forms, told apart by SHAPE rather than by call order', async (t) => { | |
| 38 | t.is(parseUri('favela://' + ROOT).kind, 'user') | |
| 39 | t.is(parseUri('favela://' + ROOT + '/post/abc').kind, 'record') | |
| 40 | t.is(parseUri('favela://blob/deadbeef').kind, 'blob') | |
| 41 | // dialect.js:43 says a caller must test for a blob FIRST or an image silently disappears. With a | |
| 42 | // real parse there is no ordering to get wrong. | |
| 43 | t.absent(isMention('favela://blob/deadbeef'), 'a blob is not a mention either, now') | |
| 44 | t.ok(isBlob('favela://blob/deadbeef'), 'and the blob predicate is unchanged') | |
| 45 | }) | |
| 46 | ||
| 47 | test('★ the parts come back', async (t) => { | |
| 48 | const u = parseUri('favela://' + ROOT + '/post/01k1v297vo008i4cq4alj7f24p') | |
| 49 | t.is(u.root, ROOT) | |
| 50 | t.is(u.collection, 'post') | |
| 51 | t.is(u.name, '01k1v297vo008i4cq4alj7f24p') | |
| 52 | t.is(parseUri('favela://blob/deadbeef').oid, 'deadbeef') | |
| 53 | t.is(parseUri('favela://' + ROOT).root, ROOT) | |
| 54 | }) | |
| 55 | ||
| 56 | test('★ minting round-trips', async (t) => { | |
| 57 | const r = recordUri({ root: ROOT, collection: 'post', name: 'abc' }) | |
| 58 | t.alike(parseUri(r), { kind: 'record', root: ROOT, collection: 'post', name: 'abc', oid: null }) | |
| 59 | t.is(parseUri(userUri(ROOT)).kind, 'user') | |
| 60 | t.is(parseUri(blobUri('deadbeef')).kind, 'blob') | |
| 61 | }) | |
| 62 | ||
| 63 | // A vector in render.test.js mentions `favela://abc123`. Records are written; the reader must not | |
| 64 | // start refusing them because a later spec said roots are 64 hex. | |
| 65 | test('⛔ a legacy non-hex authority still reads as a user', async (t) => { | |
| 66 | t.is(parseUri('favela://abc123').kind, 'user') | |
| 67 | t.ok(isMention('favela://abc123'), 'and is still a mention') | |
| 68 | }) | |
| 69 | ||
| 70 | test('⛔ minting a record URI REQUIRES an identity root — strict where we write', async (t) => { | |
| 71 | t.exception(() => recordUri({ root: 'abc123', collection: 'post', name: 'x' }), /identity root/i, | |
| 72 | 'a feed key or a short string must not reach the authority position') | |
| 73 | t.exception(() => recordUri({ root: ROOT, collection: 'a/b', name: 'x' }), /segment/i) | |
| 74 | t.exception(() => recordUri({ root: ROOT, collection: 'post', name: '' }), /segment/i) | |
| 75 | }) | |
| 76 | ||
| 77 | test('⛔ anything else fails closed', async (t) => { | |
| 78 | for (const bad of ['favela://', 'favela://a/b', 'favela://a/b/c/d', 'https://example.com', 'did:plc:x', '', null]) { | |
| 79 | t.is(parseUri(bad), null, JSON.stringify(bad)) | |
| 80 | } | |
| 81 | }) | |
| 82 | ||
| 83 | test('★ did: stays a mention — it is the legacy user form', async (t) => { | |
| 84 | t.ok(isMention('did:plc:abc'), 'dialect.js:37 calls it the legacy form and records carry it') | |
| 85 | }) |