fedipod 1.7.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.
Files changed (53) hide show
  1. package/README.md +14 -3
  2. package/gateway.md +16 -0
  3. package/groups.md +1 -0
  4. package/lib/client/c2s.mjs +112 -64
  5. package/lib/client/masto/index.mjs +9 -1
  6. package/lib/client/masto/render.mjs +5 -3
  7. package/lib/client/masto/statuses.mjs +7 -4
  8. package/lib/client/oidc-auth.mjs +10 -3
  9. package/lib/core/as2.mjs +1 -1
  10. package/lib/core/contexts/anno.json +126 -0
  11. package/lib/core/contexts/index.mjs +4 -0
  12. package/lib/core/contexts/map.json +2 -1
  13. package/lib/core/deliver.mjs +24 -1
  14. package/lib/core/intake/activities.mjs +54 -0
  15. package/lib/core/intake/index.mjs +34 -5
  16. package/lib/core/intake/notes.mjs +7 -0
  17. package/lib/core/publisher/collections.mjs +7 -4
  18. package/lib/core/publisher/index.mjs +49 -27
  19. package/lib/core/publisher/notes.mjs +154 -7
  20. package/lib/core/publisher/questions.mjs +11 -7
  21. package/lib/core/publisher/restore.mjs +27 -2
  22. package/lib/core/social.mjs +1 -0
  23. package/lib/core/store.mjs +13 -0
  24. package/lib/core/wire.mjs +71 -20
  25. package/lib/device/admin/static.mjs +2 -2
  26. package/lib/device/admin/surface.mjs +11 -2
  27. package/lib/gateway/front-core.mjs +76 -6
  28. package/lib/gateway/gateway-core.mjs +40 -0
  29. package/lib/gateway/httpsig.mjs +7 -3
  30. package/lib/pod/actor.mjs +2 -2
  31. package/lib/pod/transport.mjs +9 -3
  32. package/package.json +1 -1
  33. package/run-agent.mjs +11 -0
  34. package/scripts/refresh-contexts.mjs +5 -0
  35. package/scripts/stage-site.mjs +1 -0
  36. package/web/admin/index.html +2 -0
  37. package/web/admin/upkeep.js +9 -1
  38. package/web/app/README.md +1 -1
  39. package/web/app/agent.mjs +12 -0
  40. package/web/app/boot.mjs +49 -10
  41. package/web/app/dist/boot.js +47 -7
  42. package/web/app/dist/boot.js.map +2 -2
  43. package/web/app/dist/sw.js +1604 -722
  44. package/web/app/dist/sw.js.map +4 -4
  45. package/web/app/index.html +16 -6
  46. package/web/app/site/_redirects +1 -0
  47. package/web/app/site/admin/index.html +2 -0
  48. package/web/app/site/admin/upkeep.js +9 -1
  49. package/web/app/site/boot.js +89 -37
  50. package/web/app/site/index.html +16 -6
  51. package/web/app/site/sw.js +1605 -723
  52. package/web/front/#new-account.html# +0 -43
  53. package/web/front/new-account.html~ +0 -50
