fedipod 1.8.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,6 +37,9 @@ Which specs FediPod follows, and where it stops short: [specs-in-use.md](specs-i
37
37
  4. Enter your pod password once. It creates the account and locks your
38
38
  signing key. The password is not stored.
39
39
 
40
+ Anyone can open your profile at `https://fedipod.net/@handle`, signed in or
41
+ not.
42
+
40
43
  Your account then opens in the client. To use it from another browser, go to
41
44
  https://fedipod.net, enter your address, sign in at your pod, and unlock your
42
45
  key with your password once on that browser.
package/groups.md CHANGED
@@ -55,6 +55,7 @@ queue is its own list, separate from post review, and appears on the group's
55
55
  admin page as **Moderation requests** when something is waiting: carry it out,
56
56
  or turn it down.
57
57
  A followed group's own announced deletion of a post it carried to you is honoured.
58
+ A group that withdraws a post it carried to you takes it off your timeline too.
58
59
 
59
60
  ## Inviting people
60
61
 
@@ -175,7 +175,7 @@ export class C2S {
175
175
  + 'actor document names; this address is the owner reading their own' });
176
176
  }
177
177
  const reader = await this.auth(req, pathname);
178
- if (!reader.ok) return this.send(res, reader.status, { error: reader.error });
178
+ if (!reader.ok) return this.send(res, reader.status, { error: reader.error }, reader.headers || {});
179
179
  return this.sendInbox(res, url);
180
180
  }
181
181
  if (req.method === 'GET' || req.method === 'HEAD') {
@@ -190,7 +190,7 @@ export class C2S {
190
190
  }
191
191
 
192
192
  const who = await this.auth(req, pathname);
193
- if (!who.ok) return this.send(res, who.status, { error: who.error });
193
+ if (!who.ok) return this.send(res, who.status, { error: who.error }, who.headers || {});
194
194
 
195
195
  // A viewer (another agent holds the lease) may not act — but the user
196
196
  // acting HERE outranks an idle active agent elsewhere, exactly as on the
@@ -262,6 +262,16 @@ export class C2S {
262
262
  }
263
263
  }
264
264
 
265
+ // The actors a client addressed by id, beyond the audience words: the ones
266
+ // in to/cc are listed and delivered to; the ones in bto/bcc are delivered
267
+ // to and never listed (§6).
268
+ addressedActors(activity, object) {
269
+ const pick = (...fields) => [...new Set(fields.flatMap((f) => arr(activity[f] ?? object?.[f]).map(idOf)))]
270
+ .filter((a) => typeof a === 'string' && /^https?:\/\//u.test(a)
271
+ && a !== wire.PUBLIC && a !== this.urls.followers && a !== this.urls.actor);
272
+ return { also: pick('to', 'cc'), deliverTo: pick('bto', 'bcc') };
273
+ }
274
+
265
275
  async _dispatch(activity, { slug, raw, reply }) {
266
276
  const agent = this.agent;
267
277
  const object = typeof activity.object === 'object' && activity.object !== null
@@ -273,13 +283,14 @@ export class C2S {
273
283
  const makes = object?.type || 'Note';
274
284
  if (!object) return reply(422, { error: 'a Create carries the object it creates' });
275
285
  const visibility = this.visibilityOf(activity, object);
286
+ const { also, deliverTo } = this.addressedActors(activity, object);
276
287
  // Not a Note and not a poll: stored as sent, under this actor, and
277
288
  // the Create around it delivered — a Web Annotation, for one.
278
289
  if (makes !== 'Note' && makes !== 'Question') {
279
290
  const asSent = raw && typeof raw === 'object' && !Array.isArray(raw)
280
291
  ? (ACTIVITY_TYPES.has(raw.type) ? (raw.object && typeof raw.object === 'object' ? raw.object : null) : raw)
281
292
  : null;
282
- const made = await agent.publisher.publishObject(asSent || object, { visibility, slug });
293
+ const made = await agent.publisher.publishObject(asSent || object, { visibility, slug, also, deliverTo });
283
294
  return reply(201, { id: made.createId, object: made.id }, { location: made.createId });
284
295
  }
285
296
  // `source.content` is the client's plain text when it sends one; bare
@@ -302,6 +313,7 @@ export class C2S {
302
313
  inReplyTo: idOf(object.inReplyTo) || undefined,
303
314
  visibility,
304
315
  spoilerText: object.summary || null,
316
+ sensitive: object.sensitive === true,
305
317
  });
306
318
  return reply(201,
307
319
  { id: wire.createActivityId(question.id), object: question.id },
@@ -320,7 +332,8 @@ export class C2S {
320
332
  attachments,
321
333
  visibility,
322
334
  spoilerText: object.summary || null,
323
- slug,
335
+ sensitive: object.sensitive === true,
336
+ slug, also, deliverTo,
324
337
  });
325
338
  return reply(201, { id: wire.createActivityId(note.id), object: note.id },
326
339
  { location: wire.createActivityId(note.id) });
@@ -344,7 +357,8 @@ export class C2S {
344
357
  })).filter((a) => a.url)
