OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web commits / 8c09db0

0.3.62: notifications this node can actually answer
/notifications rendered a shell and asked the BROWSER for a bsky app password, then fetched bsky's notification API client-side. Three things wrong with that: a second credential for an account this node already controls; bsky's view rather than ours; and total blindness to ActivityPub. #45 listed "the 'did anyone reply to me' loop does not work" among the five gaps that decide whether favela is a product. The data was already here. The inbound lens folds replies, quotes and likes that touch our content into the appview (spacedust live, Constellation backfill), so getReplies answers from a local index. ap-bridge 0.0.6 keeps what the fediverse sends. This merges both into one list, server-rendered, no credential, works with JavaScript off. ⛔ ONE SURFACE, BOTH NETWORKS, ATTRIBUTED. #41's rule: inbound fediverse activity must land in the SAME place as atproto interactions "or we ship two inboxes and a confused user". Merged — but each item keeps its network badge, because a reply from Mastodon and a reply from bsky are not interchangeable and a UI that renders them identically lies about where the conversation is. ⛔ null MEANS "COULD NOT ASK". ap-bridge is single-instance, so a favela-web on another host cannot read it. The page then SAYS the fediverse side is unreadable rather than showing an empty list — "you have no replies" and "this node cannot see them" must not render alike. Same distinction the AP reach reader already draws. Remote HTML becomes TEXT before it reaches the page, and is escaped again at render: the store keeps content verbatim by design, so this is the second line of defence, not the first. Your own replies are filtered out — they are indexed exactly like anyone else's, and without it the page fills with your own voice. The bsky-app-password path is NOT deleted; it fetches something we genuinely cannot (notifications touching accounts we do not host). It is demoted into a <details> so the default page is the one needing no credential. 34 notification assertions + 8 new page assertions; suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8c09db01c55bb9d8f75fed85fa7fa0b2c7936334
by plankatron <fleeky@gmail.com> · 2026-08-18T20:43:58.000Z

Changed files (7) +428 -10