@@ -0,0 +1,126 @@
1
+ {
2
+ "@context": {
3
+ "oa": "http://www.w3.org/ns/oa#",
4
+ "dc": "http://purl.org/dc/elements/1.1/",
5
+ "dcterms": "http://purl.org/dc/terms/",
6
+ "dctypes": "http://purl.org/dc/dcmitype/",
7
+ "foaf": "http://xmlns.com/foaf/0.1/",
8
+ "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
9
+ "rdfs": "http://www.w3.org/2000/01/rdf-schema#",
10
+ "skos": "http://www.w3.org/2004/02/skos/core#",
11
+ "xsd": "http://www.w3.org/2001/XMLSchema#",
12
+ "iana": "http://www.iana.org/assignments/relation/",
13
+ "owl": "http://www.w3.org/2002/07/owl#",
14
+ "as": "http://www.w3.org/ns/activitystreams#",
15
+ "schema": "http://schema.org/",
16
+
17
+ "id": {"@type": "@id", "@id": "@id"},
18
+ "type": {"@type": "@id", "@id": "@type"},
19
+
20
+ "Annotation": "oa:Annotation",
21
+ "Dataset": "dctypes:Dataset",
22
+ "Image": "dctypes:StillImage",
23
+ "Video": "dctypes:MovingImage",
24
+ "Audio": "dctypes:Sound",
25
+ "Text": "dctypes:Text",
26
+ "TextualBody": "oa:TextualBody",
27
+ "ResourceSelection": "oa:ResourceSelection",
28
+ "SpecificResource": "oa:SpecificResource",
29
+ "FragmentSelector": "oa:FragmentSelector",
30
+ "CssSelector": "oa:CssSelector",
31
+ "XPathSelector": "oa:XPathSelector",
32
+ "TextQuoteSelector": "oa:TextQuoteSelector",
33
+ "TextPositionSelector": "oa:TextPositionSelector",
34
+ "DataPositionSelector": "oa:DataPositionSelector",
35
+ "SvgSelector": "oa:SvgSelector",
36
+ "RangeSelector": "oa:RangeSelector",
37
+ "TimeState": "oa:TimeState",
38
+ "HttpRequestState": "oa:HttpRequestState",
39
+ "CssStylesheet": "oa:CssStyle",
40
+ "Choice": "oa:Choice",
41
+ "Person": "foaf:Person",
42
+ "Software": "as:Application",
43
+ "Organization": "foaf:Organization",
44
+ "AnnotationCollection": "as:OrderedCollection",
45
+ "AnnotationPage": "as:OrderedCollectionPage",
46
+ "Audience": "schema:Audience",
47
+
48
+ "Motivation": "oa:Motivation",
49
+ "bookmarking": "oa:bookmarking",
50
+ "classifying": "oa:classifying",
51
+ "commenting": "oa:commenting",
52
+ "describing": "oa:describing",
53
+ "editing": "oa:editing",
54
+ "highlighting": "oa:highlighting",
55
+ "identifying": "oa:identifying",
56
+ "linking": "oa:linking",
57
+ "moderating": "oa:moderating",
58
+ "questioning": "oa:questioning",
59
+ "replying": "oa:replying",
60
+ "reviewing": "oa:reviewing",
61
+ "assessing": "oa:assessing",
62
+ "tagging": "oa:tagging",
63
+
64
+ "auto": "oa:autoDirection",
65
+ "ltr": "oa:ltrDirection",
66
+ "rtl": "oa:rtlDirection",
67
+
68
+ "body": {"@type": "@id", "@id": "oa:hasBody"},
69
+ "target": {"@type": "@id", "@id": "oa:hasTarget"},
70
+ "source": {"@type": "@id", "@id": "oa:hasSource"},
71
+ "selector": {"@type": "@id", "@id": "oa:hasSelector"},
72
+ "state": {"@type": "@id", "@id": "oa:hasState"},
73
+ "scope": {"@type": "@id", "@id": "oa:hasScope"},
74
+ "refinedBy": {"@type": "@id", "@id": "oa:refinedBy"},
75
+ "startSelector": {"@type": "@id", "@id": "oa:hasStartSelector"},
76
+ "endSelector": {"@type": "@id", "@id": "oa:hasEndSelector"},
77
+ "renderedVia": {"@type": "@id", "@id": "oa:renderedVia"},
78
+ "creator": {"@type": "@id", "@id": "dcterms:creator"},
79
+ "generator": {"@type": "@id", "@id": "as:generator"},
80
+ "rights": {"@type": "@id", "@id": "dcterms:rights"},
81
+ "homepage": {"@type": "@id", "@id": "foaf:homepage"},
82
+ "via": {"@type": "@id", "@id": "oa:via"},
83
+ "canonical": {"@type": "@id", "@id": "oa:canonical"},
84
+ "stylesheet": {"@type": "@id", "@id": "oa:styledBy"},
85
+ "cached": {"@type": "@id", "@id": "oa:cachedSource"},
86
+ "conformsTo": {"@type": "@id", "@id": "dcterms:conformsTo"},
87
+ "items": {"@type": "@id", "@id": "as:items", "@container": "@list"},
88
+ "partOf": {"@type": "@id", "@id": "as:partOf"},
89
+ "first": {"@type": "@id", "@id": "as:first"},
90
+ "last": {"@type": "@id", "@id": "as:last"},
91
+ "next": {"@type": "@id", "@id": "as:next"},
92
+ "prev": {"@type": "@id", "@id": "as:prev"},
93
+ "audience": {"@type": "@id", "@id": "schema:audience"},
94
+ "motivation": {"@type": "@vocab", "@id": "oa:motivatedBy"},
95
+ "purpose": {"@type": "@vocab", "@id": "oa:hasPurpose"},
96
+ "textDirection": {"@type": "@vocab", "@id": "oa:textDirection"},
97
+
98
+ "accessibility": "schema:accessibilityFeature",
99
+ "bodyValue": "oa:bodyValue",
100
+ "format": "dc:format",
101
+ "language": "dc:language",
102
+ "processingLanguage": "oa:processingLanguage",
103
+ "value": "rdf:value",
104
+ "exact": "oa:exact",
105
+ "prefix": "oa:prefix",
106
+ "suffix": "oa:suffix",
107
+ "styleClass": "oa:styleClass",
108
+ "name": "foaf:name",
109
+ "email": "foaf:mbox",
110
+ "email_sha1": "foaf:mbox_sha1sum",
111
+ "nickname": "foaf:nick",
112
+ "label": "rdfs:label",
113
+
114
+ "created": {"@id": "dcterms:created", "@type": "xsd:dateTime"},
115
+ "modified": {"@id": "dcterms:modified", "@type": "xsd:dateTime"},
116
+ "generated": {"@id": "dcterms:issued", "@type": "xsd:dateTime"},
117
+ "sourceDate": {"@id": "oa:sourceDate", "@type": "xsd:dateTime"},
118
+ "sourceDateStart": {"@id": "oa:sourceDateStart", "@type": "xsd:dateTime"},
119
+ "sourceDateEnd": {"@id": "oa:sourceDateEnd", "@type": "xsd:dateTime"},
120
+
121
+ "start": {"@id": "oa:start", "@type": "xsd:nonNegativeInteger"},
122
+ "end": {"@id": "oa:end", "@type": "xsd:nonNegativeInteger"},
123
+ "total": {"@id": "as:totalItems", "@type": "xsd:nonNegativeInteger"},
124
+ "startIndex": {"@id": "as:startIndex", "@type": "xsd:nonNegativeInteger"}
125
+ }
126
+ }
@@ -25,6 +25,9 @@ import ctx10 from './fep-5711.json' with { type: 'json' };
25
25
  import ctx11 from './join-lemmy.json' with { type: 'json' };