345
358
  : null;
346
359
  await agent.publisher.updateNote(s, {
347
- content: text, spoilerText: object?.summary || null, attachments,
360
+ content: text, spoilerText: object?.summary || null,
361
+ sensitive: object?.sensitive === undefined ? null : object.sensitive === true, attachments,
348
362
  });
349
363
  return reply(200, { ok: true, object: s.noteId });
350
364
  }
@@ -117,7 +117,15 @@ export class MastoApi {
117
117
  res.end(body);
118
118
  return true;
119
119
  };
120
- const ctx = { req, res, pathname, url, send };
120
+ // The address the client reached, so every self-URL handed back — the
121
+ // pagination links above all — names it rather than the pod's host.
122
+ const scheme = String(this.scheme
123
+ || (req.headers?.['x-forwarded-proto'] === 'https' || req.socket?.encrypted ? 'https' : 'http')).replace(/:.*$/, '');
124
+ let self = url;
125
+ try {
126
+ self = new URL(`${scheme}://${req.headers?.host || 'localhost'}${this.mount || ''}${pathname}${url.search || ''}`);
127
+ } catch { /* a Host nothing can parse: the links keep the placeholder origin */ }
128
+ const ctx = { req, res, pathname, url: self, send };
121
129
 
122
130
  if (await oauth.handle(this, ctx)) return true;
123
131
  if (!pathname.startsWith('/api/')) return false;
