fedipod 1.36.6 → 1.40.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 (94) hide show
  1. package/README.md +29 -8
  2. package/architecture.md +8 -0
  3. package/bin/fedipod.mjs +6 -0
  4. package/cli.md +7 -1
  5. package/device-agent.md +3 -3
  6. package/gateway.md +69 -8
  7. package/groups.md +2 -1
  8. package/gui.md +3 -1
  9. package/lib/client/c2s.mjs +97 -43
  10. package/lib/client/masto/accounts.mjs +1 -0
  11. package/lib/client/masto/bridge.mjs +61 -0
  12. package/lib/client/masto/index.mjs +4 -1
  13. package/lib/client/masto/oauth.mjs +1 -1
  14. package/lib/client/masto/statuses.mjs +3 -0
  15. package/lib/connections/acctfeed.mjs +13 -9
  16. package/lib/connections/bskyfeed.mjs +13 -9
  17. package/lib/connections/tagfeed.mjs +14 -9
  18. package/lib/core/deliver.mjs +17 -1
  19. package/lib/core/intake/activities.mjs +18 -2
  20. package/lib/core/intake/group.mjs +3 -1
  21. package/lib/core/intake/index.mjs +226 -48
  22. package/lib/core/intake/notes.mjs +12 -5
  23. package/lib/core/intake/verify.mjs +11 -0
  24. package/lib/core/lease.mjs +15 -1
  25. package/lib/core/place.mjs +82 -0
  26. package/lib/core/pod-only.mjs +4 -0
  27. package/lib/core/publisher/collections.mjs +37 -2
  28. package/lib/core/publisher/index.mjs +25 -1
  29. package/lib/core/publisher/notes.mjs +73 -7
  30. package/lib/core/publisher/own.mjs +143 -0
  31. package/lib/core/publisher/questions.mjs +5 -3
  32. package/lib/core/scheduled.mjs +39 -0
  33. package/lib/core/social.mjs +62 -32
  34. package/lib/core/storage.mjs +67 -0
  35. package/lib/core/store.mjs +77 -5
  36. package/lib/core/wire.mjs +51 -15
  37. package/lib/device/admin/routes/lifecycle.mjs +1 -1
  38. package/lib/device/admin/routes/setup.mjs +17 -1
  39. package/lib/device/cli/commands/setup.mjs +50 -8
  40. package/lib/device/cli/context.mjs +1 -1
  41. package/lib/device/migrate.mjs +1 -1
  42. package/lib/device/setup.mjs +45 -8
  43. package/lib/gateway/account-agent.mjs +111 -0
  44. package/lib/gateway/copy.mjs +381 -0
  45. package/lib/gateway/front-core.mjs +74 -63
  46. package/lib/gateway/gateway-core.mjs +107 -9
  47. package/lib/gateway/held-mail.mjs +198 -0
  48. package/lib/gateway/keeper-due.mjs +39 -0
  49. package/lib/gateway/keeper-session.mjs +10 -0
  50. package/lib/gateway/keeper.mjs +72 -0
  51. package/lib/gateway/masto-gateway.mjs +510 -0
  52. package/lib/gateway/quiet.mjs +7 -2
  53. package/lib/gateway/relay-extras.mjs +89 -0
  54. package/lib/gateway/state-api.mjs +207 -0
  55. package/lib/gateway/token-claims.mjs +16 -0
  56. package/lib/pod/containers.mjs +17 -0
  57. package/lib/pod/location.mjs +52 -0
  58. package/lib/pod/notes.mjs +2 -4
  59. package/lib/pod/transport.mjs +214 -24
  60. package/lib/pod/type-index.mjs +101 -0
  61. package/lib/pod/urls.mjs +6 -0
  62. package/lib/server/embed.mjs +7 -6
  63. package/lib/session/README.md +5 -5
  64. package/lib/session/demo.html +1 -1
  65. package/lib/session/fedi-account.mjs +19 -10
  66. package/lib/session/package.json +2 -2
  67. package/package.json +2 -2
  68. package/run-agent.mjs +6 -16
  69. package/scripts/stage-site.mjs +17 -4
  70. package/web/admin/actors.js +2 -0
  71. package/web/admin/gateway.js +14 -1
  72. package/web/admin/index.html +13 -0
  73. package/web/admin/oauth-signin.mjs +1 -1
  74. package/web/admin/record.js +4 -1
  75. package/web/admin/setup/index.html +17 -1
  76. package/web/admin/setup/setup.js +18 -5
  77. package/web/app/README.md +2 -2
  78. package/web/app/admin-facade.mjs +12 -2
  79. package/web/app/agent.mjs +228 -51
  80. package/web/app/boot.mjs +75 -32
  81. package/web/app/copy-mode.mjs +223 -0
  82. package/web/app/dist/boot.js +498 -88
  83. package/web/app/dist/boot.js.map +4 -4
  84. package/web/app/dist/sw.js +4016 -2539
  85. package/web/app/dist/sw.js.map +4 -4
  86. package/web/app/index.html +16 -0
  87. package/web/app/signup.mjs +65 -26
  88. package/web/app/sw-src.mjs +15 -53
  89. package/web/app/update.js +3 -2
  90. package/web/app/warm-start.mjs +115 -0
  91. package/web/app-signin/app-signin.mjs +78 -0
  92. package/web/app-signin/index.html +41 -0
  93. package/web/front/run.html +7 -1
  94. package/web/front/run.js +30 -4
