// inbound-store.js — what the fediverse sent us.
//
// ⛔ THE GAP THIS CLOSES. The inbox verified every signature, logged "accepted", returned 202 — and
// then dropped everything that was not a Follow on the floor (`default:` in router.js). So a reply
// from a Mastodon user, a mention, a boost of our post, a like: all received, all acknowledged so
// the sender considered them delivered, none of them kept. We have been broadcasting into a room we
// could not hear.
//
// The atproto side already does the opposite: spacedust subscribes to
// `app.bsky.feed.post:reply.parent.uri` precisely so we "receive exactly the interactions that touch
// our own content — replies, quotes, likes, follows", and inbound.js verifies and retains them. This
// is that capability for the other network.
//
// ⚠ NOT AN APPVIEW. This keeps what arrived, addressed to us, so it can be READ. It does not index,
// thread, or render — those are favela-web's job and a later increment. Storing it is the
// precondition; the value shows up when something displays it.
//
// JSON file, like followers.js and state.js. The skill records the intended end state (HyperDB,
// DESIGN.md §3) — this deliberately matches the existing M1 shape rather than inventing a third
// storage pattern for the same lifetime of data.
import fs from 'bare-fs'
import path from 'bare-path'
// ⛔ BOUNDED BY CONSTRUCTION. An inbox is written by REMOTE servers: whatever we accept, someone
// else decides how much of it there is. An unbounded file here is a disk-exhaustion bug with a
// signature check in front of it, not a feature. Newest-first, oldest evicted.
export const DEFAULT_CAP = 500
// Keep the fields a notification needs and drop the rest. A remote Note carries attachments,
// emojis, tags and instance-specific extensions; storing the whole thing means storing whatever a
// remote server decides to send, forever.
// ActivityPub's magic audience. An activity addressed to it is public by definition; anything else —
// a followers-only post, a direct message — is not, whatever else it looks like.
//
// ⚠ THREE SPELLINGS IN THE WILD. The full URI is canonical, `as:Public` is the compacted JSON-LD form,
// and bare `Public` appears from older implementations. Matching only the first quietly treats a public
// post as private, which fails in the safe direction but hides half the fediverse from a public page.
const PUBLIC_URIS = new Set([
'https://www.w3.org/ns/activitystreams#Public',
'as:Public',
'Public'
])
const audience = (x) => {
if (!x) return []
const v = [].concat(x.to || [], x.cc || [], x.bto || [], x.bcc || [])
return v.filter((a) => typeof a === 'string')
}
/**
* Was this addressed to the public?
*
* ⛔ THE ANSWER GATES A PUBLIC PAGE, so it fails closed: anything this cannot positively show to be
* public is treated as private. Mastodon delivers DIRECT MESSAGES to an inbox exactly like it delivers
* replies, and they arrive at the same handler — so an inbound store that could not tell them apart
* would publish somebody's DM the moment anything rendered it without a login.
*/
export function isPublicActivity (activity) {
const obj = activity && typeof activity.object === 'object' ? activity.object : null
for (const a of audience(activity).concat(audience(obj))) {
if (PUBLIC_URIS.has(a)) return true
}
return false
}
// ⛔ AN INTERACTION CARRIES NO AUDIENCE, AND ABSENCE IS NOT EVIDENCE OF PRIVACY.
//
// `isPublicActivity` looks for to/cc on the activity or its object. A Like has neither — its `object`
// is a bare URI string and Mastodon sends no addressing at all — so "no audience" read as "not
// public" and EVERY inbound like, boost and follow was withheld by construction. Measured on the live
// bridge 2026-09-15: one Like from @fleeky@prsm.space, stored `public: false`, invisible on a page
// built to show precisely that.
//
// An interaction's visibility is not its own property: it belongs to the thing it acted on, which
// here is one of our own posts, and those are public. That is also what Mastodon does — favourites
// and boosts are visible to anyone who can see the post.
//
// ⚠ THE CONTENT RULE IS UNTOUCHED. A Create or Update with no addressing is still withheld, because a
// DIRECT MESSAGE arrives at the same inbox through the same handler, and that is the one mistake this
// whole area exists to prevent. Only the types below change, and an Announce — which CAN carry
// addressing, unlike a Like — still has its stated audience honoured when it supplies one.
const INTERACTION_TYPES = new Set(['Like', 'Announce', 'Follow'])
export function isInteraction (activity) {
return !!(activity && INTERACTION_TYPES.has(activity.type))
}
// Was this addressed at all? Distinguishes "said nothing about audience" (an interaction) from
// "said followers-only" (a boost that means it).
function hasAddressing (activity) {
return audience(activity).length > 0 ||
(activity && typeof activity.object === 'object' ? audience(activity.object).length > 0 : false)
}
/** Should this appear on a page with no login in front of it? */
export function isShowable (activity) {
if (isInteraction(activity)) {
// Stated privacy beats the default; silence means the protocol had nothing to say.
return hasAddressing(activity) ? isPublicActivity(activity) : true
}
return isPublicActivity(activity)
}
/**
* Should a STORED item appear? `public` was recorded at arrival and cannot be recomputed — the
* addressing is gone — so this is the stored value, with one narrow exception.
*
* ⛔ A Like or a Follow NEVER carries an audience, so a stored `false` on one is not a privacy
* decision: it is the old rule mistaking silence for refusal. Re-deriving those is reading the type,
* not inventing addressing we lost.
*
* ⚠ An Announce is NOT in that exception. It can carry an audience, so a stored value for one might
* be a real answer, and treating it as an artifact could publish a followers-only boost. There are
* none on the live bridge; new ones get the correct rule at arrival.
*/
export function showableStored (item) {
if (!item) return false
if (item.type === 'Like' || item.type === 'Follow') return true
return item.public === true
}
export function normalize (activity, receivedAt, recipient = null) {
const obj = activity && typeof activity.object === 'object' ? activity.object : null
const objectId = obj ? obj.id : (typeof activity?.object === 'string' ? activity.object : null)
return {
id: activity?.id || null,
type: activity?.type || null,
actor: typeof activity?.actor === 'string' ? activity.actor : activity?.actor?.id || null,
objectId: objectId || null,
// inReplyTo is what makes a Note a REPLY to one of our posts rather than an unrelated mention.
inReplyTo: obj?.inReplyTo || null,
// Content is HTML from a remote server. Kept verbatim and NEVER rendered as trusted markup —
// whatever displays this must escape or sanitise it. Truncated: a notification does not need a
// 40 KB post body, and the full object is always refetchable by id.
content: typeof obj?.content === 'string' ? obj.content.slice(0, 4000) : null,
url: obj?.url || objectId || null,
// ⛔ THE PICTURES, OR A REPLY WITH ONE IS SHOWN AS EMPTY TEXT. Kept as {url, mediaType, name}
// only — `name` is the alt text, and the rest of a remote attachment is whatever that server
// decided to send. Bounded here as well as at render: this is the copy that goes to disk.
attachment: Array.isArray(obj?.attachment)
? obj.attachment.filter((a) => a && typeof a.url === 'string')
.slice(0, 8)
.map((a) => ({ url: a.url, mediaType: a.mediaType || null, name: a.name || null }))
: null,
published: obj?.published || activity?.published || null,
// ⛔ RECORDED AT ARRIVAL, because the addressing is not recoverable later — `to`/`cc` are dropped
// by this very function, and the object is only refetchable while the remote server still serves
// it. Anything stored before this field existed is `undefined`, which every reader must treat as
// NOT public; see the note on isPublicActivity.
public: isShowable(activity),
// ⛔ WHOSE MAIL IS THIS. One InboundStore serves the whole process while the inbox PATHS are
// per-user (`/ap/users/<name>/inbox`), so without this a kept item cannot say who it was
// delivered to. That is harmless while only the operator reads the store and a leak the moment a
// peer can ask for its own inbound — which is the only reason to keep it at all.
//
// ⚠ FORWARD-ONLY. `to`/`cc` are dropped above and the object is refetchable only while the
// remote server still serves it, so anything stored before this field is permanently
// unattributable and reads as `undefined`. A per-user read must exclude those rather than
// guess — the same fail-closed posture `public` takes, and for the same reason.
recipient: recipient || null,
receivedAt
}
}
export class InboundStore {
constructor ({ file, cap = DEFAULT_CAP } = {}) {
this.file = file
this.cap = cap
this._items = null
}
_load () {
if (this._items) return this._items
try {
const raw = JSON.parse(fs.readFileSync(this.file, 'utf8'))
this._items = Array.isArray(raw) ? raw : []
} catch {
this._items = []
}
return this._items
}
_save () {
try {
fs.mkdirSync(path.dirname(this.file), { recursive: true })
fs.writeFileSync(this.file, JSON.stringify(this._items, null, 2) + '\n')
} catch { /* a failed write must not fail the inbox: the 202 is already owed */ }
}
async add (item) {
const items = this._load()
// Remote servers retry, and a retry is not a second event. Dedup on activity id.
if (item.id && items.some((i) => i.id === item.id)) return false
items.unshift(item)
if (items.length > this.cap) items.length = this.cap
this._save()
return true
}
// Delete{Note} — the object is gone on the origin, so it must go here too. Returns how many were
// removed, because "nothing to delete" and "deleted" are different outcomes worth logging apart.
async removeObject (objectId) {
if (!objectId) return 0
const items = this._load()
const before = items.length
this._items = items.filter((i) => i.objectId !== objectId)
if (this._items.length !== before) this._save()
return before - this._items.length
}
/**
* A page of what arrived, newest first.
*
* ⛔ OFFSET, NOT A CURSOR, and deliberately. The store is newest-first and BOUNDED — eviction happens
* at the head, so an offset can only ever drift by however many arrived while an operator was
* reading, which shows up as a repeated row rather than a lost one. A cursor would be the right
* answer for a log that grows without limit; this one is capped by construction.
*/
async list ({ limit = 50, offset = 0, recipient = null } = {}) {
const from = Math.max(0, Number(offset) || 0)
// ⛔ FILTERED BEFORE PAGING. Paging first and filtering second yields short pages and an offset
// that no longer means what the caller asked for — the same mistake the public timeline had to
// fix, recorded at its call site.
const all = recipient ? this._load().filter((i) => i.recipient === recipient) : this._load()
return all.slice(from, from + limit)
}
/** How many are held, so a page can say whether there is more without fetching it. */
async count () { return this._load().length }
}
// The test double, matching MemoryFollowerStore / MemoryState.
export class MemoryInboundStore {
constructor (items = [], cap = DEFAULT_CAP) { this.items = items; this.cap = cap }
async add (item) {
if (item.id && this.items.some((i) => i.id === item.id)) return false
this.items.unshift(item)
if (this.items.length > this.cap) this.items.length = this.cap
return true
}
async removeObject (objectId) {
const before = this.items.length
this.items = this.items.filter((i) => i.objectId !== objectId)
return before - this.items.length
}
// ⚠ The SAME filter as the real store. A double that ignores an option makes every test using it
// a statement about the double rather than about production.
async list ({ limit = 50, offset = 0, recipient = null } = {}) {
const from = Math.max(0, Number(offset) || 0)
const all = recipient ? this.items.filter((i) => i.recipient === recipient) : this.items
return all.slice(from, from + limit)
}
async count () { return this.items.length }
}