@@ -46,7 +46,7 @@ export function account(api, actorUrl, { selfAcct } = {}) {
46
46
  // asking about itself would be told it was a person.
47
47
  bot: false, discoverable: true,
48
48
  group: self ? api.store.getConfig()?.kind === 'group' : cached.type === 'Group',
49
- created_at: '2026-01-01T00:00:00.000Z',
49
+ created_at: (self && api.store.getConfig()?.createdAt) || '2026-01-01T00:00:00.000Z',
50
50
  note: (self ? api.store.getConfig()?.summary : cached.summary) || '',
51
51
  url: actorUrl, uri: actorUrl,
52
52
  avatar: selfIcon(api, self, cached) || TRANSPARENT_PNG,
@@ -102,7 +102,9 @@ export function page(api, items, url, { limit = 20, max = 40, idOf = (s) => api.
102
102
  }
103
103
  const pageItems = items.slice(0, n);
104
104
  if (!pageItems.length) return { items: pageItems, headers: {} };
105
- const base = `http://${api.host}${url.pathname}`;
105
+ // `url` is the request as the client addressed it (index.mjs handle), so
106
+ // the links name that origin and mount, not the pod's host.
107
+ const base = url.origin + url.pathname;
106
108
  const q = (extra) => {
107
109
  const u = new URL(base);
108
110
  for (const [k, v] of url.searchParams) if (!['max_id', 'since_id', 'min_id'].includes(k)) u.searchParams.set(k, v);
@@ -222,7 +224,7 @@ export function status(api, s, { all } = {}) {
222
224
  created_at: s.published || new Date().toISOString(),
223
225
  in_reply_to_id: s.inReplyTo ? api.store.idFor(s.inReplyTo) : null,
224
226
  in_reply_to_account_id: null,
225
- sensitive: !!s.spoiler, spoiler_text: s.spoiler || '',
227
+ sensitive: !!(s.spoiler || s.sensitive), spoiler_text: s.spoiler || '',
226
228
  visibility: s.visibility || 'public', language: null,
227
229
  edited_at: s.editedAt || null,
228
230
  uri: s.noteId, url: s.link || s.noteId,
@@ -251,7 +253,7 @@ export function status(api, s, { all } = {}) {
251
253
  acct: bare.includes('@') ? bare : (host ? `${user}@${host}` : bare),
252
254
  };
253
255
  }),
254
- tags: [],
256
+ tags: (s.tags || []).map(t => ({ name: t.name, url: t.url })),
255
257
  emojis: (s.emojis || []).map(e => ({
256
258
  shortcode: e.shortcode, url: e.url, static_url: e.url, visible_in_picker: false,
257
259
  })),
@@ -37,6 +37,7 @@ export async function handle(api, ctx) {
37
37
  if (ready !== true) return send(422, { error: ready });
38
38
  }
39
39
  const spoilerText = String(body.spoiler_text || '').trim() || null;
40
+ const sensitive = body.sensitive === true || body.sensitive === 'true' || body.sensitive === '1';
40
41
  const mediaIds = [].concat(body.media_ids || body['media_ids[]'] || []).filter(Boolean);
41
42
  const media = api.store.getMedia();
42
43
  const attachments = mediaIds.map(id => media[id] && { id, ...media[id] }).filter(Boolean);
@@ -67,7 +68,7 @@ export async function handle(api, ctx) {
67
68
  const q = await api.agent.publisher.publishQuestion(body.status, {
68
69
  options: asking.options, multiple: asking.multiple,
69
70
  expiresAt: new Date(Date.now() + seconds * 1000).toISOString(),
70
- inReplyTo, visibility, spoilerText,
71
+ inReplyTo, visibility, spoilerText, sensitive,
71
72
  });
72
73
  return send(200, api.status(api.store.getStatuses().find(x => x.noteId === q.id)));
73
74
  } catch (e) {
@@ -98,7 +99,7 @@ export async function handle(api, ctx) {
98
99
  let note;
99
100
  try {
100
101
  note = await api.agent.publisher.publishNote(body.status,
101
- { inReplyTo, attachments, visibility, spoilerText });
102
+ { inReplyTo, attachments, visibility, spoilerText, sensitive });
102
103
  } catch (e) {
103
104
  // A direct message nobody could be found for: the sender's mistake to
104
105
  // fix, said in the client, not a fault of the server.
@@ -126,7 +127,7 @@ export async function handle(api, ctx) {
126
127
  const s = noteUrl && api.store.getStatuses().find(x => x.noteId === noteUrl);
127
128
  if (!s) return send(404, { error: 'Record not found' });
128
129
  return send(200, [{
129
- content: s.content || '', spoiler_text: s.spoiler || '', sensitive: !!s.spoiler,
130
+ content: s.content || '', spoiler_text: s.spoiler || '', sensitive: !!(s.spoiler || s.sensitive),
130
131
  created_at: s.editedAt || s.published, account: api.account(s.actor),
131
132
  media_attachments: (s.attachments || []).map(a => api.mediaJson(a)),
132
133
  emojis: [], poll: null,
@@ -152,8 +153,10 @@ export async function handle(api, ctx) {
152
153
  ? mediaIds.map(id => media[id] && { id, ...media[id] }).filter(Boolean)
153
154
  : null; // null: keep what the post has
154
155
  const spoilerText = String(body.spoiler_text || '').trim() || null;
156
+ const sensitive = body.sensitive === undefined ? null
157
+ : (body.sensitive === true || body.sensitive === 'true' || body.sensitive === '1');
155
158
  const patched = await api.agent.publisher.updateNote(s,
156
- { content: body.status, spoilerText, attachments });
159
+ { content: body.status, spoilerText, sensitive, attachments });
157
160
  return send(200, api.status(patched || s));
158
161
  }
159
162
  if (mStatus && req.method === 'DELETE') {
@@ -1,7 +1,7 @@
1
1
  // oidc-auth.mjs — who may drive the C2S surface. Two proofs are accepted:
2
2
  // the Mastodon facade's bearer (minted on this machine, so its holder is the
3
- // operator by construction), or a Solid-OIDC token — DPoP-bound when the
4
- // client sends the proof header — verified by the same library the wider
3
+ // operator by construction), or a Solid-OIDC token with its DPoP proof,
4
+ // which Solid-OIDC requires, verified by the same library the wider
5
5
  // Solid world uses. The token alone is not enough: the WebID it names must
6
6
  // be THIS identity's owner, an authorization step the token does not carry.
7
7
  //
@@ -25,6 +25,13 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
25
25
  if (!req.headers.authorization) {
26
26
  return { ok: false, status: 401, error: 'authentication required: a Solid-OIDC token (DPoP) or this agent\'s own bearer' };
27
27
  }
28
+ // Solid-OIDC binds the token to a key and has the client prove it holds
29
+ // that key on every request. A token shown without its proof is a token
30
+ // anyone who saw it could show.
31
+ if (!req.headers.dpop) {
32
+ return { ok: false, status: 401, error: 'a Solid-OIDC token must come with its DPoP proof',
33
+ headers: { 'www-authenticate': 'DPoP' } };
34
+ }
28
35
  let webid;
29
36
  try {
30
37
  const v = await loadVerifier();
@@ -38,7 +45,7 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
38
45
  }://${req.headers.host}${mount}${pathname}`;
39
46
  ({ webid } = await v(
40
47
  req.headers.authorization,
41
- req.headers.dpop ? { header: req.headers.dpop, method: req.method, url: htu } : undefined,
48
+ { header: req.headers.dpop, method: req.method, url: htu },
42
49
  ));
43
50
  } catch (e) {
44
51
  log(`c2s auth: token rejected — ${e.message}`);
package/lib/core/as2.mjs CHANGED
@@ -19,7 +19,7 @@
19
19
  // common shape: one that skipped it would not be read as JSON-LD, and would
20
20
  // produce no graph, so nothing could be validated against it.
21
21
  //
22
- // CONTEXTS ARE NEVER FETCHED. The loader serves the fourteen the fediverse
22
+ // CONTEXTS ARE NEVER FETCHED. The loader serves the fifteen the fediverse
23
23
  // actually uses and refuses every other URL. That refusal is the point: this
24
24
  // code runs on documents a stranger wrote, in the same path the SSRF guards
25
25
  // protect, and expanding one would otherwise mean dereferencing a URL of their
@@ -8,6 +8,7 @@ import { webcrypto } from 'node:crypto';
8
8
  import { attachProof } from './proof.mjs';
9
9
  import { pinnedFor, retryAfterMs, HTTP_TIMEOUT_MS } from '../shared/safefetch.mjs';
10
10
  import { USER_AGENT } from '../shared/ua.mjs';
11
+ import { dropFollower } from './store.mjs';
11
12
 
12
13
  const MAX_REDIRECTS = 3;
13
14
 
@@ -52,14 +53,20 @@ const MAX_QUEUE = 2000; // beyond it, overflow dead-lette
52
53
  // the server, or no answer at all, is grounds to leave it alone.
53
54
  const hostOf = (inbox) => { try { return new URL(inbox).host; } catch { return inbox; } };
54
55
  const aboutTheHost = (e) => !e.status || e.status >= 500 || e.status === 429;
56
+ // A 410 says the inbox is gone for good and a 404 that nothing answers there:
57
+ // neither earns a second POST. Gone also means whoever read there has left.
58
+ const unsalvageable = (e) => e.status === 404 || e.status === 410;
55
59
 
56
60
  export class Deliverer {
57
61
  // passive: signing-only (viewer-mode agents) — no queue drain timer, so a
58
62
  // read-only agent never mutates shared delivery state. startQueue() flips
59
63
  // it live when a viewer is promoted to active.
60
64
  constructor({ store, keyId, rsaPrivate, actorId = null, edPrivate = null, proofKeyId = null,
61
- log = console.log, passive = false }) {
65
+ log = console.log, passive = false, onGone = null }) {
62
66
  this.store = store;
67
+ // Called after followers at a gone inbox are dropped: the agent republishes
68
+ // its followers collection.
69
+ this.onGone = onGone;
63
70
  this.keyId = keyId;
64
71
  this.rsaPrivate = rsaPrivate;
65
72
  this.actorId = actorId;
@@ -182,6 +189,7 @@ export class Deliverer {
182
189
  await this.deliverNow(inbox, signed);
183
190
  this.log(`delivered ${signed.type} → ${inbox}`);
184
191
  } catch (e) {
192
+ if (unsalvageable(e)) { await this._unsalvageable(inbox, e); return; }
185
193
  this.log(`delivery failed (${e.message}) — queued`);
186
194
  const wait = e.retryAfterMs || 60_000;
187
195
  if (aboutTheHost(e)) {
@@ -192,6 +200,20 @@ export class Deliverer {
192
200
  }
193
201
  }
194
202
 
203
+ // Not retried, and on a 410 the followers who received there are dropped,
204
+ // so the next fan-out stops asking; the followers collection is republished
205
+ // through onGone. A 404 drops only this delivery.
206
+ async _unsalvageable(inbox, e) {
207
+ if (e.status !== 410) { this.log(`delivery dropped (${e.message}) — nothing answers there`); return; }
208
+ const contacts = this.store.getContacts?.();
209
+ const left = (contacts?.followers || []).filter(f => f.inbox === inbox || f.sharedInbox === inbox);
210
+ this.log(`${inbox} is gone (410) — delivery dropped${left.length ? `, ${left.length} follower(s) there forgotten` : ''}`);
211
+ if (!left.length) return;
212
+ for (const f of left) dropFollower(contacts, f.actor, 'inbox-gone');
213
+ this.store.setContacts(contacts);
214
+ try { await this.onGone?.(); } catch (err) { this.log(`followers republish after 410: ${err.message}`); }
215
+ }
216
+
195
217
  // The queue has a ceiling: a fan-out into a sea of down servers must not grow
196
218
  // queue.json without bound. Overflow keeps the evidence as a dead letter.
197
219
  _enqueue(item) {
@@ -292,6 +314,7 @@ export class Deliverer {
292
314
  changed = true;
293
315
  this.log(`retry ok: ${item.activity.type} → ${item.inbox}`);
294
316
  } catch (e) {
317
+ if (unsalvageable(e)) { changed = true; await this._unsalvageable(item.inbox, e); continue; }
295
318
  item.attempts += 1;
296
319
  changed = true;
297
320
  const wait = e.retryAfterMs || ladderFor(item);
@@ -103,6 +103,8 @@ export async function onFollow(intake, activity, actor, { trusted = false } = {}
103
103
  }
104
104
 
105
105
  export async function onUndo(intake, activity, actor, { trusted = false } = {}) {
106
+ const inner = activity.object && typeof activity.object === 'object' ? activity.object : null;
107
+ if (inner?.type === 'Like' || inner?.type === 'Announce') return onUndoReaction(intake, inner, actor, { trusted });
106
108
  // AS2 allows `object` to be a bare IRI, and that IRI is exactly the Follow
107
109
  // id we stored. Reading `.type` off a string gives undefined, so the whole
108
110
  // Undo was dropped — silently, since handle() reads that as handled, so no
@@ -274,6 +276,58 @@ export async function onAnnounce(intake, activity, actor, objectId) {
274
276
  return intake.ingestNote(objectId, actor, { via: actor });
275
277
  }
276
278
 
279
+ // Undo{Like} and Undo{Announce}: the sender unsaying a favourite or a boost.
280
+ // The Like it unsays was recorded on the sender's word alone, so its undoing
281
+ // is taken the same way: the notification it left goes. A boost unsaid by
282
+ // someone we follow takes the post they carried off the timeline — the carry
283
+ // was accepted on that follow, and the retraction is honoured on it.
284
+ export async function onUndoReaction(intake, inner, actor, { trusted = false } = {}) {
285
+ const innerActor = typeof inner.actor === 'string' ? inner.actor : inner.actor?.id;
286
+ if (innerActor && innerActor !== actor) return; // one cannot unsay another's
287
+ const wrapped = inner.object;
288
+ const target = (wrapped && typeof wrapped === 'object'
289
+ && (wrapped.type === 'Create' || wrapped.type === 'Update')) ? wrapped.object : wrapped;
290
+ const objectId = typeof target === 'string' ? target : target?.id;
291
+ if (!objectId) return;
292
+ if (objectId.startsWith(intake.urls.notes)) {
293
+ const type = inner.type === 'Like' ? 'favourite' : 'reblog';
294
+ const gone = intake.store.removeNotifications(n => n.type === type && n.actor === actor && n.noteId === objectId);
295
+ if (gone) intake.log(`${inner.type} withdrawn by ${actor} on ${objectId}`);
296
+ return;
297
+ }
298
+ if (inner.type !== 'Announce') return;
299
+ const followed = trusted || intake.store.getContacts().following.some(f => f.actor === actor && f.accepted);
300
+ if (!followed) return;
301
+ const s = intake.store.getStatuses().find(x => x.noteId === objectId);
302
+ if (!s || s.kind === 'post' || s.via !== actor) return;
303
+ await intake.forget(s);
304
+ intake.log(`boost withdrawn by ${actor}: ${objectId} left the timeline`);
305
+ }
306
+
307
+ // Block: the sender wants nothing more to do with this account. Mastodon
308
+ // drops the follow both ways on receipt, and so does this — on a delivery
309
+ // the door verified. A Block carries nothing to check and names nothing we
310
+ // hold, so one taken from the public-append inbox unverified would let
311
+ // anybody evict any follower, the hole the Undo handler closes with the
312
+ // follow id. Unverified: logged, nothing else.
313
+ export async function onBlock(intake, activity, actor, { trusted = false } = {}) {
314
+ const objectId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
315
+ if (objectId !== intake.urls.actor) return;
316
+ if (!trusted) { intake.log(`Block from ${actor} arrived unverified — ignored`); return; }
317
+ const contacts = intake.store.getContacts();
318
+ const wasFollower = contacts.followers.some(f => f.actor === actor);
319
+ const wasFollowing = contacts.following.some(f => f.actor === actor);
320
+ if (wasFollower) dropFollower(contacts, actor, 'blocked-us');
321
+ contacts.following = contacts.following.filter(f => f.actor !== actor);
322
+ intake.store.setContacts(contacts);
323
+ const reqs = intake.store.getRequests();
324
+ const left = reqs.filter(r => r.actor !== actor);
325
+ if (left.length !== reqs.length) intake.store.setRequests(left);
326
+ await intake.republish({ followers: wasFollower, following: wasFollowing, pending: true });
327
+ const what = [wasFollower && 'their follow of us', wasFollowing && 'our follow of them'].filter(Boolean).join(' and ');
328
+ intake.log(`blocked by ${actor}: ${what || 'no relationship'} dropped`);
329
+ }
330
+
277
331
  // Mastodon sends these constantly; ignoring them left deleted posts standing
278
332
  // for good. Two guards, because a forged Delete would otherwise erase anyone's
279
333
  // content: it must come from the object's own origin, and the object must
@@ -584,6 +584,7 @@ export class Intake {
584
584
  case 'Reject': return this.onReject(activity, actor, { trusted });
585
585
  case 'Move': return this.onMove(activity, actor);
586
586
  case 'Add': case 'Remove': return this.onAddRemove(activity, actor);
587
+ case 'Block': return this.onBlock(activity, actor, { trusted });
587
588
  default: this.log(`ignored ${activity.type} from ${actor}`);
588
589
  }
589
590
  }
@@ -636,6 +637,7 @@ export class Intake {
636
637
  onAddRemove(...a) { return activities.onAddRemove(this, ...a); }
637
638
  onFollow(...a) { return activities.onFollow(this, ...a); }
638
639
  onUndo(...a) { return activities.onUndo(this, ...a); }
640
+ onBlock(...a) { return activities.onBlock(this, ...a); }
639
641
  onCreate(...a) { return activities.onCreate(this, ...a); }
640
642
  onAnnouncedDelete(...a) { return activities.onAnnouncedDelete(this, ...a); }
641
643
  onAnnounce(...a) { return activities.onAnnounce(this, ...a); }
@@ -190,6 +190,11 @@ export async function ingestNote(intake, objectId, actor, { via, inline = null }
190
190
  .slice(0, MAX_MENTIONS)
191
191
  .map(t => ({ href: httpOnly(String(t.href).slice(0, MAX_URL_CHARS)), name: String(t.name).slice(0, 256) }))
192
192
  .filter(m => m.href);
193
+ const hashtags = [].concat(note.tag || [])
194
+ .filter(t => t?.type === 'Hashtag' && t.name)
195
+ .slice(0, MAX_MENTIONS)
196
+ .map(t => ({ name: String(t.name).replace(/^#/, '').slice(0, 128), url: httpOnly(String(t.href || '').slice(0, MAX_URL_CHARS)) }))
197
+ .filter(t => t.name);
193
198
  const emojis = emojisOf(note);
194
199
  const poll = pollOf(note);
195
200
  // Explicitly addressed, but to nobody public and to no followers
@@ -209,6 +214,8 @@ export async function ingestNote(intake, objectId, actor, { via, inline = null }
209
214
  ...(nonPublic ? { nonPublic: true } : {}),
210
215
  // The author's content warning, shown as one: plain text only.
211
216
  ...(note.summary ? { spoiler: String(note.summary).replace(/<[^>]*>/g, '') } : {}),
217
+ ...(note.sensitive === true ? { sensitive: true } : {}),
218
+ ...(hashtags.length ? { tags: hashtags } : {}),
212
219
  ...(poll ? { poll } : {}),
213
220
  ...(emojis.length ? { emojis } : {}),
214
221
  ...(mentions.length ? { mentions } : {}),
@@ -86,7 +86,10 @@ export async function publishOutbox(publisher, outbox, { acls = false, force = f
86
86
  // the flat collection this used to write, so an actor published before paging
87
87
  // is still readable — which matters because rebuild reads this to recover
88
88
  // posts a lost machine no longer has.
89
- export function readPublishedOutbox(publisher) { return podOutbox.readPublished(publisher.remote, publisher.urls); }
89
+ export async function readPublishedOutbox(publisher) {
90
+ const items = await podOutbox.readPublished(publisher.remote, publisher.urls);
91
+ return Array.isArray(items) ? items.map(wire.outboxLocalItem) : items;
92
+ }
90
93
 
91
94
  // The followers collection, paged like the outbox: a head that carries only
92
95
  // the count and the page bounds, and page documents holding the actor IRIs —
@@ -191,9 +194,9 @@ export async function publishBlocked(publisher) {
191
194
  }
192
195
 
193
196
  // The outbox is the public record of everything this actor has said, boosts
194
- // included. A Create goes in as its note id, which dereferences; an Announce
195
- // has only a fragment id, so the activity itself goes in the collection —
196
- // legal AS2, and what Mastodon serves.
197
+ // included. A post is recorded by its note id and published as its Create
198
+ // (wire.outboxWireItem); an Announce has only a fragment id, so the activity
199
+ // itself goes in.
197
200
  export async function recordOutbox(publisher, item) {
198
201
  const outbox = publisher.store.read('outbox.json', []);
199
202
  outbox.unshift(item);
@@ -34,7 +34,7 @@ const AGENT_VERSION = JSON.parse(fs.readFileSync(
34
34
 
35
35
  export class Publisher {
36
36
  constructor({ config, remote, store, deliverer, publicKeyPem, assertionKey = null, log = console.log,
37
- probeFetch = null, resolveMention = null, clientOrigin = null,
37
+ probeFetch = null, resolveMention = null, resolveActor = null, clientOrigin = null,
38
38
  }) {
39
39
  this.config = config;
40
40
  this.remote = remote;
@@ -57,6 +57,9 @@ export class Publisher {
57
57
  // socket opened to that pod. Tests inject their own.
58
58
  this.probeFetch = probeFetch || ((u, i) => this.remote.probe(u, i));
59
59
  this.resolveMention = resolveMention;
60
+ // An actor or object by its address, verified at its origin — how a reply
61
+ // finds the inbox of the person it answers.
62
+ this.resolveActor = resolveActor;
60
63
  // Per-poll rewrite windows, keyed by question id. See POLL_REWRITE_MS.
61
64
  this.pollTimers = new Map();
62
65
  this.log = log;
@@ -85,22 +88,14 @@ export class Publisher {
85
88
  return String(gw.url).replace(/ap\/inbox\/?$/u, 'ap/outbox');
86
89
  }
87
90
 
88
- async publishProfile({ force = false } = {}) {
91
+ // Every actor document this identity publishes, from one place: the
92
+ // profile publish and a Move both write it, and a Move that rebuilt it
93
+ // by hand dropped the gateway inbox, the outbox door and the collections.
94
+ actorDocFor({ priv = false, moderators = null, inbox = null, movedTo = this.config.movedTo || null } = {}) {
89
95
  const { urls } = this;
90
- const host = new URL(urls.base).host;
91
-
92
- // The owner-only collections (FEP-4ccd, FEP-c648) are advertised only
93
- // where "owner-only" is real — the pod must provably enforce the private
94
- // container's ACL, the same bar private posts clear.
95
- const priv = await this.privateReady() === true;
96
- const moderators = (this.config.moderators || []).length ? urls.moderators : null;
97
- // An advertised inbox gateway (config.gateway.mode past 'off') becomes the
98
- // actor's inbox; deliveries reach the pod inbox through it. Absent → today.
99
- const gw = this.config.gateway;
100
- const gwActive = gw && gw.url && gw.mode && gw.mode !== 'off';
101
- const actorDoc = wire.actorDoc({
96
+ return wire.actorDoc({
102
97
  urls, handle: wire.publicHandle(this.config), name: this.config.name, publicKeyPem: this.publicKeyPem,
103
- movedTo: this.config.movedTo || null, kind: this.config.kind,
98
+ movedTo, kind: this.config.kind,
104
99
  approveJoins: wire.followsNeedApproval(this.config),
105
100
  assertionKey: this.assertionKey,
106
101
  summary: this.config.summary || null, icon: this.config.icon || null,
@@ -111,7 +106,7 @@ export class Publisher {
111
106
  pendingFollowers: priv ? urls.pendingFollowers : null,
112
107
  pendingFollowing: priv ? urls.pendingFollowing : null,
113
108
  blocked: priv ? urls.blocked : null,
114
- inbox: gwActive ? gw.url : null,
109
+ inbox,
115
110
  // The agent's own outbox endpoint, where it is reachable: a client
116
111
  // following the actor must arrive somewhere that will take a write.
117
112
  // Otherwise the Gateway's outbox door, when one is attached.
@@ -121,18 +116,83 @@ export class Publisher {
121
116
  oauthAuthorize: this.clientOrigin ? `${this.clientOrigin}oauth/authorize` : null,
122
117
  oauthToken: this.clientOrigin ? `${this.clientOrigin}oauth/token` : null,
123
118
  });
119
+ }
120
+
121
+ // The human half: a page a browser can open and follow from. The actor
122
+ // document is for servers; this is the address you hand to a person. It
123
+ // shows who the account is, its fields, when it joined and what it pinned;
124
+ // the counts and the posts live in the collections. Written only when its
125
+ // content changed.
126
+ async publishProfilePage({ force = false } = {}) {
127
+ const { urls } = this;
128
+ const host = new URL(urls.base).host;
129
+ // When the account was made. Accounts from before this was recorded take
130
+ // the date of their oldest post, or today.
131
+ if (!this.config.createdAt) {
132
+ const oldest = this.store.getStatuses().filter(s => s.kind === 'post' && s.published)
133
+ .map(s => s.published).sort()[0];
134
+ this.config.createdAt = oldest || new Date().toISOString();
135
+ this.store.setConfig?.({ ...this.store.getConfig?.(), createdAt: this.config.createdAt });
136
+ }
137
+ const pinned = this.store.getStatuses()
138
+ .filter(s => s.kind === 'post' && s.pinned && s.visibility !== 'private' && s.visibility !== 'direct')
139
+ .sort((a, b) => String(b.published).localeCompare(String(a.published)))
140
+ .slice(0, 5)
141
+ .map(s => ({ content: s.content, published: s.published, url: s.noteId }));
142
+ // The address as the world knows it: at the Gateway for a fronted
143
+ // identity, on the pod's host otherwise, the actor id where no host can
144
+ // answer for a handle.
145
+ const frontActor = this.config.gateway?.frontActor;
146
+ const address = frontActor ? `@${wire.publicHandle(this.config)}@${new URL(frontActor).host}`
147
+ : (wire.webfingerHost(urls.base) ? `@${this.config.handle}@${host}` : urls.actor);
148
+ const html = wire.profilePageHtml({
149
+ name: this.config.name || this.config.handle,
150
+ address,
151
+ summary: this.config.summary ? wire.contentHtml(this.config.summary) : null,
152
+ icon: this.config.icon || null,
153
+ image: this.config.image || null,
154
+ fields: this.config.fields || [],
155
+ joined: this.config.createdAt,
156
+ pinned,
157
+ kind: this.config.kind,
158
+ });
159
+ const digest = crypto.createHash('sha256').update(html).digest('hex').slice(0, 32);
160
+ const seen = this.store.read('published.json', {});
161
+ if (!force && seen.pageDigest === digest) return false;
162
+ await podActor.writeProfilePage(this.remote, urls, html);
163
+ this.store.write('published.json', { ...this.store.read('published.json', {}), pageDigest: digest });
164
+ return true;
165
+ }
166
+
167
+ async publishProfile({ force = false } = {}) {
168
+ const { urls } = this;
169
+ const host = new URL(urls.base).host;
170
+
171
+ // The owner-only collections (FEP-4ccd, FEP-c648) are advertised only
172
+ // where "owner-only" is real — the pod must provably enforce the private
173
+ // container's ACL, the same bar private posts clear.
174
+ const priv = await this.privateReady() === true;
175
+ const moderators = (this.config.moderators || []).length ? urls.moderators : null;
176
+ // An advertised inbox gateway (config.gateway.mode past 'off') becomes the
177
+ // actor's inbox; deliveries reach the pod inbox through it. Absent → today.
178
+ const gw = this.config.gateway;
179
+ const gwActive = gw && gw.url && gw.mode && gw.mode !== 'off';
180
+ const actorDoc = this.actorDocFor({ priv, moderators, inbox: gwActive ? gw.url : null });
124
181
  const surface = crypto.createHash('sha256').update(JSON.stringify({
125
182
  actor: actorDoc, handle: this.config.handle, host,
126
183
  quiesced: !!this.config.quiescedAt, version: AGENT_VERSION,
127
184
  moderators: this.config.moderators || [],
128
185
  })).digest('hex').slice(0, 32);
186
+ // The human page has its own gate: it changes with pins and the joined
187
+ // date as well as with the profile, and costs one write when it does.
188
+ await this.publishProfilePage({ force });
129
189
  if (!force && this.store.read('published.json', {}).surfaceDigest === surface) {
130
190
  this.log('profile unchanged — nothing republished');
131
191
  return { unreachable: [], updated: 0, skipped: true };
132
192
  }
133
193
 
134
194
  await discovery.writeWebfinger(this.remote, urls,
135
- wire.jrd({ handle: this.config.handle, host, actor: urls.actor }));
195
+ wire.jrd({ handle: this.config.handle, host, actor: urls.actor, page: urls.profileHtml }));
136
196
  await discovery.writeHostMeta(this.remote, urls, wire.hostMeta(urls.base));
137
197
 
138
198
  const nodeinfoDocUrl = urls.home + 'ap/nodeinfo-2.0';
@@ -155,16 +215,6 @@ export class Publisher {
155
215
  // what concerns this identity. Written only while a gateway is advertised.
156
216
  if (gwActive) await this.publishGatewayPolicy();
157
217
 
158
- // The human half: a page a browser can open and follow from. The actor
159
- // document is for servers; this is the address you hand to a person.
160
- await podActor.writeProfilePage(this.remote, urls, wire.profilePageHtml({
161
- name: this.config.name || this.config.handle,
162
- address: wire.webfingerHost(urls.base) ? `@${this.config.handle}@${host}` : urls.actor,
163
- summary: this.config.summary ? wire.contentHtml(this.config.summary) : null,
164
- icon: this.config.icon || null,
165
- kind: this.config.kind,
166
- }));
167
-
168
218
  // WebID → actor: the profile card lists the actor as a foaf:account.
169
219
  // Best-effort — a profile that cannot be read or edited does not stop the
170
220
  // publish, it is logged and the rest of the surface still goes up.
@@ -358,15 +408,12 @@ export class Publisher {
358
408
  await this.deliverer.deliverToAll(inboxes, wire.moveActivity(urls, target, Date.parse(at)));
359
409
  this.config.movedTo = target; // so the republish below carries it
360
410
  this.store.setConfig({ ...this.store.getConfig(), movedTo: target, movedAt: at });
361
- await podActor.writeMoved(this.remote, urls, wire.actorDoc({
362
- urls, handle: wire.publicHandle(this.config), name: this.config.name,
363
- publicKeyPem: this.publicKeyPem, movedTo: target, kind: this.config.kind,
364
- approveJoins: wire.followsNeedApproval(this.config),
365
- assertionKey: this.assertionKey,
366
- summary: this.config.summary || null, icon: this.config.icon || null,
367
- image: this.config.image || null, fields: this.config.fields || [],
368
- webId: this.remote.webId || null,
369
- aliases: this.config.aliases || [],
411
+ const gw = this.config.gateway;
412
+ const gwActive = gw && gw.url && gw.mode && gw.mode !== 'off';
413
+ const priv = await this.privateReady() === true;
414
+ const moderators = (this.config.moderators || []).length ? urls.moderators : null;
415
+ await podActor.writeMoved(this.remote, urls, this.actorDocFor({
416
+ priv, moderators, inbox: gwActive ? gw.url : null, movedTo: target,
370
417
  }));
371
418
  await this.store.flush();
372
419
  this.log(`moved to ${target}: Move sent to ${inboxes.length} inbox(es), actor now advertises movedTo`);