fedipod-server 0.28.0 → 0.29.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 (71) hide show
  1. package/README.md +27 -28
  2. package/config/server.json +22 -0
  3. package/dist/claims.js +3 -2
  4. package/dist/directory.d.ts +2 -0
  5. package/dist/handler.d.ts +4 -1
  6. package/dist/handler.js +32 -3
  7. package/dist/handler.jsonld +4 -0
  8. package/dist/store-pod.js +37 -0
  9. package/lib/client/c2s.mjs +97 -43
  10. package/lib/client/masto/accounts.mjs +1 -0
  11. package/lib/connections/bskyfeed.mjs +6 -0
  12. package/lib/core/deliver.mjs +68 -22
  13. package/lib/core/intake/activities.mjs +18 -2
  14. package/lib/core/intake/group.mjs +3 -1
  15. package/lib/core/intake/index.mjs +50 -6
  16. package/lib/core/intake/notes.mjs +12 -5
  17. package/lib/core/lease.mjs +7 -7
  18. package/lib/core/place.mjs +82 -0
  19. package/lib/core/publisher/collections.mjs +37 -2
  20. package/lib/core/publisher/index.mjs +23 -1
  21. package/lib/core/publisher/notes.mjs +73 -7
  22. package/lib/core/publisher/own.mjs +143 -0
  23. package/lib/core/publisher/questions.mjs +5 -3
  24. package/lib/core/social.mjs +62 -32
  25. package/lib/core/store.mjs +36 -6
  26. package/lib/core/wire.mjs +51 -15
  27. package/lib/device/admin/routes/lifecycle.mjs +1 -1
  28. package/lib/device/admin/routes/setup.mjs +17 -1
  29. package/lib/device/cli/commands/setup.mjs +50 -8
  30. package/lib/device/cli/context.mjs +1 -1
  31. package/lib/device/migrate.mjs +1 -1
  32. package/lib/device/setup.mjs +45 -8
  33. package/lib/gateway/caches.mjs +36 -0
  34. package/lib/gateway/front-core.mjs +80 -111
  35. package/lib/gateway/gateway-core.mjs +91 -9
  36. package/lib/gateway/headers.mjs +49 -0
  37. package/lib/gateway/quiet.mjs +7 -2
  38. package/lib/gateway/relay-extras.mjs +89 -0
  39. package/lib/gateway/token-claims.mjs +16 -0
  40. package/lib/pod/containers.mjs +5 -1
  41. package/lib/pod/inbox.mjs +22 -3
  42. package/lib/pod/location.mjs +52 -0
  43. package/lib/pod/notes.mjs +2 -4
  44. package/lib/pod/transport.mjs +207 -25
  45. package/lib/pod/type-index.mjs +101 -0
  46. package/lib/pod/urls.mjs +6 -0
  47. package/lib/server/embed.mjs +7 -6
  48. package/lib/session/README.md +14 -0
  49. package/lib/session/fedi-account.mjs +73 -2
  50. package/lib/session/package.json +10 -2
  51. package/package.json +1 -1
  52. package/run-agent.mjs +18 -0
  53. package/web/admin/actors.js +2 -0
  54. package/web/admin/index.html +9 -0
  55. package/web/admin/record.js +4 -1
  56. package/web/admin/setup/index.html +17 -1
  57. package/web/admin/setup/setup.js +18 -5
  58. package/web/app/README.md +1 -1
  59. package/web/app/admin-facade.mjs +1 -1
  60. package/web/app/agent.mjs +24 -10
  61. package/web/app/boot.mjs +72 -32
  62. package/web/app/deliver-relay.mjs +47 -7
  63. package/web/app/dist/boot.js +466 -89
  64. package/web/app/dist/boot.js.map +4 -4
  65. package/web/app/dist/sw.js +2792 -1996
  66. package/web/app/dist/sw.js.map +4 -4
  67. package/web/app/index.html +16 -0
  68. package/web/app/signup.mjs +65 -26
  69. package/web/app/update.js +4 -0
  70. package/web/front/run.html +7 -1
  71. package/web/front/run.js +30 -4
