fedipod-server 0.13.0 → 0.13.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.
package/lib/core/as2.mjs CHANGED
@@ -144,12 +144,17 @@ export function groundContext(input) {
144
144
  * what the plain read would have concluded too.
145
145
  */
146
146
  export async function readLenient(raw) {
147
+ // The document's own node is the one its `id` names. Handed to the view
148
+ // as the root, so a document that points at itself — every actor does,
149
+ // through its key's `owner` — is still read as itself.
150
+ let input = null;
151
+ try { input = typeof raw === 'string' ? JSON.parse(raw) : (raw ?? null); } catch { /* not JSON either */ }
152
+ const own = input && typeof input === 'object' ? (input.id ?? input['@id']) : null;
153
+ const root = typeof own === 'string' && /^https?:\/\//u.test(own) ? own : null;
147
154
  try {
148
155
  const { doc, graph } = await parseAS2(raw);
149
- return { doc, graph, view: graphView(graph), degraded: null };
156
+ return { doc, graph, view: graphView(graph, { root }), degraded: null };
150
157
  } catch (e) {
151
- let input = null;
152
- try { input = typeof raw === 'string' ? JSON.parse(raw) : (raw ?? null); } catch { /* not JSON either */ }
153
158
  if (!input || typeof input !== 'object') return { doc: null, graph: null, view: null, degraded: e.message };
154
159
  // Grounded, so a graph exists for every document that is JSON at all.
155
160
  //
@@ -159,7 +164,7 @@ export async function readLenient(raw) {
159
164
  // what they read, so rewriting it here silently changed what they posted.
160
165
  try {
161
166
  const { graph } = await parseAS2(groundContext(input));
162
- return { doc: input, graph, view: graphView(graph), degraded: e.message };
167
+ return { doc: input, graph, view: graphView(graph, { root }), degraded: e.message };
163
168
  } catch (inner) {
164
169
  // Grounding names only held contexts, so reaching here means the document
165
170
  // is malformed JSON-LD rather than unfetchable. Hand back what a plain
@@ -130,14 +130,31 @@ function indexQuads(quads) {
130
130
  }
131
131
 
132
132
  /**
133
- * The document's own node: a subject nothing else points at. A document that
134
- * points back at itself leaves no such subject, so the first typed subject is
135
- * the fallback rather than nothing at all.
133
+ * The document's own node, when the caller could not name it.
134
+ *
135
+ * A named subject nothing points at is the plain case. But an actor is
136
+ * pointed at by its own key (`owner`), so it is never that; and taking "the
137
+ * first typed subject" instead handed back a profile field — a blank
138
+ * PropertyValue that happens to come first in quad order — for every
139
+ * Mastodon actor with fields on it. Named subjects therefore come before
140
+ * blank ones at every step, and a blank node is the root only when nothing
141
+ * named exists at all.
136
142
  */
137
- function findRoot({ bySubject, objects }) {
138
- for (const s of bySubject.keys()) if (!objects.has(s)) return s;
139
- for (const [s, preds] of bySubject) if (preds.has(RDF_TYPE)) return s;
140
- return bySubject.keys().next().value ?? null;
143
+ function findRoot({ bySubject, objects, blanks }) {
144
+ const all = [...bySubject.keys()];
145
+ const named = all.filter((s) => !blanks.has(s));
146
+ // A subject nothing points at is the document's own node — named if there
147
+ // is one, else blank: a client's Update carries no id of its own and is a
148
+ // blank root above the named note it edits.
149
+ const free = all.filter((s) => !objects.has(s));
150
+ const freeNamed = free.find((s) => !blanks.has(s));
151
+ if (freeNamed) return freeNamed;
152
+ if (free.length) return free[0];
153
+ // Everything is pointed at (an actor, through its key's owner): a typed
154
+ // named subject, before any typed blank one.
155
+ for (const s of named) if (bySubject.get(s).has(RDF_TYPE)) return s;
156
+ for (const s of all) if (bySubject.get(s).has(RDF_TYPE)) return s;
157
+ return named[0] ?? all[0] ?? null;
141
158
  }
142
159
 
143
160
  /** Walk an rdf:List into an ordered array. `orderedItems` depends on this. */
@@ -260,7 +277,10 @@ function makeView(subject, ctx, depth = 0, seen = new Set()) {
260
277
  export function graphView(quads, { root = null } = {}) {
261
278
  if (!quads || !quads.length) return null;
262
279
  const ctx = indexQuads(quads);
263
- const subject = root ?? findRoot(ctx);
280
+ // The root the caller names is used only when the graph has it as a
281
+ // subject: a document whose `id` names something it says nothing about
282
+ // still reads as whatever it does describe.
283
+ const subject = (root && ctx.bySubject.has(root)) ? root : findRoot(ctx);
264
284
  if (!subject) return null;
265
285
  const view = makeView(subject, ctx);
266
286
  return (view && typeof view === 'object') ? view : null;
@@ -44,7 +44,12 @@ async function verifyPodToken(request, pathname, verifier) {
44
44
  const url = request.url;
45
45
  const { webid } = await v(authz, dpop ? { header: dpop, method: request.method, url } : undefined);
46
46
  return webid || null;
47
- } catch { return null; }
47
+ } catch (e) {
48
+ // Said aloud: a token the front will not take is otherwise a bare 401 to
49
+ // the caller and nothing at all here.
50
+ console.log(`front: pod token refused on ${pathname}: ${e?.message || e}`);
51
+ return null;
52
+ }
48
53
  }
49
54
 
50
55
  // Is this WebID served by the claimed pod? A pod owner's WebID lives on the pod
@@ -149,7 +154,7 @@ async function relayOne(item, rec, fetchImpl) {
149
154
  }
150
155
  try {
151
156
  const res = await safeFetch(url, { method, headers, body, signal: AbortSignal.timeout(RELAY_TIMEOUT_MS) }, fetchImpl);
152
- const out = { url, status: res.status };
157
+ const out = { url, method, status: res.status };
153
158
  // The far server asking to be left alone has to reach the agent that will
154
159
  // do the asking again. Without this the browser build could not honour a
155
160
  // Retry-After at all — every delivery it makes goes through here — and fell
@@ -161,7 +166,7 @@ async function relayOne(item, rec, fetchImpl) {
161
166
  out.body = await readCapped(res, RELAY_MAX_BODY);
162
167
  }
163
168
  return out;
164
- } catch (e) { return { url, status: 0, error: e.message }; }
169
+ } catch (e) { return { url, method, status: 0, error: e.message }; }
165
170
  }
166
171
 
167
172
  const j = (status, obj, ct = 'application/json') =>
@@ -470,15 +475,18 @@ async function route(request, ctx) {
470
475
  try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
471
476
  const handle = String(body.handle || '').toLowerCase();
472
477
  const rec = await ctx.lookup(handle);
473
- if (!rec) return j(404, { error: 'no such account' });
478
+ if (!rec) { console.log(`relay: no account named ${handle}`); return j(404, { error: 'no such account' }); }
474
479
  const webid = await verifyPodToken(request, pathname, ctx.verifier);
475
- if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
480
+ if (!webid) { console.log(`relay @${handle}: no usable pod token`); return j(401, { error: 'a Solid-OIDC token proving the pod is required' }); }
476
481
  const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
477
- if (!owner) return j(403, { error: "the token proves a different pod than this account's" });
482
+ if (!owner) { console.log(`relay @${handle}: token is for ${webid}, not this account's pod`); return j(403, { error: "the token proves a different pod than this account's" }); }
478
483
  const items = Array.isArray(body.requests) ? body.requests : [];
479
484
  if (!items.length) return j(400, { error: 'requests must be a non-empty list' });
480
485
  if (items.length > RELAY_MAX_REQUESTS) return j(400, { error: `at most ${RELAY_MAX_REQUESTS} requests per call` });
481
486
  const results = await Promise.all(items.map((it) => relayOne(it, rec, ctx.fetchImpl || fetch)));
487
+ // One line per relayed request, so a lookup that fails on the far side is
488
+ // visible here and not only as an empty result in someone's browser.
489
+ for (const r of results) console.log(`relay @${handle}: ${r.method || ''} ${r.url} → ${r.status}${r.error ? ` (${r.error})` : ''}`);
482
490
  return j(200, { results });
483
491
  }
484
492
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod-server",
3
- "version": "0.13.0",
3
+ "version": "0.13.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
@@ -19,7 +19,7 @@ import { makeDpopSession } from './pod-auth.mjs';
19
19
  import { BrowserRemotePod } from './pod-remote.mjs';
20
20
  import { importSigningKey, loadKeysFromPod, cacheOpenedKeys } from './keys-browser.mjs';
21
21
  import { generateKeys, wrapKeys } from './keystore.mjs';
22
- import { RelayDeliverer } from './deliver-relay.mjs';
22
+ import { RelayDeliverer, doorKeyOf } from './deliver-relay.mjs';
23
23
  import { AdminFacade } from './admin-facade.mjs';
24
24
  import { BrowserAtproto } from './atproto-browser.mjs';
25
25
  import { BskyFeed } from '../../lib/connections/bskyfeed.mjs';
@@ -203,7 +203,10 @@ export class BrowserAgent {
203
203
  passive: true,
204
204
  store: this.store, rsaPrivate: keys.rsaPrivate, keyId: this.urls.actor + '#main-key',
205
205
  actorId: this.urls.actor, log: this.log,
206
- relayUrl: `${frontOrigin.replace(/\/$/, '')}/api/relay`, handle: config.handle, sessionFetch: session.fetch,
206
+ relayUrl: `${frontOrigin.replace(/\/$/, '')}/api/relay`,
207
+ // The relay finds the account by the front's own key for it, which for a
208
+ // mail-door account is the full address, not the bare handle.
209
+ handle: doorKeyOf(config.gateway?.url) || config.handle, sessionFetch: session.fetch,
207
210
  });
208
211
 
209
212
  this.publisher = new Publisher({
@@ -10,6 +10,20 @@
10
10
  import { Deliverer } from '../../lib/core/deliver.mjs';
11
11
  import { sign } from './shims/fedify-sig.mjs';
12
12
 
13
+ /**
14
+ * The name the front keys this account's row by, read off its door inbox:
15
+ * `<front>/u/<key>/ap/inbox/`. A mail-door account is keyed by its full
16
+ * address (`you@your.pod`), not the bare handle, because "you" alone is not
17
+ * unique across pods — and the relay looks the account up by that key.
18
+ * Null when the URL is not a door of that shape.
19
+ */
20
+ export function doorKeyOf(doorInboxUrl) {
21
+ try {
22
+ const seg = new URL(doorInboxUrl).pathname.split('/');
23
+ return seg[1] === 'u' && seg[2] ? decodeURIComponent(seg[2]) : null;
24
+ } catch { return null; }
25
+ }
26
+
13
27
  export class RelayDeliverer extends Deliverer {
14
28
  constructor(opts) {
15
29
  super(opts);
@@ -28,10 +42,13 @@ export class RelayDeliverer extends Deliverer {
28
42
  async signedFetch(url, init = {}) {
29
43
  const body = typeof init.body === 'string' ? init.body : (init.body ? new TextDecoder().decode(init.body) : '');
30
44
  const s = await sign({ url, method: init.method || 'GET', headers: init.headers || {}, body }, this.rsaPrivate, this.keyId);
45
+ // Every signed header goes to the relay, `accept` included: the signature
46
+ // covers it, so a relay request missing it carries an invalid signature —
47
+ // and a read without it gets the HTML page instead of the document.
31
48
  const relayReq = {
32
49
  url: s.url, method: s.method, body,
33
50
  headers: {
34
- date: s.headers.date, digest: s.headers.digest,
51
+ date: s.headers.date, digest: s.headers.digest, accept: s.headers.accept,
35
52
  'content-type': s.headers['content-type'], signature: s.headers.signature,
36
53
  },
37
54
  };
@@ -55555,10 +55555,16 @@ function indexQuads(quads) {
55555
55555
  }
55556
55556
  return { bySubject, objects, blanks };
55557
55557
  }
55558
- function findRoot({ bySubject, objects }) {
55559
- for (const s of bySubject.keys()) if (!objects.has(s)) return s;
55560
- for (const [s, preds] of bySubject) if (preds.has(RDF_TYPE)) return s;
55561
- return bySubject.keys().next().value ?? null;
55558
+ function findRoot({ bySubject, objects, blanks }) {
55559
+ const all = [...bySubject.keys()];
55560
+ const named = all.filter((s) => !blanks.has(s));
55561
+ const free = all.filter((s) => !objects.has(s));
55562
+ const freeNamed = free.find((s) => !blanks.has(s));
55563
+ if (freeNamed) return freeNamed;
55564
+ if (free.length) return free[0];
55565
+ for (const s of named) if (bySubject.get(s).has(RDF_TYPE)) return s;
55566
+ for (const s of all) if (bySubject.get(s).has(RDF_TYPE)) return s;
55567
+ return named[0] ?? all[0] ?? null;
55562
55568
  }
55563
55569
  function readList(head, ctx, depth, path) {
55564
55570
  const out = [];
@@ -55636,7 +55642,7 @@ function makeView(subject, ctx, depth = 0, seen = /* @__PURE__ */ new Set()) {
55636
55642
  function graphView(quads, { root = null } = {}) {
55637
55643
  if (!quads || !quads.length) return null;
55638
55644
  const ctx = indexQuads(quads);
55639
- const subject = root ?? findRoot(ctx);
55645
+ const subject = root && ctx.bySubject.has(root) ? root : findRoot(ctx);
55640
55646
  if (!subject) return null;
55641
55647
  const view = makeView(subject, ctx);
55642
55648
  return view && typeof view === "object" ? view : null;
@@ -55695,19 +55701,21 @@ function groundContext(input) {
55695
55701
  return { ...input, "@context": kept.length === 1 ? kept[0] : kept };
55696
55702
  }
55697
55703
  async function readLenient(raw) {
55704
+ let input = null;
55705
+ try {
55706
+ input = typeof raw === "string" ? JSON.parse(raw) : raw ?? null;
55707
+ } catch {
55708
+ }
55709
+ const own = input && typeof input === "object" ? input.id ?? input["@id"] : null;
55710
+ const root = typeof own === "string" && /^https?:\/\//u.test(own) ? own : null;
55698
55711
  try {
55699
55712
  const { doc, graph: graph2 } = await parseAS2(raw);
55700
- return { doc, graph: graph2, view: graphView(graph2), degraded: null };
55713
+ return { doc, graph: graph2, view: graphView(graph2, { root }), degraded: null };
55701
55714
  } catch (e) {
55702
- let input = null;
55703
- try {
55704
- input = typeof raw === "string" ? JSON.parse(raw) : raw ?? null;
55705
- } catch {
55706
- }
55707
55715
  if (!input || typeof input !== "object") return { doc: null, graph: null, view: null, degraded: e.message };
55708
55716
  try {
55709
55717
  const { graph: graph2 } = await parseAS2(groundContext(input));
55710
- return { doc: input, graph: graph2, view: graphView(graph2), degraded: e.message };
55718
+ return { doc: input, graph: graph2, view: graphView(graph2, { root }), degraded: e.message };
55711
55719
  } catch (inner) {
55712
55720
  return { doc: input, graph: null, view: null, degraded: `${e.message}; grounded read also failed: ${inner.message}` };
55713
55721
  }
@@ -67364,6 +67372,14 @@ var Deliverer = class {
67364
67372
 
67365
67373
  // web/app/deliver-relay.mjs
67366
67374
  init_fedify_sig();
67375
+ function doorKeyOf(doorInboxUrl) {
67376
+ try {
67377
+ const seg2 = new URL(doorInboxUrl).pathname.split("/");
67378
+ return seg2[1] === "u" && seg2[2] ? decodeURIComponent(seg2[2]) : null;
67379
+ } catch {
67380
+ return null;
67381
+ }
67382
+ }
67367
67383
  var RelayDeliverer = class extends Deliverer {
67368
67384
  constructor(opts) {
67369
67385
  super(opts);
@@ -67388,6 +67404,7 @@ var RelayDeliverer = class extends Deliverer {
67388
67404
  headers: {
67389
67405
  date: s.headers.date,
67390
67406
  digest: s.headers.digest,
67407
+ accept: s.headers.accept,
67391
67408
  "content-type": s.headers["content-type"],
67392
67409
  signature: s.headers.signature
67393
67410
  }
@@ -69615,7 +69632,9 @@ var BrowserAgent = class _BrowserAgent {
69615
69632
  actorId: this.urls.actor,
69616
69633
  log: this.log,
69617
69634
  relayUrl: `${frontOrigin.replace(/\/$/, "")}/api/relay`,
69618
- handle: config.handle,
69635
+ // The relay finds the account by the front's own key for it, which for a
69636
+ // mail-door account is the full address, not the bare handle.
69637
+ handle: doorKeyOf(config.gateway?.url) || config.handle,
69619
69638
  sessionFetch: session.fetch
69620
69639
  });
69621
69640
  this.publisher = new Publisher({