@@ -110,6 +110,17 @@ export async function readReceipt(intake, itemUrl) {
110
110
  } catch { return null; }
111
111
  }
112
112
 
113
+ // A receipt carried inside a batch rather than beside its item: the same
114
+ // check, without the read.
115
+ export async function checkReceipt(intake, receipt) {
116
+ const secret = intake.gatewaySecret();
117
+ if (!secret || !receipt || typeof receipt !== 'object') return null;
118
+ try {
119
+ const { verifyReceipt } = await import('../../gateway/httpsig.mjs');
120
+ return verifyReceipt(receipt, secret) ? receipt : null;
121
+ } catch { return null; }
122
+ }
123
+
113
124
  // Whether a receipt says anything about THIS actor. Verified-and-about-someone
114
125
  // -else is worth exactly as much as unverified, and is treated the same way:
115
126
  // the drain's verify-by-dereference still stands behind it.
@@ -149,6 +149,17 @@ export class Lease {
149
149
  return true;
150
150
  }
151
151
 
152
+ // A restarted browser worker that held the lease a moment ago: one read.
153
+ // Still ours answers { renewDue }, and when no renewal is due yet the lease
154
+ // is taken up as it stands, with no write. Not ours, or unreadable, is null.
155
+ async resume() {
156
+ const cur = await this.readFresh();
157
+ if (cur === UNREADABLE || !cur || cur.holder !== this.id || Date.now() >= cur.expiresAt) return null;
158
+ const renewDue = cur.expiresAt - Date.now() <= TTL_MS - RENEW_MS;
159
+ if (!renewDue) { this.heldUntil = cur.expiresAt; this.denied = null; }
160
+ return { renewDue };
161
+ }
162
+
152
163
  // Self-scheduling rather than setInterval, so each agent's renewals drift
153
164
  // apart instead of several beating in lockstep against one pod.
154
165
  // Idempotent, like Deliverer.startQueue: startActive reaches this twice on the
@@ -199,8 +210,11 @@ export class Lease {
199
210
  // point of the TTL is that after it another agent may start draining.
200
211
  stillHeld() { return !this.stopped && (!this.heldUntil || Date.now() < this.heldUntil); }
201
212
 
213
+ // Only while it is still ours: a release landing after another agent took
214
+ // the lease over would otherwise stand that agent down. Without an ETag to
215
+ // ask with, as before.
202
216
  async release() {
203
217
  this.stopRenewal();
204
- await this.write({ holder: this.id, expiresAt: 0 }).catch(() => {});
218
+ await this.write({ holder: this.id, expiresAt: 0 }, this.etag ? { ifMatch: this.etag } : {}).catch(() => {});
205
219
  }
206
220
  }
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ // pod-only.mjs — what stays on the pod, and only there, when an account works
2
+ // from a copy kept at its gateway (lib/gateway/copy.mjs): the signing key, the
3
+ // pod's own lease, and the passwords and tokens of accounts on other servers.
4
+ export const podOnly = (name) => name === 'keys.json' || name === 'lease.json' || name.startsWith('conn-');
@@ -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
@@ -28,6 +28,8 @@ import { ALL_COLLECTIONS } from './collections.mjs';
28
28
  import * as restore from './restore.mjs';