@@ -126,7 +126,7 @@ async function cacheCounts(agent, doc) {
126
126
 
127
127
  // `doc` skips the fetch when the caller already resolved the actor; a bulk
128
128
  // import passes publish:false and republishes the collections once at the end.
129
- export async function followActor(agent, actorUrl, { publish = true, doc: fetched = null } = {}) {
129
+ export async function followActor(agent, actorUrl, { publish = true, doc: fetched = null, serial = Date.now() } = {}) {
130
130
  const doc = fetched || await agent.intake.fetchAP(actorUrl);
131
131
  if (!doc?.inbox) throw new Error(`actor document unusable (${actorUrl})`);
132
132
  if (agent.store.isBlocked(doc.id)) throw new Error('actor is blocked');
@@ -136,7 +136,7 @@ export async function followActor(agent, actorUrl, { publish = true, doc: fetche
136
136
  rec = { actor: doc.id, inbox: doc.inbox, accepted: false };
137
137
  contacts.following.push(rec);
138
138
  }
139
- const follow = wire.followActivity({ urls: agent.publisher.urls, targetActor: doc.id, serial: Date.now() });
139
+ const follow = wire.followActivity({ urls: agent.publisher.urls, targetActor: doc.id, serial });
140
140
  rec.followActivity = follow; // kept for a later Undo
141
141
  agent.store.setContacts(contacts);
142
142
  await agent.deliverer.deliver(doc.inbox, follow);
@@ -144,9 +144,9 @@ export async function followActor(agent, actorUrl, { publish = true, doc: fetche
144
144
  return doc;
145
145
  }
146
146
 
147
- export async function followHandle(agent, handle) {
147
+ export async function followHandle(agent, handle, { serial = Date.now() } = {}) {
148
148
  const doc = await resolveHandle(agent, handle);
149
- await followActor(agent, doc.id);
149
+ await followActor(agent, doc.id, { serial });
150
150
  const clean = String(handle || '').replace(/^@/, '');
151
151
  const contacts = agent.store.getContacts();
152
152
  const rec = contacts.following.find(f => f.actor === doc.id);
@@ -154,13 +154,13 @@ export async function followHandle(agent, handle) {
154
154
  return { ok: true, actor: doc.id };
155
155
  }
156
156
 
157
- export async function unfollowActor(agent, actor) {
157
+ export async function unfollowActor(agent, actor, { serial = Date.now() } = {}) {
158
158
  const contacts = agent.store.getContacts();
159
159
  const rec = contacts.following.find(f => f.actor === actor);
160
160
  if (!rec) throw new Error('not following that actor');
161
161
  if (rec.followActivity) {
162
162
  await agent.deliverer.deliver(rec.inbox,
163
- wire.undoActivity({ urls: agent.publisher.urls, activity: rec.followActivity, serial: Date.now() }));
163
+ wire.undoActivity({ urls: agent.publisher.urls, activity: rec.followActivity, serial }));
164
164
  }
165
165
  contacts.following = contacts.following.filter(f => f.actor !== actor);
166
166
  agent.store.setContacts(contacts);
@@ -168,31 +168,31 @@ export async function unfollowActor(agent, actor) {
168
168
  return { ok: true };
169
169
  }
170
170
 
171
- export async function favourite(agent, s) {
171
+ export async function favourite(agent, s, { serial = Date.now() } = {}) {
172
172
  if (s.favourited) return s;
173
173
  const doc = await agent.intake.fetchAP(s.actor);
174
174
  if (!doc?.inbox) throw new Error('author inbox unavailable');
175
- const act = wire.likeActivity({ urls: agent.publisher.urls, noteId: s.noteId, serial: Date.now() });
175
+ const act = wire.likeActivity({ urls: agent.publisher.urls, noteId: s.noteId, serial, to: s.actor });
176
176
  await agent.deliverer.deliver(doc.endpoints?.sharedInbox || doc.inbox, act);
177
177
  return agent.store.updateStatus(s.noteId, { favourited: true, likeActivity: act });
178
178
  }
179
179
 
180
- export async function unfavourite(agent, s) {
180
+ export async function unfavourite(agent, s, { serial = Date.now() } = {}) {
181
181
  if (s.likeActivity) {
182
182
  const doc = await agent.intake.fetchAP(s.actor).catch(() => null);
183
183
  if (doc?.inbox) {
184
184
  await agent.deliverer.deliver(doc.endpoints?.sharedInbox || doc.inbox,
185
- wire.undoActivity({ urls: agent.publisher.urls, activity: s.likeActivity, serial: Date.now() }));
185
+ wire.undoActivity({ urls: agent.publisher.urls, activity: s.likeActivity, serial }));
186
186
  }
187
187
  }
188
188
  return agent.store.updateStatus(s.noteId, { favourited: false, likeActivity: undefined });
189
189
  }
190
190
 
191
191
  // Announce goes to our followers (that's what boosting is) and to the author.
192
- export async function reblog(agent, s) {
192
+ export async function reblog(agent, s, { serial = Date.now() } = {}) {
193
193
  if (s.reblogged) return s;
194
194
  const { urls } = agent.publisher;
195
- const act = wire.announceActivity({ urls, object: s.noteId, serial: Date.now() });
195
+ const act = wire.announceActivity({ urls, object: s.noteId, serial });
196
196
  const inboxes = new Set(agent.store.getContacts().followers
197
197
  .map(f => f.sharedInbox || f.inbox).filter(Boolean));
198
198
  if (s.actor !== urls.actor) {
@@ -208,15 +208,15 @@ export async function reblog(agent, s) {
208
208
  return updated;
209
209
  }
210
210
 
211
- export async function unreblog(agent, s) {
211
+ export async function unreblog(agent, s, { serial = Date.now() } = {}) {
212
212
  if (s.announceActivity) {
213
- const undo = wire.undoActivity({ urls: agent.publisher.urls, activity: s.announceActivity, serial: Date.now() });
213
+ const undo = wire.undoActivity({ urls: agent.publisher.urls, activity: s.announceActivity, serial });
214
214
  const inboxes = new Set(agent.store.getContacts().followers
215
215
  .map(f => f.sharedInbox || f.inbox).filter(Boolean));
216
216
  const doc = await agent.intake.fetchAP(s.actor).catch(() => null);
217
217
  if (doc?.inbox && s.actor !== agent.publisher.urls.actor) inboxes.add(doc.endpoints?.sharedInbox || doc.inbox);
218
218
  await agent.deliverer.deliverToAll([...inboxes], undo);
219
- await agent.publisher.unrecordOutbox(i => i?.id === s.announceActivity.id);
219
+ await agent.publisher.unrecordOutbox(i => i?.id === s.announceActivity.id, { record: () => undo });
220
220
  }
221
221
  return agent.store.updateStatus(s.noteId, { reblogged: false, announceActivity: undefined });
222
222
  }
@@ -260,7 +260,7 @@ export async function ejectFollower(agent, actor) {
260
260
 
261
261
  // Group: answer a held join request. Admitting is the Accept onFollow would
262
262
  // have sent immediately had the group not been gated.
263
- export async function admitRequest(agent, actor, { publish = true } = {}) {
263
+ export async function admitRequest(agent, actor, { publish = true, serial = Date.now() } = {}) {
264
264
  const reqs = agent.store.getRequests();
265
265
  const req = reqs.find(r => r.actor === actor);
266
266
  if (!req) throw new Error('no such request');
@@ -286,14 +286,14 @@ export async function admitRequest(agent, actor, { publish = true } = {}) {
286
286
  // A bulk admit passes publish:false and republishes once for the lot.
287
287
  if (publish) await agent.publisher.publishCollections({ followers: true, pending: true });
288
288
  await agent.deliverer.deliver(req.inbox, wire.acceptActivity({
289
- urls: agent.publisher.urls, followActivity: req.activity, serial: Date.now(),
289
+ urls: agent.publisher.urls, followActivity: req.activity, serial,
290
290
  }));
291
291
  return { ok: true, actor };
292
292
  }
293
293
 
294
294
  // Refusing is not a ban: they may ask again, and the queue is where you would
295
295
  // see that. Eject is the sticky one, because it mutes.
296
- export async function refuseRequest(agent, actor) {
296
+ export async function refuseRequest(agent, actor, { serial = Date.now() } = {}) {
297
297
  const reqs = agent.store.getRequests();
298
298
  const req = reqs.find(r => r.actor === actor);
299
299
  if (!req) throw new Error('no such request');
@@ -307,7 +307,7 @@ export async function refuseRequest(agent, actor) {
307
307
  return { ok: true, actor };
308
308
  }
309
309
  await agent.deliverer.deliver(req.inbox, wire.rejectActivity({
310
- urls: agent.publisher.urls, followActivity: req.activity, serial: Date.now(),
310
+ urls: agent.publisher.urls, followActivity: req.activity, serial,
311
311
  }));
312
312
  return { ok: true, actor };
313
313
  }
@@ -375,9 +375,21 @@ export async function votePoll(agent, s, choices) {
375
375
 
376
376
  export async function deleteNote(agent, s) {
377
377
  const { urls } = agent.publisher;
378
- const inboxes = agent.store.getContacts().followers
379
- .map(f => f.sharedInbox || f.inbox).filter(Boolean);
380
- await agent.deliverer.deliverToAll(inboxes, wire.deleteActivity({ urls, noteId: s.noteId }));
378
+ // The deletion goes where the post went: followers unless it was direct, and
379
+ // everyone it named or was blind-copied to. Addressed as the post was, read
380
+ // from the post itself while it is still there.
381
+ const doc = await Promise.resolve().then(() => podNotes.read(agent.remote, s.noteId)).catch(() => null);
382
+ const shape = wire.addressing(urls, s.visibility || 'public', (s.mentions || []).map(m => m.href).filter(Boolean));
383
+ const to = Array.isArray(doc?.to) ? doc.to : shape.to;
384
+ const cc = Array.isArray(doc?.cc) ? doc.cc : shape.cc;
385
+ const named = [...to, ...cc, ...(s.addressed || []), ...(s.replyActor ? [s.replyActor] : [])]
386
+ .filter(a => typeof a === 'string' && a !== wire.PUBLIC && a !== urls.followers && a !== urls.actor);
387
+ const inboxes = [
388
+ ...(s.visibility === 'direct' ? [] : agent.store.getContacts().followers.map(f => f.sharedInbox || f.inbox)),
389
+ ...(agent.publisher.inboxesFor ? await agent.publisher.inboxesFor(named).catch(() => []) : []),
390
+ ].filter(Boolean);
391
+ const del = wire.deleteActivity({ urls, noteId: s.noteId, to, cc });
392
+ await agent.deliverer.deliverToAll(inboxes, del);
381
393
 
382
394
  // The note is REPLACED with a Tombstone rather than removed, so a peer that
383
395
  // dereferences it learns the post was deleted instead of a bare 404 (§7.4).
@@ -386,12 +398,23 @@ export async function deleteNote(agent, s) {
386
398
  // not the object, so it is deleted outright.
387
399
  const stuck = [];
388
400
  const deletedAt = new Date().toISOString();
401
+ // Only inside the posts folders: an object that lives elsewhere on the pod
402
+ // (an annotation a client keeps in its own place, or anything it named) is
403
+ // that client's document, and a deletion here must not overwrite it — the
404
+ // profile, say — with a Tombstone. Its Create and outbox entry still go.
405
+ const ours = [urls.notes, urls.privateNotes].some(c => String(s.noteId).startsWith(c));
389
406
  try {
390
- await podNotes.writeTombstone(agent.remote, s.noteId, wire.noteTombstone(s.noteId, deletedAt));
407
+ if (ours) await podNotes.writeTombstone(agent.remote, s.noteId, wire.noteTombstone(s.noteId, deletedAt));
391
408
  } catch { stuck.push(s.noteId); }
392
- if (!await podNotes.dropCreate(agent.remote, wire.createActivityId(s.noteId))) {
393
- stuck.push(wire.createActivityId(s.noteId));
394
- }
409
+ // The Create becomes a Tombstone too, not a gap: it embedded the post, so
410
+ // its words go, and whoever asks for it is told it is gone (410 through the
411
+ // Gateway) rather than that it never was.
412
+ // An object kept outside the posts folders has its Create inside them, under
413
+ // the name it was given (publishObject).
414
+ const createId = ours ? wire.createActivityId(s.noteId) : (s.slug ? wire.createActivityId(urls.notes + s.slug) : null);
415
+ try {
416
+ if (createId) await podNotes.writeTombstone(agent.remote, createId, wire.noteTombstone(createId, deletedAt, 'Create'));
417
+ } catch { stuck.push(createId); }
395
418
  if (stuck.length) {
396
419
  agent.log?.(`delete ${s.noteId}: the pod kept ${stuck.length} document(s) — the post is STILL PUBLISHED`);
397
420
  return {
@@ -410,8 +433,15 @@ export async function deleteNote(agent, s) {
410
433
  .catch(e => agent.log?.(`bluesky mirror not deleted (${e.message}): ${s.atproto.uri}`));
411
434
  }
412
435
 
413
- // The note's outbox entry, and our own boost of it if there was one.
414
- await agent.publisher.unrecordOutbox(i => i === s.noteId || (i?.id && i.id === s.announceActivity?.id));
436
+ // The note's outbox entry, and our own boost of it if there was one. The
437
+ // Delete takes the post's place — only for a post the outbox listed, so a
438
+ // private post's deletion stays as private as the post was.
439
+ const itsOwn = (i) => i === s.noteId || (i?.id && i.id === s.announceActivity?.id)
440
+ // its edits too: an Update carries the post's words, which must not
441
+ // outlive the post on the public record
442
+ || (i?.type === 'Update' && (typeof i.object === 'string' ? i.object : i.object?.id) === s.noteId);
443
+ await agent.publisher.unrecordOutbox(itsOwn,
444
+ { record: (gone) => (gone.includes(s.noteId) ? del : null) });
415
445
  agent.store.removeStatus(s.noteId);
416
446
  return { ok: true };
417
447
  }
@@ -436,7 +466,7 @@ export async function announceModeration(agent, activity) {
436
466
  // nothing is delivered TO THE BLOCKED PARTY (§6.10). Intake already refuses a
437
467
  // blocked author on arrival. A group additionally announces the ban to its
438
468
  // members (FEP-1b12) — announceModeration keeps a person's blocks silent.
439
- export async function blockActor(agent, actorUrl) {
469
+ export async function blockActor(agent, actorUrl, { serial = Date.now() } = {}) {
440
470
  if (!/^https?:\/\/\S+$/.test(String(actorUrl || ''))) throw new Error('actor must be a URL');
441
471
  const b = agent.store.getBlocklist();
442
472
  if (!b.actors.includes(actorUrl)) b.actors.push(actorUrl);
@@ -444,12 +474,12 @@ export async function blockActor(agent, actorUrl) {
444
474
  await unfollowActor(agent, actorUrl).catch(() => {}); // not following is fine
445
475
  await agent.publisher?.publishCollections({ blocked: true }).catch(() => {});
446
476
  await announceModeration(agent,
447
- wire.blockActivity({ urls: agent.publisher.urls, targetActor: actorUrl, serial: Date.now() }))
477
+ wire.blockActivity({ urls: agent.publisher.urls, targetActor: actorUrl, serial }))
448
478
  .catch(e => agent.log?.(`moderation announce failed: ${e.message}`));
449
479
  return { ok: true, actor: actorUrl };
450
480
  }
451
481
 
452
- export async function unblockActor(agent, actorUrl) {
482
+ export async function unblockActor(agent, actorUrl, { serial = Date.now() } = {}) {
453
483
  const b = agent.store.getBlocklist();
454
484
  b.actors = b.actors.filter(a => a !== actorUrl);
455
485
  agent.store.setBlocklist(b);
@@ -459,7 +489,7 @@ export async function unblockActor(agent, actorUrl) {
459
489
  await announceModeration(agent, wire.undoActivity({
460
490
  urls: agent.publisher.urls,
461
491
  activity: { type: 'Block', actor: agent.publisher.urls.actor, object: actorUrl },
462
- serial: Date.now(),
492
+ serial,
463
493
  })).catch(e => agent.log?.(`moderation announce failed: ${e.message}`));
464
494
  return { ok: true, actor: actorUrl };
465
495
  }
@@ -72,6 +72,11 @@ export function dropFollower(contacts, actor, why) {
72
72
  return contacts;
73
73
  }
74
74
 
75
+ // What a state document looks like on the pod; write() and _put() agree on it.
76
+ const serialise = (obj) => JSON.stringify(obj, null, 2) + '\n';
77
+ // The same record apart from when it was fetched.
78
+ const sameButWhen = (a, b) => JSON.stringify({ ...a, fetchedAt: null }) === JSON.stringify({ ...b, fetchedAt: null });
79
+
75
80
  export class PodStore {
76
81
  constructor({ storage = null, log = console.log } = {}) {
77
82
  this.lastSkipped = []; // what the last load could not read, as `name (HTTP n)`
@@ -79,6 +84,7 @@ export class PodStore {
79
84
  this.log = log;
80
85
  this.cache = new Map(); // name → parsed value
81
86
  this.etags = new Map(); // name (or '') → last ETag, for revalidation
87
+ this.lastText = new Map(); // name → the bytes last loaded from or sent to the pod
82
88
  this.timers = new Map(); // name → debounce timer
83
89
  this.dirty = new Set(); // written while held; flushed by commit/release
84
90
  this._held = 0;
@@ -91,7 +97,7 @@ export class PodStore {
91
97
  attach(storage) {
92
98
  // A different tree is different state: carrying the old cache and its
93
99
  // ETags across would serve one container's documents as another's.
94
- if (this.storage && this.storage.base !== storage.base) { this.cache.clear(); this.etags.clear(); }
100
+ if (this.storage && this.storage.base !== storage.base) { this.cache.clear(); this.etags.clear(); this.lastText.clear(); }
95
101
  this.storage = storage;
96
102
  }
97
103
 
@@ -144,7 +150,7 @@ export class PodStore {
144
150
  }
145
151
  fetched++;
146
152
  this.etags.set(name, r.etag);
147
- try { this.cache.set(name, JSON.parse(r.body)); }
153
+ try { this.cache.set(name, JSON.parse(r.body)); this.lastText.set(name, r.body); }
148
154
  catch (e) { this.log(`state load ${name}: unparsable (${e.message})`); }
149
155
  }
150
156
  // Kept for /status: a document skipped here is a timeline or a contact
@@ -167,6 +173,15 @@ export class PodStore {
167
173
  write(name, obj) {
168
174
  this.cache.set(name, structuredClone(obj));
169
175
  if (!this.storage) return;
176
+ // The same bytes the pod already holds are not sent again. A like, a
177
+ // bookmark, a sweep that found nothing new each used to upload the whole
178
+ // timeline index and the whole people cache, unchanged — the largest
179
+ // write on the pod, on every small action. A write that had been armed
180
+ // and is now undone is unarmed with it.
181
+ if (this.lastText.get(name) === serialise(obj)) {
182
+ clearTimeout(this.timers.get(name)); this.timers.delete(name); this.dirty.delete(name);
183
+ return;
184
+ }
170
185
  // Held: record it and leave the writing to the commit boundary the caller
171
186
  // already has. See hold().
172
187
  if (this._held) { this.dirty.add(name); return; }
@@ -205,13 +220,13 @@ export class PodStore {
205
220
  // the returned promise so one failure cannot poison the queue.
206
221
  _put(name) {
207
222
  const done = this.chain.then(async () => {
208
- const body = JSON.stringify(this.cache.get(name), null, 2) + '\n';
223
+ const body = serialise(this.cache.get(name));
209
224
  for (let attempt = 1; attempt <= PUT_RETRIES; attempt++) {
210
225
  // A storage that throws is a bug, not a hiccup — but it must not
211
226
  // escape into the write queue, where it would look like success.
212
227
  const r = await this.storage.write(name, body, 'application/json')
213
228
  .catch(e => ({ ok: false, retry: false, why: e.message }));
214
- if (r.ok) return true;
229
+ if (r.ok) { this.lastText.set(name, body); return true; }
215
230
  // The storage says whether trying again could possibly help: a pod's
216
231
  // 4xx is an answer rather than a hiccup, and a directory's EACCES will
217
232
  // still be an EACCES in two seconds.
@@ -236,6 +251,7 @@ export class PodStore {
236
251
  // migrates to the local machine — leaving the copy behind would defeat it).
237
252
  async remove(name) {
238
253
  this.cache.delete(name);
254
+ this.lastText.delete(name);
239
255
  clearTimeout(this.timers.get(name));
240
256
  this.timers.delete(name);
241
257
  if (!this.storage) return true;
@@ -289,7 +305,17 @@ export class PodStore {
289
305
  const b = this.read('blocklist.json', {});
290
306
  return { domains: b.domains || [], actors: b.actors || [] };
291
307
  }
292
- setBlocklist(b) { this.write('blocklist.json', b); }
308
+ // Blocking someone ends their following as well: a blocked actor, or anyone
309
+ // on a blocked domain, is no longer delivered to. The mark dropFollower leaves
310
+ // keeps a reconcile from bringing them back.
311
+ setBlocklist(b) {
312
+ this.write('blocklist.json', b);
313
+ const c = this.getContacts();
314
+ const gone = c.followers.filter(f => f.actor && this.isBlocked(f.actor)).map(f => f.actor);
315
+ if (!gone.length) return;
316
+ for (const actor of gone) dropFollower(c, actor, 'blocked');
317
+ this.setContacts(c);
318
+ }
293
319
  // Takes an actor URL or an object URL: the actor list only ever matches the
294
320
  // former, the domain list matches either.
295
321
  isBlocked(url) {
@@ -462,6 +488,7 @@ export class PodStore {
462
488
  getActors() { return this.read('actors.json', {}); }
463
489
  cacheActor(url, doc) {
464
490
  const a = this.getActors();
491
+ const known = a[url];
465
492
  a[url] = {
466
493
  name: clamp(plainText(doc.name || doc.preferredUsername || ''), MAX_NAME),
467
494
  preferredUsername: clamp(plainText(doc.preferredUsername || ''), MAX_NAME),
@@ -474,9 +501,12 @@ export class PodStore {
474
501
  type: doc.type || 'Person',
475
502
  followers: doc.followers || null,
476
503
  following: doc.following || null,
477
- ...(a[url]?.counts ? { counts: a[url].counts } : {}),
504
+ ...(known?.counts ? { counts: known.counts } : {}),
478
505
  fetchedAt: new Date().toISOString(),
479
506
  };
507
+ // Fetched again and found the same: nothing to write. Only the time of
508
+ // asking differs, and that is not worth the whole cache going to the pod.
509
+ if (known && sameButWhen(known, a[url])) { a[url] = known; return; }
480
510
  this.write('actors.json', prune(a, ACTOR_CACHE_MAX, this.getContacts()));
481
511
  }
482
512
 
package/lib/core/wire.mjs CHANGED
@@ -83,7 +83,8 @@ export const assertionKeyId = (urls) => urls.actor + '#ed25519-key';
83
83
 
84
84
  export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null, movedTo = null, kind = 'person',
85
85
  approveJoins = false, summary = null, icon = null, image = null, fields = [],
86
- webId = null, aliases = [], moderators = null, pendingFollowers = null, pendingFollowing = null,
86
+ webId = null, aliases = [], moderators = null, pendingFollowers = null, pendingFollowing = null, liked = null,
87
+ ownerOutbox = null,
87
88
  blocked = null, inbox = null, outbox = null, oauthAuthorize = null, oauthToken = null,
88
89
  postingRestrictedToMods = null }) {
89
90
  // manuallyApprovesFollowers is NOT in the base AS2 context, so it is declared
@@ -119,6 +120,12 @@ export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null
119
120
  blocked: { '@id': 'bl:blocked', '@type': '@id' },
120
121
  });
121
122
  }
123
+ if (ownerOutbox) {
124
+ context.push({
125
+ fedipod: 'https://fedipod.net/ns#',
126
+ ownerOutbox: { '@id': 'fedipod:ownerOutbox', '@type': '@id' },
127
+ });
128
+ }
122
129
  // The actor's other identities: the account's WebID, plus any aliases —
123
130
  // old accounts elsewhere whose servers check for their own URL here before
124
131
  // they will send a Move. Declared inline exactly as Mastodon declares it.
@@ -193,6 +200,12 @@ export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null
193
200
  featured: urls.featured,
194
201
  followers: urls.followers,
195
202
  following: urls.following,
203
+ // What this actor has liked (§5.5). Its owner's to read, like the pending
204
+ // lists below.
205
+ ...(liked ? { liked } : {}),
206
+ // Every message this actor produced, as its owner reads it (§5.1), at an
207
+ // address the owner's own credential reaches.
208
+ ...(ownerOutbox ? { ownerOutbox } : {}),
196
209
  // FEP-1b12: attributedTo names the moderators collection; recipients
197
210
  // validate a group's announced moderation against it.
198
211
  ...(moderators ? { attributedTo: moderators } : {}),
@@ -252,12 +265,12 @@ export function tombstoneDoc(urls, deletedAt, kind = 'person') {
252
265
  // A deleted note is left as a Tombstone at its own URL — kept fetchable — so a
253
266
  // peer that dereferences it learns the post was DELETED rather than getting a
254
267
  // bare 404 it cannot tell from "never existed" (§7.4).
255
- export function noteTombstone(noteId, deletedAt) {
268
+ export function noteTombstone(noteId, deletedAt, formerType = 'Note') {
256
269
  return {
257
270
  '@context': AS_CTX,
258
271
  id: noteId,
259
272
  type: 'Tombstone',
260
- formerType: 'Note',
273
+ formerType,
261
274
  deleted: deletedAt,
262
275
  };
263
276
  }
@@ -418,25 +431,29 @@ export const FOLLOWERS_PAGE_SIZE = 20;
418
431
  export const followersPageId = (id, n) => `${id}-${n}`;
419
432
  export const followersPageCount = (total) => Math.max(1, Math.ceil(total / FOLLOWERS_PAGE_SIZE));
420
433
 
434
+ // Newest first, as every OrderedCollection is (ActivityPub §5): `first` is the
435
+ // page the newest names were added to and `next` walks back in time, and each
436
+ // page lists its names newest first. The pages themselves are still filled
437
+ // from the oldest end, so a new name rewrites one page.
421
438
  export function followersHead(id, total, pageCount = followersPageCount(total)) {
422
439
  const pages = Math.max(1, pageCount);
423
440
  return {
424
441
  '@context': AS_CTX,
425
442
  id, type: 'OrderedCollection',
426
443
  totalItems: total,
427
- first: followersPageId(id, 1),
428
- last: followersPageId(id, pages),
444
+ first: followersPageId(id, pages),
445
+ last: followersPageId(id, 1),
429
446
  };
430
447
  }
431
448
 
432
- export function followersPage(id, n, items, pageCount) {
449
+ export function followersPage(id, n, items, pageCount) { // eslint-disable-line no-unused-vars
433
450
  return {
434
451
  '@context': AS_CTX,
435
452
  id: followersPageId(id, n),
436
453
  type: 'OrderedCollectionPage',
437
454
  partOf: id,
438
- orderedItems: items,
439
- ...(n < pageCount ? { next: followersPageId(id, n + 1) } : {}),
455
+ orderedItems: [...items].reverse(),
456
+ ...(n > 1 ? { next: followersPageId(id, n - 1) } : {}),
440
457
  };
441
458
  }
442
459
 
@@ -775,12 +792,14 @@ export function followActivity({ urls, targetActor, serial }) {
775
792
  };
776
793
  }
777
794
 
778
- export function likeActivity({ urls, noteId, serial }) {
795
+ // Addressed to the author of what is liked, the one it is delivered to.
796
+ export function likeActivity({ urls, noteId, serial, to = null }) {
779
797
  return {
780
798
  '@context': AS_CTX,
781
799
  id: urls.actor + '#like-' + serial,
782
800
  type: 'Like',
783
801
  actor: urls.actor,
802
+ ...(to ? { to: [to] } : {}),
784
803
  object: noteId,
785
804
  };
786
805
  }
@@ -793,7 +812,7 @@ export function announceActivity({ urls, object, serial, published = new Date().
793
812
  audience = null }) {
794
813
  return {
795
814
  '@context': AS_CTX,
796
- id: urls.actor + '#announce-' + serial,
815
+ id: announceActivityId(urls, serial),
797
816
  type: 'Announce',
798
817
  actor: urls.actor,
799
818
  published,
@@ -831,10 +850,20 @@ export function updateActorActivity({ urls, actor, serial, published = new Date(
831
850
  // `serial` names the activity when the note is not being edited — a quote
832
851
  // authorization arriving on it changes what the note says about its quote,
833
852
  // not what it says.
853
+ // An edit's Update is named by when the edit was made, so whoever made it can
854
+ // say its id before it exists; a restatement that is not an edit has no stamp.
855
+ // Each is a document of its own beside the post, so it can be fetched by its
856
+ // id (§3.1) once it is on the public record; a fragment of another document
857
+ // could not be.
858
+ export const updateActivityId = (noteId, updated) => noteId + '-update-' + String(updated).replace(/[^0-9TZ]/g, '');
859
+ export const deleteActivityId = (noteId) => noteId + '-delete';
860
+ export const announceActivityId = (urls, serial) => urls.notes + 'announce-' + serial;
861
+ export const undoActivityId = (urls, serial) => urls.notes + 'undo-' + serial;
862
+
834
863
  export function updateActivity(note, urls, { serial = null } = {}) {
835
864
  return {
836
865
  '@context': AS_CTX,
837
- id: note.id + '#update-' + (note.updated ? String(note.updated).replace(/[^0-9TZ]/g, '') : 'q' + (serial ?? Date.now())),
866
+ id: note.updated ? updateActivityId(note.id, note.updated) : note.id + '-update-q' + (serial ?? Date.now()),
838
867
  type: 'Update',
839
868
  actor: urls.actor,
840
869
  to: note.to, cc: note.cc,
@@ -842,23 +871,30 @@ export function updateActivity(note, urls, { serial = null } = {}) {
842
871
  };
843
872
  }
844
873
 
845
- export function deleteActivity({ urls, noteId }) {
874
+ // Addressed as the post was, so a followers-only or direct post's deletion
875
+ // reaches the people who had it and is not announced to anyone else.
876
+ export function deleteActivity({ urls, noteId, to = [PUBLIC], cc = null }) {
846
877
  return {
847
878
  '@context': AS_CTX,
848
- id: noteId + '#delete',
879
+ id: deleteActivityId(noteId),
849
880
  type: 'Delete',
850
881
  actor: urls.actor,
851
- to: [PUBLIC],
882
+ to,
883
+ ...(cc?.length ? { cc } : {}),
852
884
  object: { id: noteId, type: 'Tombstone' },
853
885
  };
854
886
  }
855
887
 
888
+ // Addressed as what it takes back was: an Undo goes to whoever had that.
856
889
  export function undoActivity({ urls, activity, serial }) {
890
+ const to = activity?.to ?? (activity?.type === 'Follow' && typeof activity.object === 'string' ? [activity.object] : null);
857
891
  return {
858
892
  '@context': AS_CTX,
859
- id: urls.actor + '#undo-' + serial,
893
+ id: undoActivityId(urls, serial),
860
894
  type: 'Undo',
861
895
  actor: urls.actor,
896
+ ...(to ? { to } : {}),
897
+ ...(activity?.cc ? { cc: activity.cc } : {}),
862
898
  object: activity,
863
899
  };
864
900
  }
@@ -111,7 +111,7 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
111
111
  // A Move target nobody can resolve is a landing pad nobody lands on.
112
112
  if (!webfingerHost(urls.base) && !cfg.gateway?.frontActor) {
113
113
  return json(res, 400, {
114
- error: 'this pod is a suffix-based host, so WebFinger cannot answer for it '
114
+ error: 'this pod is suffixed, so WebFinger cannot answer for it '
115
115
  + '— other servers could never resolve this account as a Move target',
116
116
  });
117
117
  }
@@ -13,6 +13,7 @@ import { copyPrivateHalf, isCurrent, CURRENT_LAYOUT } from '../../migrate.mjs';
13
13
  import { insecureUrlReason } from '../../../shared/safefetch.mjs';
14
14
  import { newRun, preflight, runSetup, setupInputError, hasCredential, credentialPath } from '../../setup.mjs';
15
15
  import { podLayout } from '../../../pod/root.mjs';
16
+ import { chosenRoot, publicIndexKnown } from '../../../core/place.mjs';
16
17
  import { portFree, freePortFrom } from '../../ports.mjs';
17
18
  import { yieldDirectory } from '../../../gateway/directory.mjs';
18
19
  import { localFetch } from '../../../client/localapi.mjs';
@@ -115,7 +116,7 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
115
116
  }
116
117
  // ---- setup, driven by the page at /admin/setup/ ----
117
118
  case '/setup/check': {
118
- const pre = preflight(body);
119
+ const pre = { ...preflight(body), ...await placeCheck(body) };
119
120
  // For a new pod, ask the provider where it puts pods, so the page can
120
121
  // show the Gateway address before the pod exists. A provider on paths
121
122
  // means the address must live at the Gateway; the run decides for real.
@@ -410,3 +411,18 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
410
411
  default: return false;
411
412
  }
412
413
  }
414
+
415
+ // Where the data will go and whether the type index question has to be asked:
416
+ // a pod you bring is read as a stranger would (its profile is public); a new
417
+ // pod has no index, so the question is always asked for one.
418
+ async function placeCheck(body) {
419
+ if (body.mode === 'new') return { typeIndex: false };
420
+ let base;
421
+ try { base = new URL(String(body.pod || '')).href; } catch { return {}; }
422
+ if (!base.endsWith('/')) base += '/';
423
+ const place = chosenRoot(base, body.container);
424
+ return {
425
+ typeIndex: await publicIndexKnown(fetch, base),
426
+ ...(place.problem ? { placeProblem: place.problem } : { place: base + place.root }),
427
+ };
428
+ }