fedipod-server 0.28.1 → 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 (61) 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/core/deliver.mjs +17 -1
  12. package/lib/core/intake/activities.mjs +18 -2
  13. package/lib/core/intake/group.mjs +3 -1
  14. package/lib/core/intake/index.mjs +33 -2
  15. package/lib/core/intake/notes.mjs +12 -5
  16. package/lib/core/place.mjs +82 -0
  17. package/lib/core/publisher/collections.mjs +37 -2
  18. package/lib/core/publisher/index.mjs +23 -1
  19. package/lib/core/publisher/notes.mjs +73 -7
  20. package/lib/core/publisher/own.mjs +143 -0
  21. package/lib/core/publisher/questions.mjs +5 -3
  22. package/lib/core/social.mjs +62 -32
  23. package/lib/core/store.mjs +11 -1
  24. package/lib/core/wire.mjs +51 -15
  25. package/lib/device/admin/routes/lifecycle.mjs +1 -1
  26. package/lib/device/admin/routes/setup.mjs +17 -1
  27. package/lib/device/cli/commands/setup.mjs +50 -8
  28. package/lib/device/cli/context.mjs +1 -1
  29. package/lib/device/migrate.mjs +1 -1
  30. package/lib/device/setup.mjs +45 -8
  31. package/lib/gateway/front-core.mjs +54 -58
  32. package/lib/gateway/gateway-core.mjs +87 -8
  33. package/lib/gateway/relay-extras.mjs +89 -0
  34. package/lib/gateway/token-claims.mjs +16 -0
  35. package/lib/pod/location.mjs +52 -0
  36. package/lib/pod/notes.mjs +2 -4
  37. package/lib/pod/transport.mjs +171 -22
  38. package/lib/pod/type-index.mjs +101 -0
  39. package/lib/pod/urls.mjs +6 -0
  40. package/lib/server/embed.mjs +7 -6
  41. package/lib/session/fedi-account.mjs +19 -10
  42. package/lib/session/package.json +1 -1
  43. package/package.json +1 -1
  44. package/run-agent.mjs +2 -0
  45. package/web/admin/actors.js +2 -0
  46. package/web/admin/index.html +9 -0
  47. package/web/admin/record.js +4 -1
  48. package/web/admin/setup/index.html +17 -1
  49. package/web/admin/setup/setup.js +18 -5
  50. package/web/app/README.md +1 -1
  51. package/web/app/admin-facade.mjs +1 -1
  52. package/web/app/agent.mjs +11 -2
  53. package/web/app/boot.mjs +72 -32
  54. package/web/app/dist/boot.js +430 -86
  55. package/web/app/dist/boot.js.map +4 -4
  56. package/web/app/dist/sw.js +3294 -2648
  57. package/web/app/dist/sw.js.map +4 -4
  58. package/web/app/index.html +16 -0
  59. package/web/app/signup.mjs +65 -26
  60. package/web/front/run.html +7 -1
  61. package/web/front/run.js +30 -4
@@ -28,6 +28,7 @@ import * as social from '../core/social.mjs';
28
28
  import * as wire from '../core/wire.mjs';
29
29
  import { readLenient } from '../core/as2.mjs';
30
30
  import { safeSlug } from '../core/publisher/notes.mjs';
31
+ import * as podNotes from '../pod/notes.mjs';
31
32
 
32
33
  const MAX_BODY = 512 * 1024; // same ceiling the inbox drain enforces
33
34
 