29
29
  import * as notes from './notes.mjs';
30
30
  import * as questions from './questions.mjs';
31
+ import * as own from './own.mjs';
32
+ import { recordPlace } from '../place.mjs';
31
33
 
32
34
  const AGENT_VERSION = JSON.parse(fs.readFileSync(
33
35
  path.join(path.dirname(fileURLToPath(import.meta.url)), '../../../package.json'), 'utf8')).version;
@@ -40,6 +42,8 @@ export class Publisher {
40
42
  this.remote = remote;
41
43
  this.store = store;
42
44
  this.deliverer = deliverer;
45
+ // Everything this actor sends goes on the outbox its owner reads.
46
+ if (deliverer) deliverer.onSent = (a) => own.recordOwn(this, a);
43
47
  this.publicKeyPem = publicKeyPem;
44
48
  this.assertionKey = assertionKey; // Ed25519 public half, multibase; null = no proofs
45
49
  // Where this identity's client surface answers, when that is an address a
@@ -111,6 +115,9 @@ export class Publisher {
111
115
  postingRestrictedToMods: this.config.kind === 'group' ? !!this.config.postingRestrictedToMods : null,
112
116
  moderators,
113
117
  pendingFollowers: priv ? urls.pendingFollowers : null,
118
+ // Named only where the private folder is proved to keep it private.
119
+ liked: priv ? urls.liked : null,
120
+ ownerOutbox: priv ? urls.ownOutbox : null,
114
121
  pendingFollowing: priv ? urls.pendingFollowing : null,
115
122
  blocked: priv ? urls.blocked : null,
116
123
  inbox,
@@ -191,6 +198,8 @@ export class Publisher {
191
198
  actor: actorDoc, handle: this.config.handle, host,
192
199
  quiesced: !!this.config.quiescedAt, version: AGENT_VERSION,
193
200
  moderators: this.config.moderators || [],
201
+ // Who else the rules name: granting or withdrawing a keeper rewrites them.
202
+ keepers: this.remote?.keepers || [],
194
203
  })).digest('hex').slice(0, 32);
195
204
  // The human page has its own gate: it changes with pins and the joined
196
205
  // date as well as with the profile, and costs one write when it does.
@@ -244,6 +253,13 @@ export class Publisher {
244
253
  } catch (e) {
245
254
  this.log(`WebID profile not updated with the actor link: ${e.message}`);
246
255
  }
256
+ // Where the account lives, in the owner's public type index — only where
257
+ // they already have one; an index is made only on their yes, at sign-up.
258
+ if (!this.config.forum) {
259
+ await recordPlace(this.remote, urls.base, urls.home + 'ap/actor')
260
+ .then((r) => { if (r === 'registered') this.log('the public type index now records where the account lives'); })
261
+ .catch((e) => this.log(`type index not updated with the account's place: ${e.message}`));
262
+ }
247
263
 
248
264
  // inbox: public may only Append; owner (the agent) reads + drains. A
249
265
  // quiesced actor keeps its name resolving but takes no more mail, so a
@@ -277,7 +293,7 @@ export class Publisher {
277
293
  this.log(this.config.gateway?.frontActor || wire.webfingerHost(urls.base)
278
294
  ? `profile published: @${pubName}@${pubHost} → ${urls.actor}`
279
295
  : `profile published → ${urls.actor} — NOT discoverable as @${pubName}@${pubHost}: `
280
- + 'this pod is a suffix-based host, and WebFinger is only answered at a host root');
296
+ + 'this pod is suffixed, and WebFinger is only answered for a subdomained pod');
281
297
  return { unreachable, updated };
282
298
  }
283
299
 