bin/favela-web.js +42 -1
……@@ -26,6 +26,8 @@ import { Appview, INGEST_VERSION, threadPostList } from '../lib/appview.js'
2626import { parseLenses, lensesEnabled } from '../lib/lenses.js'
2727import { createLensReader, extractThreadReplies, extractThreadParents } from '../lib/lens-import.js'
2828import { frontsDiag } from '../lib/fronts-diag.js'
29import { fromApItem, fromReply, mergeNotifications } from '../lib/notifications.js'
30import { createApInbox } from '../lib/ap-inbox.js'
2931import { tidNow } from 'atproto-store/lib/tid.js' // rkey minting for like/follow records
3032import { strongRef, parseAtUri } from '../lib/strongref.js'
3133import { resolveVerifyKey } from '../lib/plc.js'
……@@ -350,6 +352,15 @@ async function apProjected () {
350352 try { return await apFollowers.projectedRkeys(ROSTER[0]) } catch { return null }
351353}
352354
355// The fediverse half of notifications. Local socket only — ap-bridge is single-instance and holds
356// other people's messages to us, so it is not a public endpoint. Returns null when this node cannot
357// ask, which the page renders as "cannot see them" rather than "none".
358const apInbox = createApInbox({
359 home: process.env.HOME || '',
360 exists: (p) => { try { return fs.existsSync(p) } catch { return false } },
361 connect: async (p) => { const { Client } = await import('hyper-svc'); return Client.connectUds(p) }
362})
363
353364const followerLens = createFollowerLens({
354365 constellation: createConstellationClient({
355366 doFetch: bareFetch,
……@@ -1730,7 +1741,37 @@ const server = http.createServer(async (req, res) => {
17301741 // hpp requires a real user bearer to mint service auth (deliberately), and holding one here would
17311742 // make favela-web a credential store, which is the thing it is built not to be.
17321743 if (path === '/notifications') {
1733 return html(res, 200, notificationsPage({ viewer, pds: EXTERNAL_URL || 'https://pds.7rnx.net' }))
1744 // Answered from what this node already knows: the inbound lens folds replies touching our
1745 // content into the appview, and ap-bridge keeps what the fediverse sent. No credential.
1746 let items = []
1747 let apAvailable = null
1748 if (viewer) {
1749 const collected = []
1750 try {
1751 // Our own recent posts are the subjects anyone could have replied to. The reply index is
1752 // keyed by parent uri, so this is N cheap local range reads, not a network call.
1753 const own = await av.getAuthorFeed(viewer.did, { limit: 20, filter: 'posts_no_replies' })
1754 for (const post of (own && own.posts) || []) {
1755 if (!post || !post.uri) continue
1756 for (const r of await av.getReplies(post.uri, { limit: 20 })) {
1757 if (!r || r.did === viewer.did) continue // your own reply is not a notification
1758 collected.push({ ...fromReply(r, post.uri), authorDid: r.did })
1759 }
1760 }
1761 } catch (e) {
1762 console.error(JSON.stringify({ msg: 'notifications: atproto side failed', error: e.message }))
1763 }
1764
1765 const apItems = await apInbox.list({ limit: 50 })
1766 apAvailable = apItems !== null // null means COULD NOT ASK, not "none"
1767 items = mergeNotifications({
1768 atproto: collected,
1769 activitypub: (apItems || []).map(fromApItem).filter(Boolean),
1770 viewerDid: viewer.did,
1771 limit: 50
1772 })
1773 }
1774 return html(res, 200, notificationsPage({ viewer, items, apAvailable, pds: EXTERNAL_URL || 'https://pds.7rnx.net' }))
17341775 }
17351776
17361777 // Bookmarks — the viewer's own saved posts. Nothing shared, nothing federated.
lib/ap-inbox.js +55 -0
……@@ -0,0 +1,55 @@
1// ap-inbox.js — read what the fediverse sent us, from ap-bridge's control socket.
2//
3// ap-bridge 0.0.6 keeps inbound replies, mentions, likes and boosts (before that it verified them,
4// returned 202, and dropped them). They are NOT served over its public HTTP surface, deliberately:
5// an AP inbox holds other people's messages to us, and publishing it would republish their content
6// under our host. So this reads the control socket, which is local and privileged.
7//
8// ⛔ LOCAL ONLY, AND THAT IS THE HONEST LIMIT. ap-bridge is single-instance by construction — its RSA
9// keypair, follower list and delivered-map are per-node state, and ap.7rnx.net is a static vhost to
10// one backend. A favela-web on any other host cannot read this and must SAY so rather than render an
11// empty list.
12//
13// ⛔ null MEANS "COULD NOT ASK". [] means "asked, nothing there". The existing AP reach reader draws
14// exactly this distinction ("so 'not projected' and 'could not ask' never render alike") and the
15// notifications page depends on it: a user with no replies and a user whose node cannot see the
16// fediverse must not be shown the same page.
17
18const DEFAULT_SOCK = '/.config/ap-bridge/svc.sock'
19
20export function createApInbox ({ sockPath = null, connect = null, home = null, exists = null } = {}) {
21 const path = sockPath || ((home || '') + DEFAULT_SOCK)
22
23 return {
24 get path () { return path },
25
26 // Is there an ap-bridge on this machine at all? Checked before connecting so the common case
27 // (no AP side here) costs nothing and logs nothing.
28 available () {
29 if (!exists) return true // no probe injected: let the connect attempt decide
30 try { return !!exists(path) } catch { return false }
31 },
32
33 /**
34 * @returns {Promise<Array|null>} items, or null when we could not ask.
35 */
36 async list ({ limit = 50 } = {}) {
37 if (!this.available()) return null
38 if (typeof connect !== 'function') return null
39 let client = null
40 try {
41 client = await connect(path)
42 const res = await client.call('notifications', { limit })
43 const items = res && res.items
44 return Array.isArray(items) ? items : null
45 } catch {
46 // Unreachable, wrong version (no `notifications` verb), or mid-restart. All the same answer:
47 // we could not ask. Never throw — the notifications page must render its atproto half
48 // regardless of whether the AP side is up.
49 return null
50 } finally {
51 try { await client?.close?.() } catch {}
52 }
53 }
54 }
55}
lib/notifications.js +126 -0
……@@ -0,0 +1,126 @@
1// notifications.js — "did anyone reply to me", answered from what THIS NODE already knows.
2//
3// ⛔ WHAT THIS REPLACES. /notifications rendered a shell and asked the browser for a bsky APP
4// PASSWORD, then fetched bsky's notification API client-side. Three things wrong with that: it needs
5// a second credential for an account you already control here; it shows bsky's view rather than this
6// node's; and it is blind to ActivityPub entirely. hyper-social #45 called it "an empty shell" and
7// put "the 'did anyone reply to me' loop does not work" among the five gaps that decide whether
8// favela is a product.
9//
10// The data was already here. On the atproto side the inbound lens folds replies, quotes and likes
11// that touch our content into the appview (spacedust live, Constellation backfill), so `getReplies`
12// answers "who replied to this" from a local index with no credential and no remote call. On the
13// ActivityPub side ap-bridge now keeps what the fediverse sends (0.0.6) and exposes it over its
14// control socket. This merges the two into one list.
15//
16// ⛔ ONE SURFACE, BOTH NETWORKS, ATTRIBUTED. #41's rule: inbound Create/Follow/Like/Announce from the
17// fediverse must land in the SAME place as atproto interactions "or we ship two inboxes and a
18// confused user". They are merged here — and each item keeps its `network`, because a reply from
19// Mastodon and a reply from bsky are not interchangeable and a UI that pretends otherwise is lying
20// about where a conversation is happening.
21
22// A notification is normalised to one shape regardless of which network produced it, so rendering
23// does not branch per source.
24// kind reply | like | boost | mention
25// network atproto | activitypub
26// who display string for the author (handle, or a short did/actor)
27// when ISO timestamp for ordering and display
28// text what they said, when the kind carries text
29// subject what of OURS it touches (uri/objectId), for linking back
30// url where to read it in full, when the source gives one
31
32const AP_KIND = { Create: 'reply', Update: 'reply', Like: 'like', Announce: 'boost' }
33
34// A remote actor URI is long and mostly noise. `https://prsm.space/users/fleeky` -> `@fleeky@prsm.space`
35// — the form a fediverse user recognises as their own name.
36export function actorHandle (actorUri) {
37 try {
38 const u = new URL(String(actorUri))
39 const name = u.pathname.split('/').filter(Boolean).pop()
40 return name ? '@' + name + '@' + u.host : u.host
41 } catch {
42 return String(actorUri || '')
43 }
44}
45
46// ⛔ HTML FROM A REMOTE SERVER IS NOT MARKUP WE TRUST. ap-bridge stores `content` verbatim, which is
47// correct — it is not the store's job to decide how it renders. Here it becomes TEXT: tags stripped,
48// entities left for the renderer to escape. A notification line is not a place to reproduce someone
49// else's markup, and treating it as such is how a mention becomes a script tag.
50export function htmlToText (html) {
51 return String(html || '')
52 .replace(/<br\s*\/?>/gi, ' ')
53 .replace(/<\/p>/gi, ' ')
54 .replace(/<[^>]*>/g, '')
55 .replace(/\s+/g, ' ')
56 .trim()
57}
58
59/** One ap-bridge inbound item -> a notification, or null if it is not one we can present. */
60export function fromApItem (item) {
61 if (!item || !item.type) return null
62 const kind = AP_KIND[item.type]
63 if (!kind) return null
64 return {
65 kind,
66 network: 'activitypub',
67 who: actorHandle(item.actor),
68 when: item.published || (item.receivedAt ? new Date(item.receivedAt).toISOString() : null),
69 text: kind === 'reply' ? htmlToText(item.content) : '',
70 // For a reply this is OUR post it answers; for a like/boost it is OUR post they acted on.
71 subject: item.inReplyTo || item.objectId || null,
72 url: item.url || item.objectId || null,
73 id: item.id || null
74 }
75}
76
77/** One indexed atproto reply -> a notification. `post` is an appview-hydrated reply. */
78export function fromReply (post, subjectUri) {
79 if (!post) return null
80 const rec = post.record || {}
81 return {
82 kind: 'reply',
83 network: 'atproto',
84 who: post.handle || post.author?.handle || shortDid(post.did),
85 when: rec.createdAt || post.createdAt || null,
86 text: typeof rec.text === 'string' ? rec.text : '',
87 subject: subjectUri || null,
88 url: post.uri || null,
89 id: post.uri || null
90 }
91}
92
93function shortDid (did) {
94 const d = String(did || '')
95 const id = d.startsWith('did:plc:') ? d.slice('did:plc:'.length) : d
96 return id.length > 14 ? id.slice(0, 8) + '…' + id.slice(-4) : id
97}
98
99/**
100 * Merge both networks into one list, newest first.
101 *
102 * ⛔ YOUR OWN ACTIVITY IS NOT A NOTIFICATION. Replies you wrote to your own thread are indexed
103 * exactly like anyone else's, so without this filter the list fills with your own voice — which is
104 * both useless and the fastest way to make a user stop opening the page.
105 *
106 * Deduped by id: the same reply can arrive from more than one source (a fediverse reply that also
107 * federates back, a record seen via both spacedust paths).
108 */
109export function mergeNotifications ({ atproto = [], activitypub = [], viewerDid = null, viewerActors = [], limit = 50 } = {}) {
110 const mine = new Set([viewerDid, ...viewerActors].filter(Boolean))
111 const seen = new Set()
112 const out = []
113
114 for (const n of [...atproto, ...activitypub]) {
115 if (!n) continue
116 if (n.authorDid && mine.has(n.authorDid)) continue
117 if (n.id && seen.has(n.id)) continue
118 if (n.id) seen.add(n.id)
119 out.push(n)
120 }
121
122 // Undated items sort last rather than being dropped: "we do not know when" is not "it did not
123 // happen", and a notification with a missing timestamp is still a notification.
124 out.sort((a, b) => String(b.when || '').localeCompare(String(a.when || '')))
125 return out.slice(0, limit)
126}
lib/render.js +39 -3
……@@ -1605,8 +1605,43 @@ export function bookmarksPage ({ result = null, viewer = null, missing = 0 } = {
16051605// This is the ONE page that requires JavaScript, and it says so rather than appearing broken with
16061606// scripting off. Notifications are inherently authenticated and per-viewer; there is no meaningful
16071607// server-rendered version when the server deliberately holds nothing.
1608export function notificationsPage ({ viewer = null, pds = 'https://pds.7rnx.net' } = {}) {
1609 const body = `<div class="viewhdr">Notifications <span class="scopenote">· fetched by your browser, not by this node</span></div>
1608// One notification line. Kind decides the verb, network decides the badge — a reply from Mastodon
1609// and a reply from bsky are not interchangeable, and a UI that renders them identically is lying
1610// about where the conversation is happening.
1611function notifRow (n) {
1612 const verb = n.kind === 'reply' ? 'replied' : n.kind === 'like' ? 'liked' : n.kind === 'boost' ? 'boosted' : 'mentioned you'
1613 const net = n.network === 'activitypub' ? 'fediverse' : 'bsky'
1614 // n.text is already stripped of remote markup by notifications.js; esc() is the second line of
1615 // defence, not the first.
1616 const said = n.text ? `<div class="ntext">${esc(n.text.slice(0, 280))}</div>` : ''
1617 const when = n.when ? `<time datetime="${esc(n.when)}">${esc(relTime(n.when))}</time>` : '<span class="dim">unknown time</span>'
1618 const link = n.url ? `<a href="${esc(n.url)}" rel="noopener">open</a>` : ''
1619 return `<div class="notif"><div class="nhead"><strong>${esc(n.who || 'someone')}</strong> ${verb} <span class="netbadge">${net}</span> · ${when} ${link}</div>${said}</div>`
1620}
1621
1622// ⛔ THIS PAGE USED TO ASK FOR A SECOND CREDENTIAL. It rendered a shell and a form for a bsky APP
1623// PASSWORD, then fetched bsky's notification API in the browser — for an account this node already
1624// controls, showing bsky's view rather than ours, and blind to ActivityPub entirely (#45: "the 'did
1625// anyone reply to me' loop does not work").
1626//
1627// Now the node answers from what it already knows: the inbound lens folds replies that touch our
1628// content into the appview, and ap-bridge keeps what the fediverse sends. No credential, works with
1629// JavaScript off.
1630//
1631// ⚠ `apAvailable === false` is NOT "no fediverse replies" — it is "this node cannot see them",
1632// because ap-bridge is single-instance and may live on another host. Saying so is the difference
1633// between an empty page that is true and one that is a lie.
1634export function notificationsPage ({ viewer = null, pds = 'https://pds.7rnx.net', items = [], apAvailable = null } = {}) {
1635 const list = items.length
1636 ? items.map(notifRow).join('')
1637 : '<p class="empty">Nothing yet — replies, likes and boosts that touch your posts appear here.</p>'
1638 const apNote = apAvailable === false
1639 ? '<p class="scopenote">⚠ The ActivityPub side is not readable from this node, so fediverse replies are not shown here. They are not missing — this node cannot see them.</p>'
1640 : ''
1641 const body = `<div class="viewhdr">Notifications <span class="scopenote">· from this node, no credential</span></div>
1642${apNote}
1643<div class="notiflist">${list}</div>
1644<details class="bskybox"><summary>also fetch bsky's own notifications in this browser</summary>
16101645<noscript><p class="empty">Notifications need JavaScript. Everything else in favela works without it — this page is the exception, because the server holds no credential of yours and so cannot fetch them for you.</p></noscript>
16111646<form id="notif-login" class="authbox" hidden>
16121647 <p class="empty">Sign in with an <strong>app password</strong> — not your account password. It is used once to mint a session and is never stored; only the short-lived tokens are kept, in this browser.</p>
……@@ -1616,7 +1651,8 @@ export function notificationsPage ({ viewer = null, pds = 'https://pds.7rnx.net'
16161651</form>
16171652<p id="notif-status" aria-live="polite" class="empty"></p>
16181653<div id="notif-list"></div>
1619<p><button id="notif-signout" class="devbtn">forget this browser\'s notification session</button></p>`
1654<p><button id="notif-signout" class="devbtn">forget this browser\'s notification session</button></p>
1655</details>`
16201656 return page({
16211657 title: 'notifications · favela',
16221658 viewer,
package.json +2 -2
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-web",
3 "version": "0.3.61",
3 "version": "0.3.62",
44 "description": "favela-web — the web/SSR surface of Favela, the native P2P-local, community-owned social client + appview over the self-custody atproto stack (hyper-proto-peer). One of the Favela surfaces (alongside favela-cli and favela-native). bsky is an optional POSSE export, not the home. See DESIGN.md.",
55 "type": "module",
66 "main": "bin/favela-web.js",
……@@ -55,7 +55,7 @@
5555 "spike:seam": "node spike/atcute-seam.mjs",
5656 "spike:p2p": "node spike/p2p-read.mjs",
5757 "web": "bare bin/favela-web.js",
58 "test": "node test/render.test.mjs && node test/static-assets.test.mjs && node test/resize-plan.test.mjs && node test/ap-followers.test.mjs && node test/reach.test.mjs && node test/strip-vectors.test.mjs && node test/edit.test.mjs && node test/facets.test.mjs && node test/mutes.test.mjs && node test/search.test.mjs && node test/threadgate.test.mjs && node test/bookmarks.test.mjs && node test/postgate.test.mjs && node test/lists.test.mjs && node test/cli-reachable.test.mjs && node test/strongref.test.mjs && node test/lens-import.test.mjs && node test/lens-thread-extract.test.mjs && node test/fronts-diag.test.mjs && node test/gate-policy.test.mjs && node test/sessions.test.mjs && node test/check.test.mjs && node test/auth.test.mjs && node test/browser-auth-client.test.mjs && node test/communities.test.mjs && node test/feedgen.test.mjs && node test/feed-reactor.test.mjs && node test/labeler-export.test.mjs && node test/label-firehose.test.mjs && node test/pairing.test.mjs && node test/writer.test.mjs && node test/blob-serve.test.mjs && node test/blobs.test.mjs && node test/spacedust.test.mjs && node test/spacedust-failclosed.test.mjs && node test/link-drain.test.mjs && node test/record-verify.test.mjs && node test/constellation.test.mjs && node test/retention.test.mjs && node test/hosted-dids.test.mjs && node test/repair-bytes.test.mjs && node test/repo-sync.test.mjs && node test/blob-refs.test.mjs && node test/inbound.test.mjs && node test/followers.test.mjs && node test/repair-strongrefs.test.mjs && node test/conformance.test.mjs",
58 "test": "node test/render.test.mjs && node test/static-assets.test.mjs && node test/resize-plan.test.mjs && node test/notifications.test.mjs && node test/ap-followers.test.mjs && node test/reach.test.mjs && node test/strip-vectors.test.mjs && node test/edit.test.mjs && node test/facets.test.mjs && node test/mutes.test.mjs && node test/search.test.mjs && node test/threadgate.test.mjs && node test/bookmarks.test.mjs && node test/postgate.test.mjs && node test/lists.test.mjs && node test/cli-reachable.test.mjs && node test/strongref.test.mjs && node test/lens-import.test.mjs && node test/lens-thread-extract.test.mjs && node test/fronts-diag.test.mjs && node test/gate-policy.test.mjs && node test/sessions.test.mjs && node test/check.test.mjs && node test/auth.test.mjs && node test/browser-auth-client.test.mjs && node test/communities.test.mjs && node test/feedgen.test.mjs && node test/feed-reactor.test.mjs && node test/labeler-export.test.mjs && node test/label-firehose.test.mjs && node test/pairing.test.mjs && node test/writer.test.mjs && node test/blob-serve.test.mjs && node test/blobs.test.mjs && node test/spacedust.test.mjs && node test/spacedust-failclosed.test.mjs && node test/link-drain.test.mjs && node test/record-verify.test.mjs && node test/constellation.test.mjs && node test/retention.test.mjs && node test/hosted-dids.test.mjs && node test/repair-bytes.test.mjs && node test/repo-sync.test.mjs && node test/blob-refs.test.mjs && node test/inbound.test.mjs && node test/followers.test.mjs && node test/repair-strongrefs.test.mjs && node test/conformance.test.mjs",
5959 "test:ws": "bare test/subscribe-labels-ws.test.mjs",
6060 "test:bare": "bare test/appview.test.mjs && bare test/appview-ingest.test.mjs && bare test/appview-dedup.test.mjs && bare test/appview-no-clobber.test.mjs && bare test/appview-blocks.test.mjs && bare test/appview-thread.test.mjs && bare test/appview-author-filter.test.mjs && bare test/appview-collections.test.mjs",
6161 "spike:pack": "bare spike/mobile-pack-probe.mjs",
test/notifications.test.mjs +133 -0
……@@ -0,0 +1,133 @@
1// "Did anyone reply to me" — from this node's own knowledge, both networks, no second credential.
2//
3// The page this feeds used to be a shell that asked the browser for a bsky APP PASSWORD and fetched
4// bsky's notification API client-side: a second credential for an account already controlled here, a
5// view that was bsky's rather than ours, and total blindness to ActivityPub. hyper-social #45 listed
6// it among the five gaps that decide whether favela is a product.
7import { fromApItem, fromReply, mergeNotifications, actorHandle, htmlToText } from '../lib/notifications.js'
8
9let pass = 0
10const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m); pass++ }
11const eq = (a, b, m) => ok(a === b, `${m} (got ${JSON.stringify(a)}, want ${JSON.stringify(b)})`)
12
13// A real item, copied from what ap-bridge actually stored when prsm.space replied on 2026-08-18.
14const AP_REPLY = {
15 id: 'https://prsm.space/users/fleeky/statuses/117118370829261804/activity',
16 type: 'Create',
17 actor: 'https://prsm.space/users/fleeky',
18 objectId: 'https://prsm.space/users/fleeky/statuses/117118370829261804',
19 inReplyTo: 'https://ap.7rnx.net/ap/objects/3mtewvmoqec22',
20 content: '<p><span class="h-card"><a href="https://ap.7rnx.net/">@<span>p2p</span></a></span> this computer needs a can of air</p>',
21 url: 'https://prsm.space/@fleeky/117118370829261804',
22 published: '2026-08-18T20:21:55Z',
23 receivedAt: 1787084518154
24}
25
26// — actor names —
27eq(actorHandle('https://prsm.space/users/fleeky'), '@fleeky@prsm.space', 'a fediverse actor reads as the handle its owner knows')
28eq(actorHandle('nonsense'), 'nonsense', 'an unparseable actor is passed through, not crashed on')
29
30// — remote HTML is TEXT here —
31// ⛔ ap-bridge stores content verbatim, which is right: it is not the store's job to decide how it
32// renders. A notification line is not the place to reproduce someone else's markup.
33eq(htmlToText('<p>hello<br />there</p>'), 'hello there', 'tags become spaces')
34ok(!htmlToText(AP_REPLY.content).includes('<'), 'no markup survives into a notification line')
35eq(htmlToText(AP_REPLY.content), '@p2p this computer needs a can of air', 'the words survive, and the mention reads as @p2p rather than being split by the h-card markup')
36
37// — the AP reply —
38{
39 const n = fromApItem(AP_REPLY)
40 eq(n.kind, 'reply', 'Create{Note} is a reply')
41 eq(n.network, 'activitypub', 'and it is labelled as fediverse')
42 eq(n.who, '@fleeky@prsm.space')
43 eq(n.when, '2026-08-18T20:21:55Z', 'the author time, not when we happened to receive it')
44 eq(n.subject, 'https://ap.7rnx.net/ap/objects/3mtewvmoqec22', 'which of OUR posts it answers')
45 ok(n.text.includes('can of air'), 'and what they said')
46}
47
48// Likes and boosts carry no text and must not pretend to.
49eq(fromApItem({ type: 'Like', actor: 'https://a.net/users/b', objectId: 'https://ap.7rnx.net/ap/objects/x' }).kind, 'like', 'Like')
50eq(fromApItem({ type: 'Announce', actor: 'https://a.net/users/b', objectId: 'https://ap.7rnx.net/ap/objects/x' }).kind, 'boost', 'Announce')
51eq(fromApItem({ type: 'Like', actor: 'https://a.net/users/b' }).text, '', 'a like has no text')
52eq(fromApItem({ type: 'Follow', actor: 'https://a.net/users/b' }), null, 'a Follow is not presented here (yet) rather than mislabelled')
53eq(fromApItem(null), null, 'nothing in, nothing out')
54
55// — the atproto reply —
56{
57 const n = fromReply({ did: 'did:plc:alice', handle: 'alice.example', uri: 'at://did:plc:alice/app.bsky.feed.post/r1', record: { text: 'nice', createdAt: '2026-08-18T10:00:00Z' } }, 'at://did:plc:wj62/app.bsky.feed.post/p1')
58 eq(n.kind, 'reply')
59 eq(n.network, 'atproto', 'labelled as atproto — a reply from Mastodon and one from bsky are not interchangeable')
60 eq(n.who, 'alice.example')
61 eq(n.subject, 'at://did:plc:wj62/app.bsky.feed.post/p1')
62}
63
64// A handle-less author falls back to a SHORT did, never 32 raw characters.
65{
66 const n = fromReply({ did: 'did:plc:wj62ebbusln6gxo5azo6ogzo', uri: 'at://x/y/z', record: { text: 'hi' } }, 'at://s')
67 eq(n.who, 'wj62ebbu…ogzo', 'readable, not decoration')
68}
69
70// — the merge —
71{
72 const merged = mergeNotifications({
73 atproto: [fromReply({ did: 'did:plc:alice', handle: 'alice.example', uri: 'at://a/1', record: { text: 'older', createdAt: '2026-08-18T09:00:00Z' } }, 'at://s')],
74 activitypub: [fromApItem(AP_REPLY)]
75 })
76 ok(merged.length === 2, 'both networks in ONE list — two inboxes is how you get a confused user')
77 eq(merged[0].network, 'activitypub', 'newest first, regardless of source')
78 eq(merged[1].when, '2026-08-18T09:00:00Z')
79}
80
81// ⛔ Your own replies are indexed exactly like anyone else's, so without this the page fills with
82// your own voice.
83{
84 const mine = { ...fromReply({ did: 'did:plc:me', handle: 'me', uri: 'at://mine/1', record: { text: 'self', createdAt: '2026-08-18T23:00:00Z' } }, 'at://s'), authorDid: 'did:plc:me' }
85 const theirs = { ...fromReply({ did: 'did:plc:alice', handle: 'alice', uri: 'at://a/2', record: { text: 'theirs', createdAt: '2026-08-18T22:00:00Z' } }, 'at://s'), authorDid: 'did:plc:alice' }
86 const merged = mergeNotifications({ atproto: [mine, theirs], viewerDid: 'did:plc:me' })
87 eq(merged.length, 1, 'your own reply is not a notification')
88 eq(merged[0].who, 'alice')
89}
90
91// The same reply can arrive twice — a fediverse reply that also federates back, or both spacedust
92// subject paths hitting one record.
93{
94 const a = fromApItem(AP_REPLY)
95 const merged = mergeNotifications({ activitypub: [a, { ...a }] })
96 eq(merged.length, 1, 'deduped by id')
97}
98
99// An undated item sorts last rather than vanishing: "we do not know when" is not "it did not happen".
100{
101 const undated = { kind: 'like', network: 'activitypub', who: '@x@y.z', when: null, id: 'u1' }
102 const dated = fromApItem(AP_REPLY)
103 const merged = mergeNotifications({ activitypub: [undated, dated] })
104 eq(merged.length, 2)
105 eq(merged[1].id, 'u1', 'undated last, still present')
106}
107
108// ── the AP inbox reader ─────────────────────────────────────────────────────────────────────────
109//
110// ⛔ null vs [] IS THE WHOLE CONTRACT. ap-bridge is single-instance, so a favela-web on any other
111// host cannot read it. "You have no replies" and "this node cannot see the fediverse" must not
112// render alike — the same distinction the AP reach reader already draws.
113{
114 const { createApInbox } = await import('../lib/ap-inbox.js')
115
116 const okClient = { call: async () => ({ ok: true, items: [AP_REPLY] }), close: async () => {} }
117 const got = await createApInbox({ sockPath: '/s', exists: () => true, connect: async () => okClient }).list({})
118 eq(Array.isArray(got) && got.length, 1, 'items when the bridge answers')
119
120 const none = await createApInbox({ sockPath: '/s', exists: () => true, connect: async () => ({ call: async () => ({ ok: true, items: [] }), close: async () => {} }) }).list({})
121 eq(Array.isArray(none) && none.length, 0, 'EMPTY when it answered and had nothing')
122
123 const noSock = await createApInbox({ sockPath: '/s', exists: () => false, connect: async () => okClient }).list({})
124 eq(noSock, null, 'NULL when there is no ap-bridge here at all')
125
126 const broke = await createApInbox({ sockPath: '/s', exists: () => true, connect: async () => { throw new Error('ECONNREFUSED') } }).list({})
127 eq(broke, null, 'NULL when it is down — never a throw, the atproto half must still render')
128
129 const oldVersion = await createApInbox({ sockPath: '/s', exists: () => true, connect: async () => ({ call: async () => { throw new Error('unknown method') }, close: async () => {} }) }).list({})
130 eq(oldVersion, null, 'NULL against an ap-bridge too old to have the verb')
131}
132
133console.log(`\nNOTIFICATIONS OK: ${pass} assertions`)
test/render.test.mjs +31 -4
……@@ -630,11 +630,17 @@ has(shell2, 'a:focus-visible,button:focus-visible', 'every interactive element g
630630
631631
632632// ── notifications ────────────────────────────────────────────────────────────────────────────────
633// The server renders the SHELL ONLY. hpp requires a real user bearer to mint service auth — deliberately,
634// so an allowlisted peer cannot mint a signing oracle with nobody present — and holding that bearer here
635// would make favela-web a credential store, which is the thing it is built not to be.
633// ⛔ THE SERVER NOW ANSWERS. This page used to render a shell and ask the BROWSER for a bsky app
634// password, because the node holds no credential of yours — true, and beside the point: it does not
635// need one. The inbound lens already folds replies that touch our content into the appview, and
636// ap-bridge keeps what the fediverse sends. #45 listed "the 'did anyone reply to me' loop does not
637// work" among the five gaps that decide whether this is a product.
638//
639// The bsky-app-password path is NOT deleted — it fetches something we genuinely cannot (bsky's own
640// notification state, incl. things touching accounts we do not host) — it is demoted into a
641// <details> so the default page is the one that needs no credential.
636642const np = notificationsPage({ viewer, pds: 'https://pds.example' })
637has(np, 'fetched by your browser, not by this node', 'the page states where the fetch happens')
643has(np, 'from this node, no credential', 'the page states where the answer comes from')
638644has(np, 'id="notif-login"', 'and offers a sign-in form')
639645has(np, 'app password</strong>', 'asking for an APP password, not the account password')
640646has(np, 'never stored', 'and says the password is not stored')
……@@ -643,6 +649,27 @@ has(np, 'name="favela-pds" content="https://pds.example"', 'the PDS origin is pa
643649has(np, '/static/notifications.js', 'and the client script is loaded')
644650// It is the ONE page that needs JS, so it says so instead of appearing broken.
645651has(np, '<noscript>', 'it degrades with an explanation, not silence')
652
653// The real list, server-rendered.
654{
655 const n = (over = {}) => ({ kind: 'reply', network: 'activitypub', who: '@fleeky@prsm.space', when: '2026-08-18T20:21:55Z', text: 'this computer needs a can of air', url: 'https://prsm.space/@fleeky/1', id: '1', ...over })
656 const withItems = notificationsPage({ viewer, items: [n()] })
657 has(withItems, '@fleeky@prsm.space', 'the author is named')
658 has(withItems, 'can of air', 'and what they said is rendered by the SERVER — no JS needed')
659 has(withItems, 'replied', 'the verb says what happened')
660 has(withItems, 'fediverse', 'and the network is badged — a Mastodon reply is not a bsky reply')
661 has(notificationsPage({ viewer, items: [n({ network: 'atproto', who: 'alice.example' })] }), 'bsky', 'the other network is badged too')
662
663 // Remote text is escaped at render even though notifications.js already stripped tags: the store
664 // keeps content verbatim by design, so this is the second line of defence, not the first.
665 const nasty = notificationsPage({ viewer, items: [n({ text: '<img src=x onerror=alert(1)>' })] })
666 not(nasty, '<img src=x', 'remote content cannot inject markup')
667
668 // ⛔ "no replies" and "this node cannot see the fediverse" are different pages.
669 has(notificationsPage({ viewer, items: [], apAvailable: false }), 'cannot see them', 'an unreadable AP side SAYS so')
670 not(notificationsPage({ viewer, items: [], apAvailable: true }), 'cannot see them', 'and stays quiet when it can see')
671 has(notificationsPage({ viewer, items: [] }), 'Nothing yet', 'a genuinely empty list reads as empty')
672}
646673has(np, 'Everything else in favela works without it', 'naming this as the deliberate exception')
647674// The server must never be handed the credential.
648675not(np, 'action="/api/', 'the form posts to NO favela endpoint — the browser talks to the PDS directly')