OverviewFilesCommitsIssuesDocsReleasesPeers

favela-markdown commits / b688118

0.1.6: the favela:// authority is the identity root
hyper-social #61 §12 #1 — identity root, feed key, or both. It is the ROOT, and until now it was quietly both. A mention carries an identity root (dialect.js:37, favela-peer mentions.js:116). favela-surface's quote path carried a FEED KEY (router.js:291). Both are 64 hex characters, so no reader could tell which it had been handed. The root wins, and not on taste: - the mention form already uses it and those records are written - NAMING.md §3 — a name resolves to an identity root, and only then to a room key - #64 §1 — handle claims anchor to the root; pgh shipped the device-key version first and needed `names reanchor --all --force` to repair it - #61 §2a — deriving an address from a rotatable locator is the exact failure favela:// exists to prevent, and is why roomKey = hash(NS || did) broke A feed key says WHERE the bytes are; the root says WHO. Same split as a record's name versus its oplog. ⛔ AND isMention WAS SWALLOWING RECORD URIS. It was bare `^(favela://|did:)`, so it matched every favela URI. The blob form was noticed and worked around by ORDERING — dialect.js:43 tells every caller to test for a blob first or "the picture silently disappears". The record form was never specified, so nobody worked around it: a quote was classified as a mention of a user whose identity was actually a feed key. lib/uri.js replaces the predicate chain with a parse that tells the three forms apart by SHAPE, so there is no call order to get wrong: favela://blob/<oid> blob (authority is the literal) favela://<root> user (no path) favela://<root>/<collection>/<name> record (two segments) Anything else is null. isMention is now the user form specifically. ⚠ Two divergent copies of that rule existed — dialect.js:38 and strip.js:66. strip.js is the one that mattered, because strip() produces the facets a bridge sends: a record URI became a mention facet and every bridge then tried to resolve a record address as a person. Both delegate now. ⚠ PERMISSIVE READING, STRICT WRITING. parseUri does not police the authority's shape, because it must read bytes already written — including the vector that mentions favela://abc123. recordUri REFUSES anything but a 64-hex root, because that is the door a feed key walks through, and a URI written into a record cannot be corrected: §8 forbids rewriting a record you did not author. favela-markdown 77/77 tests, 2432/2432 asserts — the 36 conformance vectors are unchanged, which is what says this narrowing is safe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b6881185b7d6e7dbbb707bd854988b431c30ad2f
by plankatron <fleeky@gmail.com> · 2026-09-08T14:33:26.000Z

Changed files (5) +220 -3

lib/dialect.js +15 -1
……@@ -15,6 +15,7 @@ import { gfmFootnote } from 'micromark-extension-gfm-footnote'
1515import { gfmFootnoteFromMarkdown } from 'mdast-util-gfm-footnote'
1616import { directive } from 'micromark-extension-directive'
1717import { directiveFromMarkdown } from 'mdast-util-directive'
18import { parseUri } from './uri.js'
1819
1920export const MICROMARK = {
2021 // ⛔ 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_À-
3536// §3.2 — only http/https may carry a link. Everything else renders as text.
3637export const isHttp = (uri) => /^https?:\/\//i.test(String(uri || ''))
3738// §3.4 — a mention names a USER: `favela://<identity-root>`; `did:` is the legacy form.
38export 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:`.
47export 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}
3953
4054// record-vocabulary.md §3.5 — an embedded file: `favela://blob/<oid>`, where `<oid>` is the
4155// hyper-overlay content hash. A record never contains media; it names a file the overlay holds.
lib/strip.js +12 -1
……@@ -21,6 +21,7 @@
2121// gives them as lexical rules over text (§3.6, §3.7) — a normative regex in the hashtag case —
2222// so there is no structural ambiguity to get wrong.
2323
24import { isUserUri } from './uri.js'
2425import { fromMarkdown } from 'mdast-util-from-markdown'
2526import { gfmStrikethrough } from 'micromark-extension-gfm-strikethrough'
2627import { gfmStrikethroughFromMarkdown } from 'mdast-util-gfm-strikethrough'
……@@ -63,7 +64,17 @@ const isHttp = (uri) => /^https?:\/\//i.test(String(uri || ''))
6364// LEGACY form kept because §8 is forward-only and nothing is rewritten. strip() does NOT resolve
6465// either: it is a codec with no registry access, and resolution to a bsky DID belongs at
6566// PROJECTION time (identity.md §5). So the facet carries the target exactly as written.
66const 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.
74const isMention = (uri) => {
75 const s = String(uri || '')
76 return /^did:/i.test(s) || isUserUri(s)
77}
6778
6879// §6 — the NATIVE facet feature: { type, value }, the same compact form the vectors are written in.
6980//
lib/uri.js +107 -0
……@@ -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
40const PREFIX = 'favela://'
41const HEX64 = /^[0-9a-f]{64}$/i
42const OID = /^[0-9a-f]{8,128}$/i
43// One path segment, matching favela-peer's `isSafeSeg`: no separator, not a dot entry.
44const 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 */
57export 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
82export const isUserUri = (uri) => { const u = parseUri(uri); return !!u && u.kind === 'user' }
83export const isRecordUri = (uri) => { const u = parseUri(uri); return !!u && u.kind === 'record' }
84export const isBlobUri = (uri) => { const u = parseUri(uri); return !!u && u.kind === 'blob' }
85
86export const userUri = (root) => PREFIX + String(root)
87export 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 */
97export 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
107export default { parseUri, isUserUri, isRecordUri, isBlobUri, userUri, recordUri, blobUri }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-markdown",
3 "version": "0.1.5",
3 "version": "0.1.6",
44 "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.",
55 "type": "module",
66 "license": "MIT",
test/uri.test.js +85 -0
……@@ -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
25import test from 'brittle'
26import { parseUri, userUri, recordUri, blobUri, isRecordUri } from '../lib/uri.js'
27import { isMention, isBlob } from '../lib/dialect.js'
28
29const ROOT = 'a'.repeat(64)
30
31test('★★ 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
37test('★★ 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
47test('★ 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
56test('★ 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.
65test('⛔ 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
70test('⛔ 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
77test('⛔ 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
83test('★ 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})