26
26
  import ctx12 from './joinmastodon.json' with { type: 'json' };
27
27
  import ctx13 from './miscellany.json' with { type: 'json' };
28
+ // Not from fedify: the W3C Web Annotation context, which a client such as
29
+ // dokieli names on the annotations it posts to the outbox.
30
+ import ctx14 from './anno.json' with { type: 'json' };
28
31
 
29
32
  /** URL → the context document itself. Nothing outside this map is ever resolved. */
30
33
  export const CONTEXTS = {
@@ -42,4 +45,5 @@ export const CONTEXTS = {
42
45
  'https://join-lemmy.org/context.json': ctx11,
43
46
  'http://joinmastodon.org/ns': ctx12,
44
47
  'https://purl.archive.org/miscellany': ctx13,
48
+ 'http://www.w3.org/ns/anno.jsonld': ctx14,
45
49
  };
@@ -12,5 +12,6 @@
12
12
  "https://w3id.org/fep/5711": "fep-5711.json",
13
13
  "https://join-lemmy.org/context.json": "join-lemmy.json",
14
14
  "http://joinmastodon.org/ns": "joinmastodon.json",
15
- "https://purl.archive.org/miscellany": "miscellany.json"
15
+ "https://purl.archive.org/miscellany": "miscellany.json",
16
+ "http://www.w3.org/ns/anno.jsonld": "anno.json"
16
17
  }