@@ -192,9 +193,11 @@ export class C2S {
192
193
  * published copy and it stays the canonical one; this is served only where
193
194
  * that copy cannot be read by the client asking.
194
195
  */
195
- sendLocalOutbox(res, url, origin) {
196
+ sendLocalOutbox(res, url, origin, { owner = false } = {}) {
196
197
  const id = `${origin}ap/outbox`;
197
- const outbox = this.store.read('outbox.json', []); // newest first
198
+ // newest first; the owner's view is every message, the public one what a
199
+ // stranger may see
200
+ const outbox = this.store.read(owner ? 'outbox-own.json' : 'outbox.json', []);
198
201
  const page = url?.searchParams?.get('page') || null;
199
202
  const ct = { 'content-type': 'application/activity+json; charset=utf-8' };
200
203
  if (!page) {
@@ -270,12 +273,22 @@ export class C2S {
270
273
  return this.sendInbox(res, url);
271
274
  }
272
275
  if (req.method === 'GET' || req.method === 'HEAD') {
276
+ // The owner, signed in, reads every message the actor produced (§5.1:
277
+ // the outbox is filtered by who asks). Anyone else reads the public one.
278
+ const owner = pathname === '/ap/outbox' && req.headers.authorization
279
+ ? (await this.auth(req, pathname)).ok : false;
273
280
  // Where the client surface answers on an address a stranger can reach,
274
281
  // the pod's copies already name it and are the documents. Standalone
275
282
  // they are not reachable by the client asking, so it is answered here.
276
283
  const local = this.agent.publisher?.clientOrigin ? null : this.localOrigin(req);
277
284
  if (local && pathname === '/ap/actor') return this.sendLocalActor(req, res, local);
278
- if (local && pathname === '/ap/outbox') return this.sendLocalOutbox(res, url, local);
285
+ if (local && pathname === '/ap/outbox') return this.sendLocalOutbox(res, url, local, { owner });
286
+ // The owner is answered here, not sent on: a redirect loses the
287
+ // credential (a browser drops it across origins, and a DPoP proof names
288
+ // the address it was made for), so the pod would refuse them.
289
+ if (owner && pathname === '/ap/outbox') {
290
+ return this.sendLocalOutbox(res, url, this.agent.publisher.clientOrigin, { owner });
291
+ }
279
292
  // The pod's copy is the document; send the reader there.
280
293
  const target = pathname === '/ap/actor' ? this.urls.actor : this.urls.outbox;
281
294
  res.writeHead(303, { location: target, 'cache-control': 'no-store' });
@@ -334,14 +347,22 @@ export class C2S {
334
347
  visibilityOf(activity, object) {
335
348
  const to = arr(activity.to ?? object?.to).map(idOf);
336
349
  const cc = arr(activity.cc ?? object?.cc).map(idOf);
337
- if (!to.length && !cc.length) return 'public';
350
+ // Blind copies alone are still an audience: a post for them and nobody
351
+ // else is direct, never public.
352
+ const blind = arr(activity.bto ?? object?.bto).length + arr(activity.bcc ?? object?.bcc).length;
353
+ if (!to.length && !cc.length) return blind ? 'direct' : 'public';
338
354
  if (to.includes(wire.PUBLIC)) return 'public';
339
355
  if (cc.includes(wire.PUBLIC)) return 'unlisted';
340
- if (to.includes(this.urls.followers)) return 'private';
356
+ // Followers named anywhere — "to alice, cc my followers" included — is a
357
+ // followers-only post that also reaches whoever else is named.
358
+ if (to.includes(this.urls.followers) || cc.includes(this.urls.followers)) return 'private';
341
359
  return 'direct';
342
360
  }
343
361
 
344
- async dispatch(activity, { slug = null, raw = null } = {}) {
362
+ // `serial` and `at` name what this activity will make: the outbox door
363
+ // chose them and told the client, so they are used as given. A client
364
+ // posting here directly gets fresh ones.
365
+ async dispatch(activity, { slug = null, raw = null, serial = Date.now(), at = new Date().toISOString() } = {}) {
345
366
  const reply = (status, body, headers = {}) => ({ status, body, headers });
346
367
  if (!activity || typeof activity !== 'object' || Array.isArray(activity) || !activity.type) {
347
368
  return reply(400, { error: 'a typed ActivityStreams object is required' });
@@ -349,10 +370,12 @@ export class C2S {
349
370
  // A bare object arrives without an activity around it; the server supplies
350
371
  // the Create (§6.2.1), carrying the object's own addressing up onto it.
351
372
  if (!ACTIVITY_TYPES.has(activity.type)) {
352
- activity = { type: 'Create', object: activity, to: activity.to, cc: activity.cc };
373
+ // Every audience field is copied up onto the Create (§6.2.1).
374
+ const { to, cc, bto, bcc, audience } = activity;
375
+ activity = { type: 'Create', object: activity, to, cc, bto, bcc, audience };
353
376
  }
354
377
  try {
355
- return await this._dispatch(activity, { slug, raw, reply });
378
+ return await this._dispatch(activity, { slug, raw, reply, serial, at });
356
379
  } catch (e) {
357
380
  this.log(`c2s ${activity?.type}: ${e.message}`);
358
381
  return reply(422, { error: e.message || String(e) });
@@ -366,11 +389,23 @@ export class C2S {
366
389
  const pick = (...fields) => [...new Set(fields.flatMap((f) => arr(activity[f] ?? object?.[f]).map(idOf)))]
367
390
  .filter((a) => typeof a === 'string' && /^https?:\/\//u.test(a)
368
391
  && a !== wire.PUBLIC && a !== this.urls.followers && a !== this.urls.actor);
369
- return { also: pick('to', 'cc'), deliverTo: pick('bto', 'bcc') };
392
+ // `audience` is who the post is for, a group most often: delivered to and
393
+ // named like to and cc (§7.1).
394
+ return { also: pick('to', 'cc', 'audience'), deliverTo: pick('bto', 'bcc') };
370
395
  }
371
396
 
372
- async _dispatch(activity, { slug, raw, reply }) {
397
+ async _dispatch(activity, { slug, raw, reply, serial, at }) {
373
398
  const agent = this.agent;
399
+ // §6: every post to the outbox answers 201 with the new activity's id.
400
+ const made = (id, body = {}) => reply(201, { id, ...body }, { location: id });
401
+ // What is never sent anywhere — a person's block, a pin — is still a
402
+ // message this actor produced, so the owner's outbox records it here.
403
+ const kept = (type, extra) => {
404
+ const id = type === 'Undo' ? wire.undoActivityId(this.urls, serial) : `${this.urls.actor}#${type.toLowerCase()}-${serial}`;
405
+ const act = { id, type, actor: this.urls.actor, published: at, ...extra };
406
+ agent.publisher.recordOwn?.(act);
407
+ return act.id;
408
+ };
374
409
  const object = typeof activity.object === 'object' && activity.object !== null
375
410
  ? activity.object : null;
376
411
  const objectId = idOf(activity.object);
@@ -411,6 +446,7 @@ export class C2S {
411
446
  visibility,
412
447
  spoilerText: object.summary || null,
413
448
  sensitive: object.sensitive === true,
449
+ slug,
414
450
  });
415
451
  return reply(201,
416
452
  { id: wire.createActivityId(question.id), object: question.id },
@@ -445,19 +481,32 @@ export class C2S {
445
481
  if (s.actor !== this.urls.actor || s.kind !== 'post') {
446
482
  return reply(403, { error: 'not your note' });
447
483
  }
448
- const text = String(object?.source?.content ?? object?.content ?? '');
449
- if (!text.trim()) return reply(422, { error: 'the edit has no content' });
450
- const attachments = object?.attachment !== undefined
484
+ // §6.3: a client's Update changes only what it sends, and a field sent
485
+ // as null is removed. What was sent is read from the bytes, because a
486
+ // JSON-LD reading drops the nulls that mean "remove this".
487
+ const sent = raw?.type === 'Update' && raw.object && typeof raw.object === 'object' ? raw.object : (object || {});
488
+ const has = (k) => Object.prototype.hasOwnProperty.call(sent, k);
489
+ const stored = await Promise.resolve().then(() => podNotes.read(agent.remote, s.noteId)).catch(() => null);
490
+ if (stored?.type && stored.type !== 'Note') {
491
+ await agent.publisher.updateObject(s, sent, { updated: at });
492
+ return made(wire.updateActivityId(s.noteId, at), { object: s.noteId });
493
+ }
494
+ const text = has('content') || sent.source?.content !== undefined
495
+ ? String(object?.source?.content ?? object?.content ?? '') : String(s.text ?? '');
496
+ if (!text.trim()) return reply(422, { error: 'the edit leaves the note with no content' });
497
+ const attachments = has('attachment')
451
498
  ? arr(object.attachment).map((a) => ({
452
499
  url: a?.url, mediaType: a?.mediaType,
453
500
  ...(a?.name ? { description: a.name } : {}),
454
501
  })).filter((a) => a.url)
455
502
  : null;
456
503
  await agent.publisher.updateNote(s, {
457
- content: text, spoilerText: object?.summary || null,
458
- sensitive: object?.sensitive === undefined ? null : object.sensitive === true, attachments,
504
+ content: text,
505
+ spoilerText: has('summary') ? (object?.summary || null) : (s.spoiler || null),
506
+ sensitive: has('sensitive') ? object?.sensitive === true : null, attachments,
507
+ updated: at,
459
508
  });
460
- return reply(200, { ok: true, object: s.noteId });
509
+ return made(wire.updateActivityId(s.noteId, at), { object: s.noteId });
461
510
  }
462
511
 
463
512
  case 'Delete': {
@@ -471,7 +520,7 @@ export class C2S {
471
520
  }
472
521
  const r = await social.deleteNote(agent, s);
473
522
  if (!r.ok) return reply(502, { error: r.error, stillPublished: r.stillPublished });
474
- return reply(200, { ok: true });
523
+ return made(wire.deleteActivityId(s.noteId), { object: s.noteId });
475
524
  }
476
525
 
477
526
  case 'Follow': {
@@ -479,31 +528,27 @@ export class C2S {
479
528
  // An acct: form or bare handle resolves through WebFinger; an https
480
529
  // IRI is fetched directly.
481
530
  if (/^acct:|^@|^[^/@]+@[^/@]+$/.test(objectId) && !/^https?:/.test(objectId)) {
482
- const r = await social.followHandle(agent, objectId.replace(/^acct:/, ''));
531
+ const r = await social.followHandle(agent, objectId.replace(/^acct:/, ''), { serial });
483
532
  const rec = this.store.getContacts().following.find((f) => f.actor === r.actor);
484
- return reply(201, { id: rec?.followActivity?.id, object: r.actor },
485
- rec?.followActivity?.id ? { location: rec.followActivity.id } : {});
533
+ return made(rec?.followActivity?.id, { object: r.actor });
486
534
  }
487
- const doc = await social.followActor(agent, objectId);
535
+ const doc = await social.followActor(agent, objectId, { serial });
488
536
  const rec = this.store.getContacts().following.find((f) => f.actor === doc.id);
489
- return reply(201, { id: rec?.followActivity?.id, object: doc.id },
490
- rec?.followActivity?.id ? { location: rec.followActivity.id } : {});
537
+ return made(rec?.followActivity?.id, { object: doc.id });
491
538
  }
492
539
 
493
540
  case 'Like': {
494
541
  const s = this.byIri(objectId);
495
542
  if (!s) return reply(422, { error: 'that note is not held here — like what the timeline holds' });
496
- const updated = await social.favourite(agent, s);
497
- return reply(201, { id: updated.likeActivity?.id, object: s.noteId },
498
- updated.likeActivity?.id ? { location: updated.likeActivity.id } : {});
543
+ const updated = await social.favourite(agent, s, { serial });
544
+ return made(updated.likeActivity?.id, { object: s.noteId });
499
545
  }
500
546
 
501
547
  case 'Announce': {
502
548
  const s = this.byIri(objectId);
503
549
  if (!s) return reply(422, { error: 'that note is not held here — boost what the timeline holds' });
504
- const updated = await social.reblog(agent, s);
505
- return reply(201, { id: updated.announceActivity?.id, object: s.noteId },
506
- updated.announceActivity?.id ? { location: updated.announceActivity.id } : {});
550
+ const updated = await social.reblog(agent, s, { serial });
551
+ return made(updated.announceActivity?.id, { object: s.noteId });
507
552
  }
508
553
 
509
554
  case 'Undo': {
@@ -511,39 +556,47 @@ export class C2S {
511
556
  // sends one, by its type+object when it re-states it instead.
512
557
  const inner = object;
513
558
  const innerId = idOf(activity.object);
559
+ const undone = wire.undoActivityId(this.urls, serial);
514
560
  if (inner?.type === 'Block') {
515
561
  const target = idOf(inner.object);
516
562
  if (!target) return reply(400, { error: 'unblock whom?' });
517
- await social.unblockActor(agent, target);
518
- return reply(200, { ok: true, object: target });
563
+ await social.unblockActor(agent, target, { serial });
564
+ // A group's unban was sent, and recorded as it went; a person's is not.
565
+ if (this.store.getConfig()?.kind !== 'group') {
566
+ kept('Undo', { object: { type: 'Block', actor: this.urls.actor, object: target } });
567
+ }
568
+ return made(undone, { object: target });
519
569
  }
520
570
  const statuses = this.store.getStatuses();
521
571
  let s = innerId ? statuses.find((x) => x.likeActivity?.id === innerId) : null;
522
572
  if (!s && inner?.type === 'Like') s = this.byIri(idOf(inner.object));
523
573
  if (s?.favourited) {
524
- const updated = await social.unfavourite(agent, s);
525
- return reply(200, { ok: true, object: updated.noteId });
574
+ const updated = await social.unfavourite(agent, s, { serial });
575
+ return made(undone, { object: updated.noteId });
526
576
  }
527
577
  s = innerId ? statuses.find((x) => x.announceActivity?.id === innerId) : null;
528
578
  if (!s && inner?.type === 'Announce') s = this.byIri(idOf(inner.object));
529
579
  if (s?.reblogged) {
530
- const updated = await social.unreblog(agent, s);
531
- return reply(200, { ok: true, object: updated.noteId });
580
+ const updated = await social.unreblog(agent, s, { serial });
581
+ return made(undone, { object: updated.noteId });
532
582
  }
533
583
  const following = this.store.getContacts().following;
534
584
  const rec = following.find((f) => f.followActivity?.id === innerId)
535
585
  || (inner?.type === 'Follow' ? following.find((f) => f.actor === idOf(inner.object)) : null);
536
586
  if (rec) {
537
- await social.unfollowActor(agent, rec.actor);
538
- return reply(200, { ok: true, object: rec.actor });
587
+ await social.unfollowActor(agent, rec.actor, { serial });
588
+ return made(undone, { object: rec.actor });
539
589
  }
540
590
  return reply(422, { error: 'nothing here matches what that Undo names' });
541
591
  }
542
592
 
543
593
  case 'Block': {
544
594
  if (!objectId) return reply(400, { error: 'block whom? object must name an actor' });
545
- await social.blockActor(agent, objectId);
546
- return reply(200, { ok: true, object: objectId });
595
+ await social.blockActor(agent, objectId, { serial });
596
+ // A group's ban was sent to its members; a person's never goes anywhere.
597
+ const id = this.store.getConfig()?.kind === 'group'
598
+ ? `${this.urls.actor}#block-${serial}` : kept('Block', { object: objectId });
599
+ return made(id, { object: objectId });
547
600
  }
548
601
 
549
602
  case 'Add':
@@ -556,7 +609,8 @@ export class C2S {
556
609
  const s = this.byIri(objectId);
557
610
  if (!s) return reply(404, { error: 'no such note here' });
558
611
  const updated = await social.pinStatus(agent, s, activity.type === 'Add');
559
- return reply(200, { ok: true, object: updated.noteId, pinned: !!updated.pinned });
612
+ return made(kept(activity.type, { object: updated.noteId, target: this.urls.featured }),
613
+ { object: updated.noteId, pinned: !!updated.pinned });
560
614
  }
561
615
 
562
616
  case 'Accept':
@@ -566,10 +620,10 @@ export class C2S {
566
620
  const requester = object?.actor ? idOf(object.actor) : objectId;
567
621
  if (!requester) return reply(400, { error: 'whose request? object must name the Follow or its actor' });
568
622
  const r = activity.type === 'Accept'
569
- ? await social.admitRequest(agent, requester).catch((e) => ({ error: e.message }))
570
- : await social.refuseRequest(agent, requester).catch((e) => ({ error: e.message }));
623
+ ? await social.admitRequest(agent, requester, { serial }).catch((e) => ({ error: e.message }))
624
+ : await social.refuseRequest(agent, requester, { serial }).catch((e) => ({ error: e.message }));
571
625
  if (r.error) return reply(404, { error: r.error });
572
- return reply(200, { ok: true, object: requester });
626
+ return made(`${this.urls.actor}#${activity.type.toLowerCase()}-${serial}`, { object: requester });
573
627
  }
574
628
 
575
629
  case 'Move':
@@ -255,6 +255,7 @@ export async function handle(api, ctx) {
255
255
  if (req.method === 'POST' && !b.domains.includes(domain)) b.domains.push(domain);
256
256
  if (req.method === 'DELETE') b.domains = b.domains.filter(d => d !== domain);
257
257
  api.store.setBlocklist(b);
258
+ Promise.resolve(api.agent?.publisher?.publishCollections?.({ blocked: true })).catch(() => {});
258
259
  return send(200, {});
259
260
  }
260
261
 
@@ -187,7 +187,21 @@ export class Deliverer {
187
187
  return out;
188
188
  }
189
189
 
190
+ // A fresh activity of this actor's, for the outbox its owner reads. Never
191
+ // allowed to stop a delivery; a retry from the queue is not fresh.
192
+ _sent(activity) {
193
+ try { this.onSent?.(activity); } catch (e) { this.log(`owner's outbox record: ${e.message}`); }
194
+ }
195
+
196
+ // An inbox on a blocked domain is never delivered to, whoever still lists it.
197
+ _blockedInbox(inbox) {
198
+ try { return !!this.store.getBlocklist?.().domains.length && this.store.isBlocked?.(new URL(inbox).origin + '/'); }
199
+ catch { return false; }
200
+ }
201
+
190
202
  async deliver(inbox, activity) {
203
+ this._sent(activity);
204
+ if (this._blockedInbox(inbox)) return;
191
205
  // Proved before anything else, so the copy that goes on the queue is the
192
206
  // copy that was signed — a retry days later must not post a bare activity.
193
207
  const signed = await this.proofed(activity);
@@ -250,13 +264,15 @@ export class Deliverer {
250
264
  }
251
265
 
252
266
  async deliverToAll(inboxes, activity) {
267
+ this._sent(activity);
253
268
  // Proved once for the whole fan-out: every recipient gets the same bytes,
254
269
  // and one signature is computed rather than one per follower.
255
270
  const signed = await this.proofed(activity);
256
271
  // Shared inboxes deduplicate fan-out to the same server. Sent in batches
257
272
  // of batchSize, and a host found cooling by one batch is not asked again
258
273
  // by the next.
259
- const targets = [...new Set(inboxes)].map((inbox) => ({ inbox, activity: signed }));
274
+ const targets = [...new Set(inboxes)].filter((inbox) => !this._blockedInbox(inbox))
275
+ .map((inbox) => ({ inbox, activity: signed }));
260
276
  for (let i = 0; i < targets.length; i += this.batchSize) {
261
277
  const chunk = targets.slice(i, i + this.batchSize).filter((t) => !this._queueIfCooling(t.inbox, signed));
262
278
  if (!chunk.length) continue;
@@ -5,6 +5,9 @@
5
5
  import { dropFollower } from '../store.mjs';
6
6
  import { emojisOf, pollOf, isContentType, trimActivity, reactionOf, quoteOf, quotePolicyOf, authorOf } from './activity.mjs';
7
7
 
8
+ // How long a follower's refollow waits before it is answered again.
9
+ const REACCEPT_MS = 60 * 60 * 1000;
10
+
8
11
  const idOf = (v) => (typeof v === 'string' ? v : v?.id);
9
12
 
10
13
  // §7.6 Add / §7.9 Remove. The side effect would be to add or remove the object
@@ -98,6 +101,18 @@ export async function onFollow(intake, activity, actor, { trusted = false } = {}
98
101
  await intake.republish({ followers: true });
99
102
  intake.log(`new follower: ${actor}`);
100
103
  }
104
+ // A refollow from someone already following is answered again — their server
105
+ // may have lost our Accept — but not every time: the same Follow sent over and
106
+ // over would otherwise make us send, and record, an Accept for each one.
107
+ if (existing) {
108
+ const c = intake.store.getContacts();
109
+ const f = c.followers.find(x => x.actor === actor);
110
+ if (f?.acceptedAt && Date.now() - Date.parse(f.acceptedAt) < REACCEPT_MS) {
111
+ intake.log(`refollow from ${actor} — already accepted within the hour, not answered again`);
112
+ return;
113
+ }
114
+ if (f) { f.acceptedAt = new Date().toISOString(); intake.store.setContacts(c); }
115
+ }
101
116
  const { acceptActivity } = await import('../wire.mjs');
102
117
  await intake.deliverer.deliver(doc.inbox,
103
118
  acceptActivity({ urls: intake.urls, followActivity: activity, serial: intake.serial++ }));
@@ -370,8 +385,9 @@ export async function onDelete(intake, activity, actor) {
370
385
  await intake.forget(s, { collect: retracted });
371
386
  }
372
387
  if (retracted.length) {
373
- const gone = new Set(retracted);
374
- await intake.publisher.unrecordOutbox(i => gone.has(i?.id));
388
+ const gone = new Set(retracted.map(u => u.object.id));
389
+ await intake.publisher.unrecordOutbox(i => gone.has(i?.id),
390
+ { record: (was) => (intake.config.private ? null : retracted.filter(u => was.includes(u.object.id))) });
375
391
  }
376
392
  // Both: an account deletion drops them from followers AND following.
377
393
  await intake.republish({ followers: true, following: true });
@@ -175,7 +175,9 @@ export async function amplify(intake, noteId, { approved = false, activity = nul
175
175
  // Marked carried before recorded: a failed outbox write costs one missing
176
176
  // entry, a failed status write would carry the same post twice.
177
177
  intake.store.updateStatus(noteId, { announcedAt: new Date().toISOString(), announceActivity: act });
178
- await intake.publisher.recordOutbox(act);
178
+ // A private group's carries are written for its members alone, so they go on
179
+ // its owner's full outbox (recorded as sent) and never on the public one.
180
+ if (!intake.config.private) await intake.publisher.recordOutbox(act);
179
181
  intake.store.setPending(intake.store.getPending().filter(p => p.noteId !== noteId));
180
182
  intake.log(`amplified ${noteId} → ${inboxes.length} inbox(es)`);
181
183
  // What the group carried is what the group's conversation holds. A forum
@@ -656,10 +656,33 @@ export class Intake {
656
656
  // document, not our reading of it); the graph is what it decides from.
657
657
  // Returns null when published, else the reason it was not — a dead letter.
658
658
  async ownerPostFrom(activity, raw, receipt) {
659
+ // The receipt names the body it vouches for; a body it does not name is
660
+ // not the owner's. A body already handled — a replayed request, or an item
661
+ // the pod would not let us delete — is not published twice.
662
+ const hash = raw ? await sha256Hex(raw) : null;
663
+ if (receipt.hash && receipt.hash !== hash) return 'owner post refused: the body is not the one its receipt names';
664
+ const seen = this.store.read('c2s-seen.json', []);
665
+ if (hash && seen.includes(hash)) {
666
+ this.log('owner post already handled — not published again');
667
+ return null;
668
+ }
659
669
  let asSent = null;
660
670
  try { asSent = raw ? JSON.parse(raw) : null; } catch { asSent = null; }
661
- const r = await this.ownerPost(activity, { raw: asSent, slug: receipt.slug || null });
662
- if (!r || r.status >= 300) return `owner post refused (${r?.status || '?'}): ${r?.body?.error || ''}`;
671
+ const r = await this.ownerPost(activity, { raw: asSent, slug: receipt.slug || null,
672
+ // What the door told the client this will be called, when it said.
673
+ ...(receipt.serial ? { serial: receipt.serial } : {}), ...(receipt.at ? { at: receipt.at } : {}) });
674
+ // Handled, one way or the other: a second sight of it changes nothing. A
675
+ // throw above is not handled, and is tried again.
676
+ if (hash) this.store.write('c2s-seen.json', [hash, ...this.store.read('c2s-seen.json', [])].slice(0, C2S_SEEN_MAX));
677
+ if (!r || r.status >= 300) {
678
+ const why = r?.body?.error || 'no reason was given';
679
+ // The door already answered the app "created"; the owner is the one
680
+ // left to tell.
681
+ await this.publisher.noteToSelf?.(`An app asked your account to publish ${article(activity.type)}, `
682
+ + `and your account refused it: ${why}. Nothing was sent.`)
683
+ .catch(e => this.log(`could not tell the owner their post was refused: ${e.message}`));
684
+ return `owner post refused (${r?.status || '?'}): ${why}`;
685
+ }
663
686
  this.log(`owner post from the outbox door published: ${r.body?.object || r.body?.id || activity.type}`);
664
687
  return null;
665
688
  }
@@ -699,3 +722,11 @@ export class Intake {
699
722
  retract(...a) { return notes.retract(this, ...a); }
700
723
  addReply(...a) { return notes.addReply(this, ...a); }
701
724
  }
725
+
726
+ // "a Like", "an Update": the kind of activity, as a sentence says it.
727
+ const article = (type) => `${/^[AEIOU]/u.test(String(type)) ? 'an' : 'a'} ${type || 'post'}`;
728
+
729
+ // Bodies the outbox door handed over that were handled, newest first.
730
+ const C2S_SEEN_MAX = 500;
731
+ const sha256Hex = async (text) => [...new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256',
732
+ new TextEncoder().encode(text)))].map(b => b.toString(16).padStart(2, '0')).join('');
@@ -91,7 +91,11 @@ export async function maybeForward(intake, activity) {
91
91
  .filter(Boolean))];
92
92
  if (!inboxes.length) return;
93
93
  intake._forwardBudget -= 1;
94
- await intake.deliverer.deliverToAll(inboxes, activity);
94
+ // Blind copies are for their sender's delivery alone and never travel on
95
+ // (§6, §7.1), from the activity or its object.
96
+ const strip = (o) => { if (!o || typeof o !== 'object') return o; const { bto, bcc, ...rest } = o; return rest; }; // eslint-disable-line no-unused-vars
97
+ const onward = { ...strip(activity), ...(activity.object && typeof activity.object === 'object' ? { object: strip(activity.object) } : {}) };
98
+ await intake.deliverer.deliverToAll(inboxes, onward);
95
99
  intake.store.write('forwarded.json', [...forwarded, id].slice(-MAX_FORWARDED));
96
100
  intake.log(`forwarded ${activity.type} ${id} to ${inboxes.length} follower inbox(es)`);
97
101
  } catch (e) {
@@ -319,10 +323,13 @@ export async function retract(intake, noteId, { collect = null } = {}) {
319
323
  if (!s.announceActivity) throw new Error('that post was never carried');
320
324
  const { undoActivity } = await import('../wire.mjs');
321
325
  const inboxes = intake.announceTargets(s.actor);
322
- await intake.deliverer.deliverToAll(inboxes,
323
- undoActivity({ urls: intake.urls, activity: s.announceActivity, serial: intake.serial++ }));
324
- if (collect) collect.push(s.announceActivity.id);
325
- else await intake.publisher.unrecordOutbox(i => i?.id === s.announceActivity.id);
326
+ const undo = undoActivity({ urls: intake.urls, activity: s.announceActivity, serial: intake.serial++ });
327
+ await intake.deliverer.deliverToAll(inboxes, undo);
328
+ // The Undo takes the carry's place in the outbox, so a reader catching up
329
+ // from it learns the carry was withdrawn.
330
+ if (collect) collect.push(undo);
331
+ else await intake.publisher.unrecordOutbox(i => i?.id === s.announceActivity.id,
332
+ { record: () => (intake.config.private ? null : undo) });
326
333
  intake.store.updateStatus(noteId, {
327
334
  announcedAt: undefined, announceActivity: undefined, retractedAt: new Date().toISOString(),
328
335
  });
@@ -0,0 +1,82 @@
1
+ // place.mjs — where on a pod a FediPod account lives, and recording it.
2
+ //
3
+ // The account's container is always named `fedipod`, inside a container its
4
+ // owner chose. The choice is recorded in their public type index as an
5
+ // instance of as:Actor (lib/pod/type-index.mjs), and that record is how the
6
+ // account is found again. An account made before the choice existed sits at
7
+ // `<pod>fedipod/` with no record, and is found there.
8
+
9
+ import * as typeIndex from '../pod/type-index.mjs';
10
+ import { PodTransport } from '../pod/transport.mjs';
11
+ import { rootFromContainer, rootOfActor } from '../pod/location.mjs';
12
+ import { DEFAULT_ROOT } from './wire.mjs';
13
+
14
+ export { podRootPath } from '../pod/location.mjs';
15
+
16
+ /** The container someone typed, as the account's root — or why it cannot be. */
17
+ export const chosenRoot = (podBase, typed) => rootFromContainer(podBase, typed, DEFAULT_ROOT);
18
+
19
+ /**
20
+ * The roots of the accounts this pod's owner has recorded, then the place an
21
+ * older account sits without a record. `pod` is a transport acting as the owner.
22
+ */
23
+ export async function candidateRoots(pod, podBase) {
24
+ const recorded = await typeIndex.registeredActors(pod, podBase).catch(() => []);
25
+ const roots = recorded.map(a => rootOfActor(podBase, a)).filter(Boolean);
26
+ return [...new Set([...roots, DEFAULT_ROOT])];
27
+ }
28
+
29
+ /**
30
+ * The account on this pod: the first candidate whose config `read(root)` finds
31
+ * and `accept(config)` takes. Null when there is none.
32
+ */
33
+ export async function findAccount(pod, podBase, read, accept = () => true) {
34
+ for (const root of await candidateRoots(pod, podBase)) {
35
+ const config = await read(root).catch(() => null);
36
+ if (config && accept(config)) return { root, config };
37
+ }
38
+ return null;
39
+ }
40
+
41
+ /**
42
+ * Record the account's place in the owner's public type index. With no index,
43
+ * one is made only when `create` is true — the person's own yes. Returns what
44
+ * was done: 'registered', 'already', or 'no-index'.
45
+ */
46
+ export async function recordPlace(pod, podBase, actorAtPod, { create = false } = {}) {
47
+ let index = await typeIndex.findPublicIndex(pod, podBase);
48
+ if (!index) {
49
+ if (!create) return 'no-index';
50
+ index = await typeIndex.createPublicIndex(pod, podBase);
51
+ }
52
+ return (await typeIndex.register(pod, index, actorAtPod)) ? 'registered' : 'already';
53
+ }
54
+
55
+ /** Whether this person's profile names a public type index. */
56
+ export async function hasPublicIndex(pod, podBase) {
57
+ return !!(await typeIndex.findPublicIndex(pod, podBase).catch(() => null));
58
+ }
59
+
60
+ /**
61
+ * Whether a pod's owner has a public type index, asked as a stranger before any
62
+ * credential exists: true, false, or null when the profile could not be read.
63
+ */
64
+ export async function publicIndexKnown(fetchImpl, podBase, webId = `${podBase}profile/card#me`) {
65
+ const pod = new PodTransport({ fetch: fetchImpl }, { webId });
66
+ try { return !!(await typeIndex.findPublicIndex(pod, podBase)); } catch { return null; }
67
+ }
68
+
69
+ /**
70
+ * The root for a container typed before the pod's own path was known — a new
71
+ * pod: the answer is taken as inside the pod's root, whatever it turns out to be.
72
+ */
73
+ export function chosenRootInNewPod(podBase, typed) {
74
+ const inner = String(typed ?? '').trim().replace(/^\/+/, '');
75
+ return chosenRoot(podBase, new URL(podBase).pathname + inner);
76
+ }
77
+
78
+ /** The error that stops a setup until its person has said yes to a new type index. */
79
+ export function needsIndex(detail) {
80
+ return Object.assign(new Error(`${detail} FediPod records where your account lives in your public type index, `
81
+ + 'so it needs one. Say yes to creating it, or nothing is set up.'), { code: 'needs-index' });
82
+ }
@@ -9,6 +9,7 @@ import * as podFollowers from '../../pod/followers.mjs';
9
9
  import * as podFollowing from '../../pod/following.mjs';
10
10
  import * as podFeatured from '../../pod/featured.mjs';
11
11
  import * as podPrivate from '../../pod/private.mjs';
12
+ import * as podNotes from '../../pod/notes.mjs';
12
13
 
13
14
  // The default for publishCollections: the whole public surface, ACLs included.
14
15
  // A caller that knows what it changed narrows it; a caller that says nothing
@@ -133,6 +134,9 @@ export function readPublishedFollowers(publisher) { return podFollowers.readPubl
133
134
 
134
135
  export async function publishCollections(publisher, which = ALL_COLLECTIONS) {
135
136
  const { urls } = publisher;
137
+ // A block takes the blocked out of the followers too (PodStore.setBlocklist),
138
+ // so the published list changes with it.
139
+ if (which.blocked) which = { ...which, followers: true };
136
140
  const contacts = publisher.store.getContacts();
137
141
  if (which.followers) {
138
142
  // Reconcile walks the published pages, so — like the outbox — pay for it
@@ -147,7 +151,8 @@ export async function publishCollections(publisher, which = ALL_COLLECTIONS) {
147
151
  }
148
152
  if (which.following) {
149
153
  await podFollowing.write(publisher.remote, urls,
150
- wire.orderedCollection(urls.following, contacts.following.filter(f => f.accepted).map(f => f.actor)),
154
+ // newest first (§5): contacts append as follows are made
155
+ wire.orderedCollection(urls.following, contacts.following.filter(f => f.accepted).map(f => f.actor).reverse()),
151
156
  { publicRead: which.acls });
152
157
  }
153
158
  if (which.pending) await publisher.publishPending();
@@ -158,6 +163,14 @@ export async function publishCollections(publisher, which = ALL_COLLECTIONS) {
158
163
  // reader, a refusal nothing caches, and every server asked again.
159
164
  if (which.featured) await publisher.publishFeatured();
160
165
  if (which.outbox) {
166
+ // A group made private takes back what it carried while open, as it goes:
167
+ // those posts were carried for its members, and a group turned private must
168
+ // not go on listing them to the world. Taken back, not hidden, so opening
169
+ // the group again does not put them back.
170
+ if (publisher.config?.kind === 'group' && publisher.config?.private) {
171
+ const carry = (i) => i?.type === 'Announce' || i?.type === 'Undo';
172
+ if (publisher.store.read('outbox.json', []).some(carry)) await unrecordOutbox(publisher, carry);
173
+ }
161
174
  const outbox = publisher.store.read('outbox.json', []);
162
175
  // Reconcile reads every published page, which is the expensive part of a
163
176
  // profile save. It is worth paying only when we are about to write pages
@@ -206,14 +219,32 @@ export async function recordOutbox(publisher, item) {
206
219
  const outbox = publisher.store.read('outbox.json', []);
207
220
  outbox.unshift(item);
208
221
  publisher.store.write('outbox.json', outbox);
222
+ await writeEntry(publisher, item);
209
223
  await publisher.publishOutbox(outbox);
210
224
  }
211
225
 
226
+ // An entry kept whole — a boost, an edit, a deletion, an undo — is also written
227
+ // as its own document at its id, beside the posts, so it can be fetched (§3.1).
228
+ // Only for the public record, and only where the id is one of those documents.
229
+ async function writeEntry(publisher, item) {
230
+ const notes = publisher.urls?.notes;
231
+ if (!notes || !item || typeof item !== 'object' || typeof item.id !== 'string' || !item.id.startsWith(notes)) return;
232
+ await podNotes.writeCreate(publisher.remote, item.id, { '@context': wire.AS_CTX, ...item })
233
+ .catch(e => publisher.log?.(`${item.type} ${item.id} not written as its own document: ${e.message}`));
234
+ }
235
+
212
236
  // Taking something out of the outbox is a DECISION — a post deleted, a boost
213
237
  // undone. It leaves a mark for the same reason dropFollower does: the pod's
214
238
  // copy is rewritten right after, but a rewrite that fails would otherwise let
215
239
  // the next reconcile put the entry back. Bounded, like the follower one.
216
- export async function unrecordOutbox(publisher, matches) {
240
+ //
241
+ // What took it back goes in its place: a Delete for a deleted post, an Undo for
242
+ // a withdrawn boost. A reader catching up from the outbox otherwise never learns
243
+ // the entry it saw earlier was withdrawn. `record(gone)` names that activity (or
244
+ // several, newest last), or null when nothing it cares about was in the outbox
245
+ // (a private post never was). Both land in the one write, so the outbox is
246
+ // never seen with the entry gone and its reason missing.
247
+ export async function unrecordOutbox(publisher, matches, { record = null } = {}) {
217
248
  const before = publisher.store.read('outbox.json', []);
218
249
  const outbox = before.filter(i => !matches(i));
219
250
  const gone = before.filter(i => matches(i))
@@ -224,8 +255,12 @@ export async function unrecordOutbox(publisher, matches) {
224
255
  publisher.store.write('outbox-removed.json',
225
256
  [...marks, ...gone.map(id => ({ id, at }))].slice(-500));
226
257
  }
258
+ const reasons = [].concat((gone.length && record ? record(gone) : null) || []);
259
+ for (const r of reasons) { outbox.unshift(r); await writeEntry(publisher, r); }
227
260
  publisher.store.write('outbox.json', outbox);
228
261
  await publisher.publishOutbox(outbox);
262
+ publisher.unrecordOwn?.(matches);
263
+ return gone;
229
264
  }
230
265
 
231
266
  // The pinned posts, as the actor's featured collection — the one document a