@@ -468,6 +484,14 @@ export class Publisher {
468
484
  publishBlocked(...a) { return collections.publishBlocked(this, ...a); }
469
485
  recordOutbox(...a) { return collections.recordOutbox(this, ...a); }
470
486
  unrecordOutbox(...a) { return collections.unrecordOutbox(this, ...a); }
487
+ recordOwn(...a) { return own.recordOwn(this, ...a); }
488
+ backfillLiked() { return own.backfillLiked(this); }
489
+ inboxesFor(...a) { return notes.inboxesFor(this, ...a); }
490
+ noteToSelf(...a) { return notes.noteToSelf(this, ...a); }
491
+ updateObject(...a) { return notes.updateObject(this, ...a); }
492
+ unrecordOwn(...a) { return own.unrecordOwn(this, ...a); }
493
+ publishOwn(...a) { return own.publishOwn(this, ...a); }
494
+ ownSettled() { return own.ownSettled(this); }
471
495
  publishFeatured(...a) { return collections.publishFeatured(this, ...a); }
472
496
 
473
497
  // restore.mjs
@@ -79,7 +79,9 @@ export async function mentionsFor(publisher, content, inReplyTo) {
79
79
  const doc = await publisher.resolveMention(handle).catch(() => null);
80
80
  if (!doc?.id) { publisher.log(`mention @${handle} did not resolve — left as text`); continue; }
81
81
  if (!inText.has(handle) && doc.type !== 'Group') continue; // author trimmed them out
82
- mentions.push({ handle, actor: doc.id, page: doc.url || null, inbox: doc.endpoints?.sharedInbox || doc.inbox });
82
+ // Naming yourself still tags you; it never delivers to your own inbox (§7.1).
83
+ const self = doc.id === publisher.urls.actor;
84
+ mentions.push({ handle, actor: doc.id, page: doc.url || null, inbox: self ? null : doc.endpoints?.sharedInbox || doc.inbox });
83
85
  }
84
86
  return mentions;
85
87
  }
