fedipod 1.8.0 → 1.10.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,11 @@ 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
+ <!-- CLAUDE 2026-09-15 — the short profile address; delete these markers when done -->
41
+ Anyone can open your profile at `https://fedipod.net/@handle`, signed in or
42
+ not.
43
+ <!-- /CLAUDE -->
44
+
40
45
  Your account then opens in the client. To use it from another browser, go to
41
46
  https://fedipod.net, enter your address, sign in at your pod, and unlock your
42
47
  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;
@@ -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,6 +116,22 @@ 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
+ async publishProfile({ force = false } = {}) {
122
+ const { urls } = this;
123
+ const host = new URL(urls.base).host;
124
+
125
+ // The owner-only collections (FEP-4ccd, FEP-c648) are advertised only
126
+ // where "owner-only" is real — the pod must provably enforce the private
127
+ // container's ACL, the same bar private posts clear.
128
+ const priv = await this.privateReady() === true;
129
+ const moderators = (this.config.moderators || []).length ? urls.moderators : null;
130
+ // An advertised inbox gateway (config.gateway.mode past 'off') becomes the
131
+ // actor's inbox; deliveries reach the pod inbox through it. Absent → today.
132
+ const gw = this.config.gateway;
133
+ const gwActive = gw && gw.url && gw.mode && gw.mode !== 'off';
134
+ const actorDoc = this.actorDocFor({ priv, moderators, inbox: gwActive ? gw.url : null });
124
135
  const surface = crypto.createHash('sha256').update(JSON.stringify({
125
136
  actor: actorDoc, handle: this.config.handle, host,
126
137
  quiesced: !!this.config.quiescedAt, version: AGENT_VERSION,
@@ -132,7 +143,7 @@ export class Publisher {
132
143
  }
133
144
 
134
145
  await discovery.writeWebfinger(this.remote, urls,
135
- wire.jrd({ handle: this.config.handle, host, actor: urls.actor }));
146
+ wire.jrd({ handle: this.config.handle, host, actor: urls.actor, page: urls.profileHtml }));
136
147
  await discovery.writeHostMeta(this.remote, urls, wire.hostMeta(urls.base));
137
148
 
138
149
  const nodeinfoDocUrl = urls.home + 'ap/nodeinfo-2.0';
@@ -358,15 +369,12 @@ export class Publisher {
358
369
  await this.deliverer.deliverToAll(inboxes, wire.moveActivity(urls, target, Date.parse(at)));
359
370
  this.config.movedTo = target; // so the republish below carries it
360
371
  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 || [],
372
+ const gw = this.config.gateway;
373
+ const gwActive = gw && gw.url && gw.mode && gw.mode !== 'off';
374
+ const priv = await this.privateReady() === true;
375
+ const moderators = (this.config.moderators || []).length ? urls.moderators : null;
376
+ await podActor.writeMoved(this.remote, urls, this.actorDocFor({
377
+ priv, moderators, inbox: gwActive ? gw.url : null, movedTo: target,
370
378
  }));
371
379
  await this.store.flush();
372
380
  this.log(`moved to ${target}: Move sent to ${inboxes.length} inbox(es), actor now advertises movedTo`);