fedipod-server 0.14.0 → 0.14.1

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.
@@ -197,7 +197,7 @@ export async function onUndo(intake, activity, actor, { trusted = false } = {})
197
197
  intake.log(`unfollowed by ${actor}`);
198
198
  }
199
199
 
200
- export async function onCreate(intake, activity, actor) {
200
+ export async function onCreate(intake, activity, actor, { trusted = false } = {}) {
201
201
  const objectId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
202
202
  if (!objectId) return 'Create without object id';
203
203
  if (intake.store.isBlocked(objectId)) return `blocked domain (${objectId})`;
@@ -219,7 +219,15 @@ export async function onCreate(intake, activity, actor) {
219
219
  const ingested = intake.store.getStatuses()
220
220
  .some(x => x.noteId === objectId && (x.kind === 'timeline' || x.kind === 'mention'));
221
221
  if (!ingested) {
222
- const rejected = await intake.ingestNote(objectId, actor);
222
+ // A verified delivery that carries the note inline is read from that
223
+ // copy: the door checked the sender's signature over these very bytes,
224
+ // and the receipt vouches for this actor. That is the only way a direct
225
+ // or followers-only post can ever be read — it lives in the sender's
226
+ // owner-only container, and fetching it from there answers 401 to
227
+ // everyone. An unverified delivery still fetches, as it always did.
228
+ const inline = trusted && typeof activity.object === 'object' && activity.object?.id === objectId
229
+ ? activity.object : null;
230
+ const rejected = await intake.ingestNote(objectId, actor, { inline });
223
231
  if (rejected) return rejected;
224
232
  }
225
233
  // A group carries its members' posts onward. Only reached from Create, so an
@@ -539,7 +539,7 @@ export class Intake {
539
539
  switch (activity.type) {
540
540
  case 'Follow': return this.onFollow(activity, actor, { trusted });
541
541
  case 'Undo': return this.onUndo(activity, actor, { trusted });
542
- case 'Create': return this.onCreate(activity, actor);
542
+ case 'Create': return this.onCreate(activity, actor, { trusted });
543
543
  case 'Accept': return this.onAccept(activity, actor, { trusted });
544
544
  case 'Like': case 'Announce': {
545
545
  // FEP-1b12: a group Announces the member's whole Create, not the note.
@@ -114,8 +114,10 @@ export function referencesOurObject(intake, activity) {
114
114
  // Shared tail of Create/Announce: deref the note at its origin (never trust
115
115
  // the delivered copy), mirror it into pod RDF + statuses, notify on replies
116
116
  // to our own notes. Returns a rejection reason string, or undefined.
117
- export async function ingestNote(intake, objectId, actor, { via } = {}) {
118
- const note = await intake.fetchAP(objectId);
117
+ export async function ingestNote(intake, objectId, actor, { via, inline = null } = {}) {
118
+ // `inline` is the note as delivered, offered only for a delivery whose
119
+ // signature the door verified (onCreate); otherwise the origin's copy.
120
+ const note = inline || await intake.fetchAP(objectId);
119
121
  if (!note) return `object fetch failed (${objectId})`;
120
122
  if (note.id !== objectId || !isContentType(note.type)) return `object not verifiable content (${objectId}, ${note.type})`;
121
123
  const { attachmentsOf, titledContent } = await import('../wire.mjs');
@@ -15,7 +15,14 @@ import { dropFollower } from './store.mjs';
15
15
  // belongs behind the same SSRF guard as every other outbound request. fedify's
16
16
  // lookup did not go through ours. https only, because a handle resolved over
17
17
  // plaintext is a handle anyone on the path can answer for.
18
- export async function lookupWebFinger(acct) {
18
+ //
19
+ // `fetchImpl`, when given, makes the request instead of safeFetch: the agent's
20
+ // own remote read, which in the browser build is the relay — a server-side
21
+ // fetch, so the lookup does not depend on the far pod host answering a
22
+ // browser's cross-origin request. Every other remote read there goes the
23
+ // same way; WebFinger went direct and was the one lookup that could fail
24
+ // while the rest worked.
25
+ export async function lookupWebFinger(acct, { fetchImpl = null } = {}) {
19
26
  const clean = String(acct || '').replace(/^acct:/, '');
20
27
  const at = clean.lastIndexOf('@');
21
28
  if (at < 1) return null;
@@ -23,9 +30,8 @@ export async function lookupWebFinger(acct) {
23
30
  if (!host || /[/\\?#]/.test(host)) return null;
24
31
  const { safeFetch, readCapped } = await import('../shared/safefetch.mjs');
25
32
  const url = `https://${host}/.well-known/webfinger?resource=${encodeURIComponent(`acct:${clean}`)}`;
26
- const res = await safeFetch(url, {
27
- headers: { accept: 'application/jrd+json, application/json' },
28
- }).catch(() => null);
33
+ const headers = { accept: 'application/jrd+json, application/json' };
34
+ const res = await (fetchImpl ? fetchImpl(url, { headers }) : safeFetch(url, { headers })).catch(() => null);
29
35
  if (!res || res.status >= 400) return null;
30
36
  let jrd;
31
37
  try { jrd = JSON.parse(await readCapped(res, 256 * 1024)); } catch { return null; }
@@ -80,13 +86,18 @@ export async function resolveHandle(agent, handle) {
80
86
  const clean = String(handle || '').replace(/^@/, '');
81
87
  if (!/^[^@]+@[^@]+$/.test(clean)) throw new Error('handle must look like user@host');
82
88
  if (agent.store.isBlocked('https://' + clean.split('@')[1] + '/')) throw new Error('domain is blocked');
83
- const jrd = await lookupWebFinger('acct:' + clean);
89
+ // Through the agent's own remote read where it has one — the relay, in the
90
+ // browser build — and directly otherwise.
91
+ const remote = agent.intake?.deliverer?.signedFetch
92
+ ? (u, i) => agent.intake.deliverer.signedFetch(u, i) : null;
93
+ const lookup = (a) => lookupWebFinger(a, { fetchImpl: remote });
94
+ const jrd = await lookup('acct:' + clean);
84
95
  const self = selfLink(jrd);
85
96
  if (!self?.href) throw new Error(`webfinger found no actor for ${clean}`);
86
97
  const doc = await agent.intake.fetchAP(self.href);
87
98
  if (!doc?.id) throw new Error(`actor document unusable for ${clean}`);
88
99
  if (agent.store.isBlocked(doc.id)) throw new Error('actor is blocked');
89
- await confirmDelegation(clean.slice(clean.lastIndexOf('@') + 1), doc);
100
+ await confirmDelegation(clean.slice(clean.lastIndexOf('@') + 1), doc, lookup);
90
101
  // Counts live in the collections, not the actor document, so a client asking
91
102
  // "who is this" would otherwise be told everyone has none. Two GETs, and only
92
103
  // when someone asked by name — never on the drain path.
@@ -429,7 +429,14 @@ async function route(request, ctx) {
429
429
  // Re-attaching your own pod — a retry, a re-provision, a second browser — is
430
430
  // idempotent: it updates the row in place and keeps the same door secret.
431
431
  const prior = await ctx.lookup(key);
432
- if (prior && prior.podHome !== podHome) return j(409, { error: 'that name is taken' });
432
+ // The same account on the same pod may correct where its tree lives — a
433
+ // row made by an older sign-up named the pod root, and every delivery
434
+ // to it was written where nothing reads. A different pod is still taken.
435
+ const sameOwner = (a, b) => { try { return new URL(a).origin === new URL(b).origin; } catch { return false; } };
436
+ if (prior && prior.podHome !== podHome
437
+ && !(prior.webId && prior.webId === webid && sameOwner(prior.podHome, podHome))) {
438
+ return j(409, { error: 'that name is taken' });
439
+ }
433
440
  // Re-attaching is idempotent — a retry, a re-provision, a second browser —
434
441
  // and it hands back the EXISTING receipt secret. So it has to be the same
435
442
  // person: matching podHome was enough on a subdomain server and, on a
package/lib/pod/inbox.mjs CHANGED
@@ -22,10 +22,13 @@ const RECEIPT_CT = 'application/json';
22
22
  *
23
23
  * @returns {Promise<boolean>} whether it landed
24
24
  */
25
- export async function appendWithToken(url, body, contentType, { appendToken = null, fetchImpl = fetch } = {}) {
25
+ export async function appendWithToken(url, body, contentType, { appendToken = null, fetchImpl = fetch, report = null } = {}) {
26
26
  const headers = { 'content-type': contentType,
27
27
  ...(appendToken ? { authorization: `Bearer ${appendToken}` } : {}) };
28
28
  const r = await fetchImpl(url, { method: 'PUT', headers, body }).catch(() => null);
29
+ // The pod's answer, for a caller that keeps a log: a refused write is
30
+ // otherwise a bare "failed" with the status thrown away.
31
+ report?.(r ? r.status : 0);
29
32
  return !!r && r.status < 400;
30
33
  }
31
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod-server",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "The FediPod Server: a full ActivityPub server as a Community Solid Server component.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
package/web/app/agent.mjs CHANGED
@@ -25,7 +25,7 @@ import { BrowserAtproto } from './atproto-browser.mjs';
25
25
  import { BskyFeed } from '../../lib/connections/bskyfeed.mjs';
26
26
  import { BrowserFediAccounts } from './fediacct-browser.mjs';
27
27
  import { AcctFeed } from '../../lib/connections/acctfeed.mjs';
28
- import { followActor, unfollowActor } from '../../lib/core/social.mjs';
28
+ import { followActor, unfollowActor, resolveHandle } from '../../lib/core/social.mjs';
29
29
  import { ImportWorker } from '../../lib/connections/import.mjs';
30
30
 
31
31
  // The authorities this identity answers on: exactly one, this origin. The Node
@@ -212,6 +212,10 @@ export class BrowserAgent {
212
212
  this.publisher = new Publisher({
213
213
  config: this.store.getConfig(), remote: this.remote, store: this.store,
214
214
  deliverer: this.deliverer, publicKeyPem: keys.rsaPublicPem, assertionKey: null, log: this.log,
215
+ // Who a post names, resolved — the same lookup the installed agent
216
+ // gives its publisher. Without it no mention from the browser ever
217
+ // resolved: a direct message went to nobody, a mention notified no one.
218
+ resolveMention: (h) => resolveHandle(this, h),
215
219
  });
216
220
 
217
221
  // The Bluesky connection, stamped to this actor. The same client the Node
@@ -57393,7 +57393,7 @@ async function onUndo(intake, activity, actor, { trusted = false } = {}) {
57393
57393
  await intake.republish({ followers: true });
57394
57394
  intake.log(`unfollowed by ${actor}`);
57395
57395
  }
57396
- async function onCreate(intake, activity, actor) {
57396
+ async function onCreate(intake, activity, actor, { trusted = false } = {}) {
57397
57397
  const objectId = typeof activity.object === "string" ? activity.object : activity.object?.id;
57398
57398
  if (!objectId) return "Create without object id";
57399
57399
  if (intake.store.isBlocked(objectId)) return `blocked domain (${objectId})`;
@@ -57402,7 +57402,8 @@ async function onCreate(intake, activity, actor) {
57402
57402
  if (!intake.concernsUs(envelope, actor)) return `not addressed to us (${objectId})`;
57403
57403
  const ingested = intake.store.getStatuses().some((x) => x.noteId === objectId && (x.kind === "timeline" || x.kind === "mention"));
57404
57404
  if (!ingested) {
57405
- const rejected = await intake.ingestNote(objectId, actor);
57405
+ const inline = trusted && typeof activity.object === "object" && activity.object?.id === objectId ? activity.object : null;
57406
+ const rejected = await intake.ingestNote(objectId, actor, { inline });
57406
57407
  if (rejected) return rejected;
57407
57408
  }
57408
57409
  if (intake.config.kind === "group") await intake.amplify(objectId, { activity });
@@ -57616,8 +57617,8 @@ function referencesOurObject(intake, activity) {
57616
57617
  if (refs.some((r) => r.startsWith(intake.urls.notes))) return true;
57617
57618
  return intake.config.kind === "group" && refs.some((r) => intake.store.getStatuses().some((s) => s.noteId === r));
57618
57619
  }
57619
- async function ingestNote(intake, objectId, actor, { via } = {}) {
57620
- const note = await intake.fetchAP(objectId);
57620
+ async function ingestNote(intake, objectId, actor, { via, inline = null } = {}) {
57621
+ const note = inline || await intake.fetchAP(objectId);
57621
57622
  if (!note) return `object fetch failed (${objectId})`;
57622
57623
  if (note.id !== objectId || !isContentType(note.type)) return `object not verifiable content (${objectId}, ${note.type})`;
57623
57624
  const { attachmentsOf: attachmentsOf3, titledContent: titledContent2 } = await Promise.resolve().then(() => (init_wire(), wire_exports));
@@ -63143,7 +63144,7 @@ var Intake = class {
63143
63144
  case "Undo":
63144
63145
  return this.onUndo(activity, actor, { trusted });
63145
63146
  case "Create":
63146
- return this.onCreate(activity, actor);
63147
+ return this.onCreate(activity, actor, { trusted });
63147
63148
  case "Accept":
63148
63149
  return this.onAccept(activity, actor, { trusted });
63149
63150
  case "Like":
@@ -64122,7 +64123,7 @@ __export(social_exports, {
64122
64123
  });
64123
64124
  init_node_crypto();
64124
64125
  init_wire();
64125
- async function lookupWebFinger(acct) {
64126
+ async function lookupWebFinger(acct, { fetchImpl = null } = {}) {
64126
64127
  const clean = String(acct || "").replace(/^acct:/, "");
64127
64128
  const at = clean.lastIndexOf("@");
64128
64129
  if (at < 1) return null;
@@ -64130,9 +64131,8 @@ async function lookupWebFinger(acct) {
64130
64131
  if (!host || /[/\\?#]/.test(host)) return null;
64131
64132
  const { safeFetch: safeFetch2, readCapped: readCapped3 } = await Promise.resolve().then(() => (init_safefetch(), safefetch_exports));
64132
64133
  const url = `https://${host}/.well-known/webfinger?resource=${encodeURIComponent(`acct:${clean}`)}`;
64133
- const res = await safeFetch2(url, {
64134
- headers: { accept: "application/jrd+json, application/json" }
64135
- }).catch(() => null);
64134
+ const headers = { accept: "application/jrd+json, application/json" };
64135
+ const res = await (fetchImpl ? fetchImpl(url, { headers }) : safeFetch2(url, { headers })).catch(() => null);
64136
64136
  if (!res || res.status >= 400) return null;
64137
64137
  let jrd2;
64138
64138
  try {
@@ -64168,13 +64168,15 @@ async function resolveHandle(agent2, handle7) {
64168
64168
  const clean = String(handle7 || "").replace(/^@/, "");
64169
64169
  if (!/^[^@]+@[^@]+$/.test(clean)) throw new Error("handle must look like user@host");
64170
64170
  if (agent2.store.isBlocked("https://" + clean.split("@")[1] + "/")) throw new Error("domain is blocked");
64171
- const jrd2 = await lookupWebFinger("acct:" + clean);
64171
+ const remote = agent2.intake?.deliverer?.signedFetch ? (u, i) => agent2.intake.deliverer.signedFetch(u, i) : null;
64172
+ const lookup2 = (a) => lookupWebFinger(a, { fetchImpl: remote });
64173
+ const jrd2 = await lookup2("acct:" + clean);
64172
64174
  const self2 = selfLink(jrd2);
64173
64175
  if (!self2?.href) throw new Error(`webfinger found no actor for ${clean}`);
64174
64176
  const doc = await agent2.intake.fetchAP(self2.href);
64175
64177
  if (!doc?.id) throw new Error(`actor document unusable for ${clean}`);
64176
64178
  if (agent2.store.isBlocked(doc.id)) throw new Error("actor is blocked");
64177
- await confirmDelegation(clean.slice(clean.lastIndexOf("@") + 1), doc);
64179
+ await confirmDelegation(clean.slice(clean.lastIndexOf("@") + 1), doc, lookup2);
64178
64180
  await cacheCounts(agent2, doc);
64179
64181
  return doc;
64180
64182
  }
@@ -69764,7 +69766,11 @@ var BrowserAgent = class _BrowserAgent {
69764
69766
  deliverer: this.deliverer,
69765
69767
  publicKeyPem: keys.rsaPublicPem,
69766
69768
  assertionKey: null,
69767
- log: this.log
69769
+ log: this.log,
69770
+ // Who a post names, resolved — the same lookup the installed agent
69771
+ // gives its publisher. Without it no mention from the browser ever
69772
+ // resolved: a direct message went to nobody, a mention notified no one.
69773
+ resolveMention: (h) => resolveHandle(this, h)
69768
69774
  });
69769
69775
  this.atproto = new BrowserAtproto({ store: this.store, actorId: this.urls.actor, log: this.log });
69770
69776
  this.publisher.atproto = this.atproto;