@@ -144,12 +146,62 @@ export function assertDirectAddressed(content, mentions) {
144
146
  // otherwise the agent mints one as it always has.
145
147
  const SLUG_OK = /^[A-Za-z0-9._-]{1,64}$/u;
146
148
  export const safeSlug = (s) => (typeof s === 'string' && SLUG_OK.test(s) && !/^\.+$/u.test(s) ? s : null);
147
- async function slugFor(publisher, container, wanted, published) {
149
+ export async function slugFor(publisher, container, wanted, published) {
148
150
  const name = safeSlug(wanted);
149
151
  if (name && !(await podNotes.read(publisher.remote, container + name).catch(() => null))) return name;
150
152
  return published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
151
153
  }
152
154
 
155
+ // A message from this account to itself, for something its owner must hear
156
+ // and has no other way to: a post an app sent through the outbox door that the
157
+ // account then refused, after the door had already told the app "created".
158
+ // Addressed to the owner alone and kept in the private container, so it shows
159
+ // in the owner's apps as a direct message and a mention, and nowhere else.
160
+ export async function noteToSelf(publisher, text) {
161
+ const { urls } = publisher;
162
+ const published = new Date().toISOString();
163
+ const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
164
+ const id = urls.privateNotes + slug;
165
+ const content = wire.contentHtml(text);
166
+ await podNotes.write(publisher.remote, id, { '@context': wire.AS_CTX, id, type: 'Note',
167
+ attributedTo: urls.actor, to: [urls.actor], published, content })
168
+ .catch(e => publisher.log(`note to self kept here only — the pod would not take it: ${e.message}`));
169
+ publisher.store.addStatus({ noteId: id, actor: urls.actor, content, text, published,
170
+ kind: 'post', visibility: 'direct', slug });
171
+ publisher.store.addNotification({ type: 'mention', actor: urls.actor, noteId: id });
172
+ return id;
173
+ }
174
+
175
+ // An app's edit of an object that is not a Note — an annotation, a poll —
176
+ // changes only the fields it sends (§6.3); a field sent as null is removed.
177
+ // What the object is, who made it and when, where it lives and who it is
178
+ // addressed to stay as they are, and so do a poll's choices and its count.
179
+ const FIXED = new Set(['@context', 'id', 'type', 'attributedTo', 'published', 'to', 'cc', 'bto', 'bcc',
180
+ 'audience', 'oneOf', 'anyOf', 'endTime', 'closed', 'votersCount', 'replies']);
181
+ export async function updateObject(publisher, s, patch, { updated = new Date().toISOString() } = {}) {
182
+ const { urls } = publisher;
183
+ const doc = await podNotes.read(publisher.remote, s.noteId);
184
+ if (!doc?.id) throw new Error('the object is not on the pod to change');
185
+ const next = { ...doc };
186
+ for (const [k, v] of Object.entries(patch || {})) {
187
+ if (FIXED.has(k)) continue;
188
+ if (v === null) delete next[k]; else next[k] = v;
189
+ }
190
+ if (typeof next.content === 'string') next.content = wire.sanitizeHtml(next.content);
191
+ next.updated = updated;
192
+ await podNotes.write(publisher.remote, doc.id, next);
193
+ await podNotes.writeCreate(publisher.remote, wire.createActivityId(doc.id), wire.createActivity(next, urls));
194
+ publisher.store.updateStatus(s.noteId, { content: rowContent(next), editedAt: updated });
195
+ const update = wire.updateActivity(next, urls);
196
+ const inboxes = [...new Set([
197
+ ...(s.visibility === 'direct' ? [] : publisher.store.getContacts().followers.map(f => f.sharedInbox || f.inbox)),
198
+ ...(await inboxesFor(publisher, [...(s.addressed || []), ...(s.replyActor ? [s.replyActor] : [])])),
199
+ ].filter(Boolean))];
200
+ await publisher.deliverer.deliverToAll(inboxes, update);
201
+ if (publisher.store.read('outbox.json', []).includes(s.noteId)) await publisher.recordOutbox(update);
202
+ return update;
203
+ }
204
+
153
205
  // What the author's own timeline shows for an object: its content, else its
154
206
  // text under one of the names other vocabularies use, else a link to it.
155
207
  export function rowContent(obj) {
@@ -173,7 +225,9 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
173
225
  const published = new Date().toISOString();
174
226
  const slug = await slugFor(publisher, priv ? urls.privateNotes : urls.notes, wanted, published);
175
227
  const mentions = await publisher._mentionsFor(content, inReplyTo);
176
- if (visibility === 'direct') assertDirectAddressed(content, mentions);
228
+ // Addressed by id (a client-to-server post) it is addressed already; only a
229
+ // post that names nobody but in its text has to name them there.
230
+ if (visibility === 'direct' && !also.length && !deliverTo.length) assertDirectAddressed(content, mentions);
177
231
  const reply = await replyTarget(publisher, inReplyTo, mentions, visibility);
178
232
  const selfQuote = quote?.id && quote.actor === urls.actor;
179
233
  const quoted = quote?.id ? {
@@ -199,6 +253,11 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
199
253
  publisher.store.addStatus({
200
254
  noteId: note.id, actor: urls.actor, content: note.content, published, inReplyTo,
201
255
  kind: 'post', slug, text: content, visibility,
256
+ // Everyone a client named, blind copies included: an edit or a deletion
257
+ // must reach them too. Kept here only, never published. `named` is the
258
+ // visible part, which an edit addresses again.
259
+ ...(also.length || deliverTo.length ? { addressed: [...new Set([...also, ...deliverTo])] } : {}),
260
+ ...(also.length ? { named: [...also] } : {}),
202
261
  ...(spoilerText ? { spoiler: spoilerText } : {}),
203
262
  ...(note.sensitive ? { sensitive: true } : {}),
204
263
  ...(attachments?.length ? { attachments } : {}),
@@ -301,6 +360,8 @@ export async function publishObject(publisher, object, { visibility = 'public',
301
360
  publisher.store.addStatus({
302
361
  noteId: id, actor: urls.actor, content: rowContent(row), published: row.published || published,
303
362
  kind: 'post', slug: name, visibility,
363
+ ...(also.length || deliverTo.length ? { addressed: [...new Set([...also, ...deliverTo])] } : {}),
364
+ ...(also.length ? { named: [...also] } : {}),
304
365
  });
305
366
  if (await publisher.store.commit?.() === false) publisher.log(`object published but its timeline row was refused: ${id}`);
306
367
 
@@ -332,16 +393,16 @@ export function rowTags(note) {
332
393
  return { ...(mentions.length ? { mentions } : {}), ...(hashtags.length ? { tags: hashtags } : {}) };
333
394
  }
334
395
 
335
- export async function updateNote(publisher, s, { content, spoilerText = null, sensitive = null, attachments = null } = {}) {
396
+ export async function updateNote(publisher, s, { content, spoilerText = null, sensitive = null, attachments = null, updated = new Date().toISOString() } = {}) {
336
397
  const { urls } = publisher;
337
- const updated = new Date().toISOString();
338
398
  const inText = new Set(wire.mentionsIn(content));
339
399
  const mentions = [];
340
400
  for (const handle of inText) {
341
401
  if (!publisher.resolveMention) break;
342
402
  const doc = await publisher.resolveMention(handle).catch(() => null);
343
403
  if (!doc?.id) { publisher.log(`mention @${handle} did not resolve — left as text`); continue; }
344
- mentions.push({ handle, actor: doc.id, page: doc.url || null, inbox: doc.endpoints?.sharedInbox || doc.inbox });
404
+ mentions.push({ handle, actor: doc.id, page: doc.url || null,
405
+ inbox: doc.id === urls.actor ? null : doc.endpoints?.sharedInbox || doc.inbox });
345
406
  }
346
407
  const atts = attachments ?? s.attachments ?? [];
347
408
  // The note stays in the container its visibility put it in; a recovered
@@ -353,7 +414,7 @@ export async function updateNote(publisher, s, { content, spoilerText = null, se
353
414
  urls, slug, content, published: s.published, inReplyTo: s.inReplyTo,
354
415
  attachments: atts, mentions, visibility: s.visibility || 'public',
355
416
  summary: spoilerText, sensitive: sensitive ?? !!s.sensitive, updated, container,
356
- also: reply ? [reply.actor] : [],
417
+ also: [...new Set([...(s.named || []), ...(reply ? [reply.actor] : [])])],
357
418
  quote: rowQuote(s),
358
419
  });
359
420
  await podNotes.write(publisher.remote, note.id, note);
@@ -372,8 +433,13 @@ export async function updateNote(publisher, s, { content, spoilerText = null, se
372
433
  ...(s.visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
373
434
  ...mentions.map(m => m.inbox),
374
435
  reply?.inbox,
436
+ // everyone a client named or blind-copied, and a quoted author
437
+ ...(await inboxesFor(publisher, [...(s.addressed || []), ...(s.quoteRequest?.actor ? [s.quoteRequest.actor] : [])])),
375
438
  ].filter(Boolean))];
376
439
  await publisher.deliverer.deliverToAll(inboxes, update);
440
+ // The edit goes on the record beside the Create, for a post the outbox lists:
441
+ // a reader catching up from the outbox otherwise keeps the words it first saw.
442
+ if (publisher.store.read('outbox.json', []).includes(s.noteId)) await publisher.recordOutbox(update);
377
443
  publisher.log(`note edited: ${note.id} → ${inboxes.length} inbox(es)`);
378
444
  return patched;
379
445
  }
@@ -0,0 +1,143 @@
1
+ // own.mjs — the outbox as its owner reads it, and the owner's liked list.
2
+ //
3
+ // The public outbox is what a stranger may see: posts and boosts, and what
4
+ // withdrew them. ActivityPub's outbox is every message the actor produced,
5
+ // filtered by who is asking (§5.1), so its owner is owed the rest as well —
6
+ // likes, follows, undos, follower decisions, followers-only and direct posts.
7
+ // That view lives in the private container, whose owner-only rule it inherits,
8
+ // and is what the outbox address answers its signed-in owner.
9
+ //
10
+ // Everything this actor sends passes through the deliverer, which hands each
11
+ // fresh activity here (Deliverer.onSent), so nothing has to remember to record
12
+ // itself. What is never sent — a block or a pin posted by a client — is
13
+ // recorded by the client-to-server dispatcher.
14
+ //
15
+ // `liked` (§5.5) is the objects this actor has liked and not taken back,
16
+ // kept beside it under the same rule.
17
+
18
+ import crypto from 'node:crypto';
19
+ import * as wire from '../wire.mjs';
20
+ import * as collection from '../../pod/collection.mjs';
21
+
22
+ const OWN = 'outbox-own.json';
23
+ const LIKED = 'liked.json';
24
+ // The owner's view keeps its newest entries; older ones leave the view only —
25
+ // the public outbox, and everything the account did, are untouched.
26
+ export const OWN_MAX = 5000;
27
+
28
+ // A Create is kept by its object's id, as the public outbox keeps it, so the
29
+ // two can be matched when a post is withdrawn; anything else is kept whole.
30
+ const localForm = (a) => (a?.type === 'Create' && (typeof a.object === 'string' ? a.object : a.object?.id)) || a;
31
+
32
+ export function recordOwn(publisher, activity) {
33
+ const { urls, store } = publisher;
34
+ if (!activity?.type || activity.actor !== urls.actor) return false;
35
+ const item = localForm(activity);
36
+ const id = wire.outboxItemId(item);
37
+ const own = store.read(OWN, []);
38
+ if (id && own.some(i => wire.outboxItemId(i) === id)) return false; // a retry, or said twice
39
+ own.unshift(item);
40
+ store.write(OWN, own.slice(0, OWN_MAX));
41
+ if (activity.type === 'Like' || (activity.type === 'Undo' && activity.object?.type === 'Like')) {
42
+ backfillLiked(publisher);
43
+ const object = wire.outboxItemId(activity.type === 'Like' ? activity.object : activity.object.object);
44
+ if (object) {
45
+ const liked = store.read(LIKED, []).filter(o => o !== object);
46
+ if (activity.type === 'Like') liked.unshift(object);
47
+ store.write(LIKED, liked);
48
+ }
49
+ }
50
+ schedule(publisher);
51
+ return true;
52
+ }
53
+
54
+ // An account that liked things before the list existed: the likes it still
55
+ // holds (a timeline row marked favourited, with the Like that was sent) are
56
+ // its liked list to begin with. Once only — a list that exists, even empty,
57
+ // is the record from then on.
58
+ export function backfillLiked(publisher) {
59
+ const { store } = publisher;
60
+ if (store.read(LIKED, null) !== null) return 0;
61
+ const liked = (store.getStatuses?.() || [])
62
+ .filter(s => s.favourited && s.noteId)
63
+ .sort((x, y) => String(y.published || '').localeCompare(String(x.published || '')))
64
+ .map(s => s.noteId);
65
+ store.write(LIKED, [...new Set(liked)]);
66
+ schedule(publisher);
67
+ return liked.length;
68
+ }
69
+
70
+ // A post deleted or a boost withdrawn leaves the owner's view as it leaves the
71
+ // public one; the Delete or Undo that did it is already recorded, having been
72
+ // sent.
73
+ export function unrecordOwn(publisher, matches) {
74
+ const own = publisher.store.read(OWN, []);
75
+ const kept = own.filter(i => !matches(i));
76
+ if (kept.length === own.length) return;
77
+ publisher.store.write(OWN, kept);
78
+ schedule(publisher);
79
+ }
80
+
81
+ // Recording never waits on the pod: a like is answered when it is sent, and
82
+ // the pages follow. One publish at a time, the last one covering every record
83
+ // made while it waited.
84
+ function schedule(publisher) {
85
+ if (publisher._ownQueued) return;
86
+ publisher._ownQueued = true;
87
+ publisher._ownChain = (publisher._ownChain || Promise.resolve()).then(async () => {
88
+ publisher._ownQueued = false;
89
+ try { await publishOwn(publisher); }
90
+ catch (e) { publisher.log?.(`owner's outbox view not published: ${e.message}`); }
91
+ });
92
+ }
93
+
94
+ export function ownSettled(publisher) { return publisher._ownChain || Promise.resolve(); }
95
+
96
+ export async function publishOwn(publisher) {
97
+ const { urls, store, remote } = publisher;
98
+ // Written only where the private folder is proved to keep it private — the
99
+ // same bar the pending and blocked lists clear. Recorded meanwhile, and
100
+ // published the first time the bar is met.
101
+ if (publisher.privateReady && await publisher.privateReady() !== true) return;
102
+ const seen = store.read('published.json', {});
103
+ const own = store.read(OWN, []);
104
+ const out = wire.outboxPaging(own, seen.ownIndex || []);
105
+ const ownPages = await writePages(remote, seen.ownPages, out.pages.map((items, i) => ({
106
+ id: wire.outboxPageId(urls.ownOutbox, i + 1), doc: wire.outboxPage(urls.ownOutbox, i + 1, items),
107
+ })));
108
+ const ownHead = await writeHead(remote, seen.ownHead, urls.ownOutbox, wire.outboxHead(urls.ownOutbox, own.length, out.pages.length));
109
+
110
+ const liked = store.read(LIKED, []);
111
+ // liked.json is newest first; pages are filled from the oldest end.
112
+ const lk = wire.followersPaging([...liked].reverse(), seen.likedIndex || []);
113
+ const likedPages = await writePages(remote, seen.likedPages, lk.pages.map((items, i) => ({
114
+ id: wire.followersPageId(urls.liked, i + 1), doc: wire.followersPage(urls.liked, i + 1, items, lk.pages.length),
115
+ })));
116
+ const likedHead = await writeHead(remote, seen.likedHead, urls.liked, wire.followersHead(urls.liked, liked.length, lk.pages.length));
117
+
118
+ store.write('published.json', { ...store.read('published.json', {}),
119
+ ownPages, ownIndex: out.index, ownHead, likedPages, likedIndex: lk.index, likedHead });
120
+ }
121
+
122
+ // A head is written only when it changed: most records change a page, not the
123
+ // count or the bounds.
124
+ async function writeHead(remote, before, url, doc) {
125
+ const digest = crypto.createHash('sha256').update(JSON.stringify(doc)).digest('hex').slice(0, 16);
126
+ if (digest !== before) await collection.writeHead(remote, url, doc);
127
+ return digest;
128
+ }
129
+
130
+ // Only the pages that changed, and none past the new end. No rule is written:
131
+ // the private container's owner-only rule is inherited.
132
+ async function writePages(remote, before = {}, pages) {
133
+ const after = {};
134
+ for (const [i, { id, doc }] of pages.entries()) {
135
+ const n = i + 1;
136
+ after[n] = crypto.createHash('sha256').update(JSON.stringify(doc)).digest('hex').slice(0, 16);
137
+ if (before[n] !== after[n]) await collection.writePage(remote, id, doc);
138
+ }
139
+ for (const n of Object.keys(before).map(Number).filter(n => Number.isFinite(n) && n > pages.length)) {
140
+ await collection.dropPage(remote, pages[0].id.replace(/-\d+$/u, `-${n}`));
141
+ }
142
+ return after;
143
+ }
@@ -9,7 +9,7 @@ import crypto from 'node:crypto';
9
9
  import * as wire from '../wire.mjs';
10
10
  import * as polls from '../polls.mjs';
11
11
  import * as podNotes from '../../pod/notes.mjs';
12
- import { assertDirectAddressed, replyTarget, rowTags } from './notes.mjs';
12
+ import { assertDirectAddressed, replyTarget, rowTags, slugFor } from './notes.mjs';
13
13
 
14
14
  // How long a poll gathers votes before its Question is rewritten. Every vote
15
15
  // changes a number other servers re-read, and rewriting per vote would make a
@@ -22,7 +22,7 @@ const POLL_REWRITE_MS = 10_000;
22
22
  * voter pick more than one, and `expiresAt` is when voting stops.
23
23
  */
24
24
  export async function publishQuestion(publisher, content, { options = [], multiple = false, expiresAt = null,
25
- inReplyTo = undefined, visibility = 'public', spoilerText = null, sensitive = false } = {}) {
25
+ inReplyTo = undefined, visibility = 'public', spoilerText = null, sensitive = false, slug: wanted = null } = {}) {
26
26
  const { urls } = publisher;
27
27
  const priv = visibility === 'private' || visibility === 'direct';
28
28
  if (priv) {
@@ -51,7 +51,9 @@ export async function publishQuestion(publisher, content, { options = [], multip
51
51
  }
52
52
 
53
53
  const published = new Date().toISOString();
54
- const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
54
+ // The name the client asked for, when it is plain — the outbox door has
55
+ // already told the client the address it names.
56
+ const slug = await slugFor(publisher, priv ? urls.privateNotes : urls.notes, wanted, published);
55
57
  const mentions = await publisher._mentionsFor(content, inReplyTo);
56
58
  if (visibility === 'direct') assertDirectAddressed(content, mentions);
57
59
  const reply = await replyTarget(publisher, inReplyTo, mentions, visibility);