@@ -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
@@ -59,8 +59,10 @@ const MAX_ITEM_ATTEMPTS = 5;
59
59
  const MAX_ITEMS_PER_DRAIN = 50;
60
60
 
61
61
  export class Intake {
62
- constructor({ config, urls, remote, store, deliverer, publisher, log = console.log, lease = null, archive = null, push = true, pollSeconds = null }) {
63
- Object.assign(this, { config, urls, remote, store, deliverer, publisher, log, lease, archive, push, pollSeconds });
62
+ // `ownerPost(activity, { raw, slug })` is the client-to-server dispatcher,
63
+ // for an item the Gateway's outbox door took on the owner's behalf.
64
+ constructor({ config, urls, remote, store, deliverer, publisher, log = console.log, lease = null, archive = null, push = true, pollSeconds = null, ownerPost = null }) {
65
+ Object.assign(this, { config, urls, remote, store, deliverer, publisher, log, lease, archive, push, pollSeconds, ownerPost });
64
66
  this.serial = Date.now();
65
67
  this.stopped = false;
66
68
  // (attempt counts are kept in pod state — see _bumpAttempt)
@@ -429,9 +431,14 @@ export class Intake {
429
431
  // receipt reads as null, which is exactly today's unverified behavior.
430
432
  const receipt = activity ? await this._readReceipt(url) : null;
431
433
  if (activity && this.gatewaySecret()) this._bumpGatewayStat(!!receipt?.verified);
432
- const rejection = activity ? await this.handle(activity, receipt) : 'unparsable JSON';
433
- if (!rejection && raw) await this._archive(url, raw, activity);
434
- if (!rejection) await this._maybeForward(activity); // §7.1.2, only what we accepted
434
+ // The owner's own post, taken at the outbox door: not mail to read
435
+ // but a write to make. Never archived or forwarded as if received.
436
+ const owned = activity && this.isOwnerPost(receipt);
437
+ const rejection = !activity ? 'unparsable JSON'
438
+ : owned ? await this.ownerPostFrom(activity, raw, receipt)
439
+ : await this.handle(activity, receipt);
440
+ if (!rejection && raw && !owned) await this._archive(url, raw, activity);
441
+ if (!rejection && !owned) await this._maybeForward(activity); // §7.1.2, only what we accepted
435
442
  if (rejection) {
436
443
  this.store.addDeadLetter({
437
444
  inboxUrl: url, reason: rejection, activity: trimActivity(activity),
@@ -577,6 +584,7 @@ export class Intake {
577
584
  case 'Reject': return this.onReject(activity, actor, { trusted });
578
585
  case 'Move': return this.onMove(activity, actor);
579
586
  case 'Add': case 'Remove': return this.onAddRemove(activity, actor);
587
+ case 'Block': return this.onBlock(activity, actor, { trusted });
580
588
  default: this.log(`ignored ${activity.type} from ${actor}`);
581
589
  }
582
590
  }
@@ -594,6 +602,26 @@ export class Intake {
594
602
  gatewaySecret(...a) { return verify.gatewaySecret(this, ...a); }
595
603
  _bumpGatewayStat(...a) { return verify.bumpGatewayStat(this, ...a); }
596
604
  _readReceipt(...a) { return verify.readReceipt(this, ...a); }
605
+
606
+ // A receipt the door stamped `c2s` for THIS actor, and only a receipt whose
607
+ // HMAC verified (readReceipt returns nothing else). A stranger appending an
608
+ // item that claims to be ours has no such receipt and is read as mail.
609
+ isOwnerPost(receipt) {
610
+ return !!(this.ownerPost && receipt?.verified && receipt.method === 'c2s' && receipt.actor === this.urls.actor);
611
+ }
612
+
613
+ // Publish what the owner posted at the door. The bytes as the client wrote
614
+ // them go to the dispatcher (an object stored as sent must be the client's
615
+ // document, not our reading of it); the graph is what it decides from.
616
+ // Returns null when published, else the reason it was not — a dead letter.
617
+ async ownerPostFrom(activity, raw, receipt) {
618
+ let asSent = null;
619
+ try { asSent = raw ? JSON.parse(raw) : null; } catch { asSent = null; }
620
+ const r = await this.ownerPost(activity, { raw: asSent, slug: receipt.slug || null });
621
+ if (!r || r.status >= 300) return `owner post refused (${r?.status || '?'}): ${r?.body?.error || ''}`;
622
+ this.log(`owner post from the outbox door published: ${r.body?.object || r.body?.id || activity.type}`);
623
+ return null;
624
+ }
597
625
  receiptVouchesFor(...a) { return verify.receiptVouchesFor(this, ...a); }
598
626
  isGone(...a) { return verify.isGone(this, ...a); }
599
627
 
@@ -609,6 +637,7 @@ export class Intake {
609
637
  onAddRemove(...a) { return activities.onAddRemove(this, ...a); }
610
638
  onFollow(...a) { return activities.onFollow(this, ...a); }
611
639
  onUndo(...a) { return activities.onUndo(this, ...a); }
640
+ onBlock(...a) { return activities.onBlock(this, ...a); }
612
641
  onCreate(...a) { return activities.onCreate(this, ...a); }
613
642
  onAnnouncedDelete(...a) { return activities.onAnnouncedDelete(this, ...a); }
614
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;
@@ -76,22 +79,23 @@ export class Publisher {
76
79
  // republish button. Without it, an actor lost from the pod would match the
77
80
  // digest, be skipped, and leave the agent reporting success while nobody can
78
81
  // resolve it.
79
- async publishProfile({ force = false } = {}) {
80
- const { urls } = this;
81
- const host = new URL(urls.base).host;
82
-
83
- // The owner-only collections (FEP-4ccd, FEP-c648) are advertised only
84
- // where "owner-only" is real — the pod must provably enforce the private
85
- // container's ACL, the same bar private posts clear.
86
- const priv = await this.privateReady() === true;
87
- const moderators = (this.config.moderators || []).length ? urls.moderators : null;
88
- // An advertised inbox gateway (config.gateway.mode past 'off') becomes the
89
- // actor's inbox; deliveries reach the pod inbox through it. Absent → today.
82
+ // The Gateway's outbox door for this account, when a Gateway is attached:
83
+ // beside its inbox door, or under the fronted actor.
84
+ gatewayOutbox() {
90
85
  const gw = this.config.gateway;
91
- const gwActive = gw && gw.url && gw.mode && gw.mode !== 'off';
92
- const actorDoc = wire.actorDoc({
86
+ if (!(gw && gw.url && gw.mode && gw.mode !== 'off')) return null;
87
+ if (gw.frontActor) return String(gw.frontActor).replace(/ap\/actor\/?$/u, 'ap/outbox');
88
+ return String(gw.url).replace(/ap\/inbox\/?$/u, 'ap/outbox');
89
+ }
90
+
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 } = {}) {
95
+ const { urls } = this;
96
+ return wire.actorDoc({
93
97
  urls, handle: wire.publicHandle(this.config), name: this.config.name, publicKeyPem: this.publicKeyPem,
94
- movedTo: this.config.movedTo || null, kind: this.config.kind,
98
+ movedTo, kind: this.config.kind,
95
99
  approveJoins: wire.followsNeedApproval(this.config),
96
100
  assertionKey: this.assertionKey,
97
101
  summary: this.config.summary || null, icon: this.config.icon || null,
@@ -102,15 +106,32 @@ export class Publisher {
102
106
  pendingFollowers: priv ? urls.pendingFollowers : null,
103
107
  pendingFollowing: priv ? urls.pendingFollowing : null,
104
108
  blocked: priv ? urls.blocked : null,
105
- inbox: gwActive ? gw.url : null,
109
+ inbox,
106
110
  // The agent's own outbox endpoint, where it is reachable: a client
107
111
  // following the actor must arrive somewhere that will take a write.
108
- outbox: this.clientOrigin ? `${this.clientOrigin}ap/outbox` : null,
112
+ // Otherwise the Gateway's outbox door, when one is attached.
113
+ outbox: this.clientOrigin ? `${this.clientOrigin}ap/outbox` : this.gatewayOutbox(),
109
114
  // How a client-to-server client finds the way in with nothing configured
110
115
  // by hand. Advertised only where the surface is publicly reachable.
111
116
  oauthAuthorize: this.clientOrigin ? `${this.clientOrigin}oauth/authorize` : null,
112
117
  oauthToken: this.clientOrigin ? `${this.clientOrigin}oauth/token` : null,
113
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 });
114
135
  const surface = crypto.createHash('sha256').update(JSON.stringify({
115
136
  actor: actorDoc, handle: this.config.handle, host,
116
137
  quiesced: !!this.config.quiescedAt, version: AGENT_VERSION,
@@ -122,7 +143,7 @@ export class Publisher {
122
143
  }
123
144
 
124
145
  await discovery.writeWebfinger(this.remote, urls,
125
- wire.jrd({ handle: this.config.handle, host, actor: urls.actor }));
146
+ wire.jrd({ handle: this.config.handle, host, actor: urls.actor, page: urls.profileHtml }));
126
147
  await discovery.writeHostMeta(this.remote, urls, wire.hostMeta(urls.base));
127
148
 
128
149
  const nodeinfoDocUrl = urls.home + 'ap/nodeinfo-2.0';
@@ -163,6 +184,8 @@ export class Publisher {
163
184
  actorUrl: urls.actor,
164
185
  accountName: `@${this.config.handle}@${host}`,
165
186
  kind: this.config.kind,
187
+ // Where a Solid client posts: dokieli reads `as:outbox` off the WebID.
188
+ outbox: this.clientOrigin ? `${this.clientOrigin}ap/outbox` : this.gatewayOutbox(),
166
189
  });
167
190
  if (wrote) this.log('WebID profile now lists the actor as a foaf:account');
168
191
  } catch (e) {
@@ -346,15 +369,12 @@ export class Publisher {
346
369
  await this.deliverer.deliverToAll(inboxes, wire.moveActivity(urls, target, Date.parse(at)));
347
370
  this.config.movedTo = target; // so the republish below carries it
348
371
  this.store.setConfig({ ...this.store.getConfig(), movedTo: target, movedAt: at });
349
- await podActor.writeMoved(this.remote, urls, wire.actorDoc({
350
- urls, handle: wire.publicHandle(this.config), name: this.config.name,
351
- publicKeyPem: this.publicKeyPem, movedTo: target, kind: this.config.kind,
352
- approveJoins: wire.followsNeedApproval(this.config),
353
- assertionKey: this.assertionKey,
354
- summary: this.config.summary || null, icon: this.config.icon || null,
355
- image: this.config.image || null, fields: this.config.fields || [],
356
- webId: this.remote.webId || null,
357
- 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,
358
378
  }));
359
379
  await this.store.flush();
360
380
  this.log(`moved to ${target}: Move sent to ${inboxes.length} inbox(es), actor now advertises movedTo`);
@@ -400,6 +420,7 @@ export class Publisher {
400
420
  reconcileFollowers(...a) { return restore.reconcileFollowers(this, ...a); }
401
421
  reconcileOutbox(...a) { return restore.reconcileOutbox(this, ...a); }
402
422
  rebuildStatuses(...a) { return restore.rebuildStatuses(this, ...a); }
423
+ healStatuses(...a) { return restore.healStatuses(this, ...a); }
403
424
 
404
425
  // notes.mjs
405
426
  ensureMediaContainer(...a) { return notes.ensureMediaContainer(this, ...a); }
@@ -408,6 +429,7 @@ export class Publisher {
408
429
  privateReady(...a) { return notes.privateReady(this, ...a); }
409
430
  _mentionsFor(...a) { return notes.mentionsFor(this, ...a); }
410
431
  publishNote(...a) { return notes.publishNote(this, ...a); }
432
+ publishObject(...a) { return notes.publishObject(this, ...a); }
411
433
  updateNote(...a) { return notes.updateNote(this, ...a); }
412
434
 
413
435
  // questions.mjs