fedipod 1.32.0 → 1.36.6

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/pod/inbox.mjs CHANGED
@@ -85,13 +85,15 @@ export async function writeKeep(pod, urls) {
85
85
  * those and not the others is an inbox that is open when it should be shut.
86
86
  */
87
87
  export async function setPosture(pod, urls, posture) {
88
- if (posture === 'open') return pod.setAcl(urls.inbox, ['Append']);
89
- if (posture === 'closed') return pod.setAcl(urls.inbox, []);
88
+ // Restated at every start, and most starts find it already so: the rule is
89
+ // read first and written only when it differs (transport.setAcl ifChanged).
90
+ if (posture === 'open') return pod.setAcl(urls.inbox, ['Append'], { ifChanged: true });
91
+ if (posture === 'closed') return pod.setAcl(urls.inbox, [], { ifChanged: true });
90
92
  const webId = posture?.gatewayWebId;
91
93
  if (!webId) throw new Error(`inbox.setPosture: unknown posture ${JSON.stringify(posture)}`);
92
94
  // Public loses Append; the door keeps it. Both halves in one write, because
93
95
  // between two writes the inbox is either open to everyone or shut to the door.
94
- return pod.setAcl(urls.inbox, [], { appendAgents: [webId] });
96
+ return pod.setAcl(urls.inbox, [], { appendAgents: [webId], ifChanged: true });
95
97
  }
96
98
 
97
99
  /**
@@ -145,3 +147,20 @@ export async function readDeliveryReceipt(pod, itemUrl, { maxBytes, readCapped }
145
147
  * @returns {Promise<boolean>} whether the pod removed it
146
148
  */
147
149
  export const dropHandledItem = (pod, url) => pod.delete(url);
150
+
151
+ /**
152
+ * Remove the receipt a gateway wrote beside an item, once the item is gone.
153
+ * Best-effort: a receipt that stays is a stray document, never a lost
154
+ * delivery. Left behind, they were one stray per delivery, forever.
155
+ */
156
+ export const dropReceiptBeside = (pod, url) => pod.delete(url + '.receipt.json').catch(() => false);
157
+
158
+ /**
159
+ * Receipts whose item is no longer in the inbox, from the last listing: what
160
+ * earlier drains left behind. The drain removes a few of these each sweep
161
+ * until none are left.
162
+ */
163
+ export const orphanReceipts = (pod, urls) => pod.orphanReceipts?.(urls.inbox) ?? [];
164
+
165
+ /** Remove one such stray. @returns {Promise<boolean>} whether the pod removed it */
166
+ export const dropStrayReceipt = (pod, url) => pod.delete(url).catch(() => false);
@@ -345,7 +345,31 @@ export class PodTransport {
345
345
  const podTarget = this.toPod ? this.toPod(targetUrl) : targetUrl;
346
346
  const url = await this.aclUrlFor(podTarget);
347
347
  if (!await this.aclWritable(url)) return null;
348
- return this.put(url, this.aclDoc(podTarget, publicModes, { ...opts, aclUrl: url }), 'text/turtle');
348
+ const doc = this.aclDoc(podTarget, publicModes, { ...opts, aclUrl: url });
349
+ // `ifChanged`: read the rule the pod holds and write only when it differs.
350
+ // For a rule restated at every start — the inbox door — a read is the
351
+ // whole cost, where a write took the pod's lock to say the same thing.
352
+ if (opts.ifChanged && await this.aclSame(url, doc)) return { status: 304, unchanged: true };
353
+ return this.put(url, doc, 'text/turtle');
354
+ }
355
+
356
+ // Whether the pod's rule at `aclUrl` states exactly what `doc` states.
357
+ // Compared as graphs, not bytes: the pod serialises what it holds its own
358
+ // way. Every rule this file writes names its subjects, so triple sets are
359
+ // enough; anything unreadable or with blank nodes reads as different.
360
+ async aclSame(aclUrl, doc) {
361
+ try {
362
+ const res = await this.fetch(aclUrl, { headers: { accept: 'text/turtle' } });
363
+ if (res.status !== 200) return false;
364
+ const triples = (text) => {
365
+ const g = $rdf.graph();
366
+ $rdf.parse(text, g, aclUrl, 'text/turtle');
367
+ if (g.statements.some((st) => st.subject.termType === 'BlankNode' || st.object.termType === 'BlankNode')) return null;
368
+ return g.statements.map((st) => `${st.subject.value} ${st.predicate.value} ${st.object.value}`).sort().join('\n');
369
+ };
370
+ const theirs = triples(await res.text());
371
+ return theirs !== null && theirs === triples(doc);
372
+ } catch { return false; }
349
373
  }
350
374
 
351
375
  // Child documents of an LDP container (URLs under it, excluding aux docs).
@@ -382,12 +406,14 @@ export class PodTransport {
382
406
  $rdf.parse(body, g, url, 'text/turtle');
383
407
  const here = $rdf.sym(url);
384
408
  const seen = new Set();
409
+ const receipts = new Set();
385
410
  const list = [];
386
411
  for (const child of g.each(here, LDP('contains'), null, here)) {
387
412
  const u = child.value;
388
413
  // `.receipt.json` is a verification receipt a gateway wrote beside an
389
414
  // inbox item — read with the item, never enumerated as an item itself.
390
- if (!u.startsWith(url) || u === url || /\.(acl|meta|receipt\.json)$/.test(u) || seen.has(u)) continue;
415
+ if (u.endsWith('.receipt.json')) { receipts.add(u); continue; }
416
+ if (!u.startsWith(url) || u === url || /\.(acl|meta)$/.test(u) || seen.has(u)) continue;
391
417
  seen.add(u);
392
418
  list.push({
393
419
  url: u,
@@ -395,14 +421,21 @@ export class PodTransport {
395
421
  modified: g.any(child, DC('modified'), null, here)?.value || null,
396
422
  });
397
423
  }
424
+ // Whether a receipt sits beside each item, so the drain asks for one only
425
+ // where there is one; and the receipts nothing sits beside any more.
426
+ for (const item of list) item.receipt = receipts.has(item.url + '.receipt.json');
427
+ const orphans = [...receipts].filter((r) => !seen.has(r.slice(0, -'.receipt.json'.length)));
398
428
  // Oldest first. An LDP listing is a set, so without this a drain works in
399
429
  // whatever order the graph happened to parse — a mention from last week
400
430
  // after one from today, and no way to make progress predictable.
401
431
  list.sort((a, b) => String(a.modified || '').localeCompare(String(b.modified || '')));
402
- this._listCache.set(url, { etag: res.headers.get('etag'), children: list });
432
+ this._listCache.set(url, { etag: res.headers.get('etag'), children: list, orphans });
403
433
  return list;
404
434
  }
405
435
 
436
+ /** Receipts in the last listing of `url` whose item is gone. */
437
+ orphanReceipts(url) { return this._listCache?.get(url)?.orphans ?? []; }
438
+
406
439
  /**
407
440
  * The WebID profile advertises the actor as an account:
408
441
  * <webId> foaf:account <actor> .
@@ -18,6 +18,9 @@ Once signed in, the account gives you:
18
18
  - **`post`** — publishes a post as them, public, unlisted or followers-only.
19
19
  - **`reply`** — answers a post.
20
20
  - **`timeline`** — their home timeline, newest first.
21
+ - **`outbox`** — their own posts, newest first. Pass `rdf: true` to also get
22
+ the real thing as RDF, for an app that wants to work with it as linked
23
+ data rather than as this library's own shape.
21
24
  - **`follow`** — follows someone by handle.
22
25
  - **`favourite`**, **`boost`** — as they say.
23
26
  - **`profile`** — who they are as their server or pod shows them: name,
@@ -37,6 +40,8 @@ DeviceAgent or Server account, and when fedipod.net is next open for a
37
40
  browser-based one.
38
41
 
39
42
  Three files, no dependencies. It runs in a page and in a service worker.
43
+ The one exception: `outbox({ rdf: true })` needs the `jsonld` package, and
44
+ only loads it when that flag is actually used.
40
45
 
41
46
  [The demo](https://jeff-zucker.github.io/FediPod/) shows the sign-in and
42
47
  the profile that comes back.
@@ -73,6 +78,7 @@ const me = await accounts.current(); // null when nobody i
73
78
  if (me?.notice) show(me.notice);
74
79
  await me.post({ text: 'Hello from my app' });
75
80
  for (const p of await me.timeline({ limit: 20 })) render(p);
81
+ for (const p of await me.outbox({ limit: 20 })) render(p);
76
82
  await me.follow('@aisha@her.server');
77
83
  await me.reply(p.url, 'Well said');
78
84
  await me.signOut();
@@ -93,6 +99,14 @@ await me.signOut();
93
99
  - **`reply(post, text)`** — the post is named by its address or its id.
94
100
  - **`timeline({ limit })`** — each post as `{ id, url, author: { id, handle,
95
101
  name }, html, published, inReplyTo }`.
102
+ - **`outbox({ limit, rdf })`** — the same shape as `timeline`, but only this
103
+ account's own posts. With `rdf: true`, the returned array also carries
104
+ `.rdf`: the real outbox parsed into an RDF/JS quad array — for a Mastodon
105
+ account too, since a Mastodon server is itself an ActivityPub server with
106
+ a real actor and outbox, same as a pod's. It comes back empty on a server
107
+ that requires a signed request just to read that document, which some
108
+ do. Reading it needs the `jsonld` package available to your app; without
109
+ `rdf: true` nothing changes and nothing extra loads.
96
110
  - **`fetch`** — the raw authenticated fetch, for anything the above does not
97
111
  cover.
98
112
  - **`actor`** — the account's ActivityPub id.
@@ -15,6 +15,8 @@
15
15
  // await me.post({ text }); await me.timeline({ limit: 20 })
16
16
  // await me.reply(postUrlOrId, text); await me.follow('@aisha@her.server')
17
17
  // await me.favourite(postUrlOrId); await me.boost(postUrlOrId)
18
+ // await me.outbox({ limit: 20 }); // this account's own posts, newest first
19
+ // await me.outbox({ rdf: true }); // the same, plus the real RDF graph on .rdf
18
20
  // await me.signOut()
19
21
  //
20
22
  // A Mastodon account is used through its server's API with a token the
@@ -58,6 +60,35 @@ export function fediAccount({
58
60
  return body;
59
61
  };
60
62
 
63
+ // Only loaded when `outbox({ rdf: true })` is actually called, so nobody
64
+ // pays for a JSON-LD parser just by importing this file. Not in
65
+ // `dependencies` — the app supplies "jsonld" if it wants this flag to work.
66
+ const loadJsonLd = async () => {
67
+ try { return (await import('jsonld')).default; }
68
+ catch { throw new Error('outbox({ rdf: true }) needs the "jsonld" package available to the app — it is not bundled with this library'); }
69
+ };
70
+
71
+ // The account's real outbox, read from the pod: the head collection names
72
+ // `first`, each page names `orderedItems` and (while there is more) `next`.
73
+ // Walked only far enough to cover `limit`, and run through jsonld.toRDF so
74
+ // the result is a genuine RDF/JS quad array, not this library's own shape.
75
+ const fetchOutboxRdf = async (actorUrl, limit) => {
76
+ if (!actorUrl) return null;
77
+ const jsonld = await loadJsonLd();
78
+ const actorDoc = await json(actorUrl, { headers: { accept: 'application/activity+json' } });
79
+ let pageUrl = actorDoc?.outbox ? (await json(actorDoc.outbox, { headers: { accept: 'application/activity+json' } }))?.first : null;
80
+ const quads = []; const seen = new Set(); let collected = 0;
81
+ while (pageUrl && collected < limit && !seen.has(pageUrl)) {
82
+ seen.add(pageUrl);
83
+ const page = await json(pageUrl, { headers: { accept: 'application/activity+json' } });
84
+ if (!page) break;
85
+ quads.push(...(await jsonld.toRDF(page)));
86
+ collected += (page.orderedItems || []).length;
87
+ pageUrl = page.next || null;
88
+ }
89
+ return quads;
90
+ };
91
+
61
92
  // ---- finding out what an address is ----
62
93
 
63
94
  // Does this host speak the Mastodon API? Any server of that family says so
@@ -255,6 +286,20 @@ export function fediAccount({
255
286
  const list = await call(`/api/v1/timelines/home?limit=${Math.min(40, limit)}`).then((r) => said(r, a.host));
256
287
  return (Array.isArray(list) ? list : []).map(item);
257
288
  },
289
+ // This account's own posts, newest first — Mastodon's equivalent of an
290
+ // outbox. A Mastodon server IS an ActivityPub server, so its actor and
291
+ // outbox are real AS2/JSON-LD too, same as a pod's; `rdf: true` reads
292
+ // that, not the REST API above. It comes back empty on an instance that
293
+ // requires a signed request just to read the public actor document
294
+ // (some do — mastodon.social among them); `fetchOutboxRdf`'s plain GET
295
+ // then gets 401 and the loop below finds nothing to walk.
296
+ async outbox({ limit = 20, rdf = false } = {}) {
297
+ const me = await call('/api/v1/accounts/verify_credentials').then((r) => said(r, a.host));
298
+ const list = await call(`/api/v1/accounts/${me.id}/statuses?limit=${Math.min(40, limit)}`).then((r) => said(r, a.host));
299
+ const items = (Array.isArray(list) ? list : []).map(item);
300
+ if (rdf) items.rdf = await fetchOutboxRdf(a.actor, limit);
301
+ return items;
302
+ },
258
303
  async follow(handle) {
259
304
  const who = parseAddress(handle);
260
305
  if (!who?.at) throw new Error('a handle looks like @you@your.server');
@@ -314,6 +359,23 @@ export function fediAccount({
314
359
  .slice(0, limit)
315
360
  .map((s) => ({ id: s.noteId, url: s.noteId, published: s.published || null, author: who(s.actor), html: s.content || '', inReplyTo: s.inReplyTo || null }));
316
361
  },
362
+ // This account's own posts, newest first, read from the same local
363
+ // record `timeline` uses (fast, no network call). With `rdf: true`,
364
+ // also fetches the real outbox from the pod and parses it into RDF/JS
365
+ // quads on the returned array's `.rdf` — a second, live document, not
366
+ // derived from the local record, so it needs `jsonld` (see loadJsonLd).
367
+ async outbox({ limit = 20, rdf = false } = {}) {
368
+ const rows = await state('statuses.json');
369
+ const actors = (await state('actors.json')) || {};
370
+ const who = (id) => { const d = actors[id]?.doc || actors[id] || {}; return { id, handle: d.preferredUsername ? `@${d.preferredUsername}@${new URL(id).host}` : null, name: d.name || null }; };
371
+ const items = (Array.isArray(rows) ? rows : [])
372
+ .filter((s) => s.kind === 'post')
373
+ .sort((x, y) => String(y.published || '').localeCompare(String(x.published || '')))
374
+ .slice(0, limit)
375
+ .map((s) => ({ id: s.noteId, url: s.noteId, published: s.published || null, author: who(s.actor), html: s.content || '', inReplyTo: s.inReplyTo || null }));
376
+ if (rdf) items.rdf = await fetchOutboxRdf(facts.actor, limit);
377
+ return items;
378
+ },
317
379
  async follow(handle) {
318
380
  const who = parseAddress(handle);
319
381
  if (!who?.at) throw new Error('a handle looks like @you@your.server');
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fediverse-account",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Type a Fediverse handle or a WebID and get back an account you can act with: post, read its timeline, follow, reply, favourite, boost. Works out whether the account lives on a Mastodon-family server or on a Solid pod through FediPod, sends the person to sign in there, brings them back to where they were, and speaks to whichever it is.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,5 +37,13 @@
37
37
  "scripts": {
38
38
  "prepublishOnly": "node ../../scripts/check-pod-calls.mjs"
39
39
  },
40
- "dependencies": {}
40
+ "dependencies": {},
41
+ "peerDependencies": {
42
+ "jsonld": "^9.0.0"
43
+ },
44
+ "peerDependenciesMeta": {
45
+ "jsonld": {
46
+ "optional": true
47
+ }
48
+ }
41
49
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod",
3
- "version": "1.32.0",
3
+ "version": "1.36.6",
4
4
  "description": "Standalone single-actor ActivityPub agent whose wire face, RDF truth and state all live on a Solid pod (CSS). Bundles a Phanpy UI and a Mastodon client-API facade.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/run-agent.mjs CHANGED
@@ -53,6 +53,7 @@ import { exposureProblem, hostLabel } from './lib/shared/guard.mjs';
53
53
  import { pendingSteps } from './lib/device/migrate.mjs';
54
54
  import { apUrls, assertionKeyId , publicHandle } from './lib/core/wire.mjs';
55
55
  import { followActor, unfollowActor, resolveHandle } from './lib/core/social.mjs';
56
+ import * as podInbox from './lib/pod/inbox.mjs';
56
57
 
57
58
  export class Agent {
58
59
  constructor({ home, log }) {
@@ -510,6 +511,19 @@ export class Agent {
510
511
  return true;
511
512
  }
512
513
 
514
+ // The inbox's door must be open to the world (or to the gateway alone, in
515
+ // locked mode) or nothing anyone sends ever lands. The permission is written
516
+ // at setup, and a container made again later — a root move, a pod rebuilt —
517
+ // came back without it: the group's inbox on fp2 refused every delivery for
518
+ // nine days while its agent ran on, healthy-looking. One write per start,
519
+ // and it is idempotent.
520
+ async ensureInboxOpen() {
521
+ const g = this.store.getConfig()?.gateway;
522
+ const posture = g?.mode === 'locked' && g.webId ? { gatewayWebId: g.webId } : 'open';
523
+ await podInbox.setPosture(this.remote, this.urls, posture);
524
+ return posture;
525
+ }
526
+
513
527
  // Read-only mode: refresh the state cache periodically, and take over the
514
528
  // moment the active agent's lease frees.
515
529
  startViewer() {
@@ -607,6 +621,8 @@ export class Agent {
607
621
  .catch(e => this.log(`actor check failed: ${e.message}`));
608
622
  this.ensureFeaturedPublished()
609
623
  .catch(e => this.log(`featured check failed: ${e.message}`));
624
+ this.ensureInboxOpen()
625
+ .catch(e => this.log(`inbox door check failed: ${e.message}`));
610
626
  // The human page, rewritten only when what it shows has changed: one
611
627
  // local digest compare, and a write the first time after an upgrade.
612
628
  this.publisher.publishProfilePage()
package/web/app/agent.mjs CHANGED
@@ -169,12 +169,14 @@ export class BrowserAgent {
169
169
  this.lease.onLost = () => this.demote();
170
170
  this.lease.startRenewal();
171
171
  try {
172
- // Forced, not revalidated. While this device watched, the ACTIVE one was
173
- // writing; our cache is however stale the last viewer poll left it, and
174
- // the store is write-through — so the first write from here would push a
175
- // whole document back over newer state. Read what is actually there
176
- // before acting on it.
177
- await this.store.load({ force: true }).catch((e) => this.log(`re-reading state: ${e.message}`));
172
+ // Forced, not revalidated, when this device WATCHED first: the active
173
+ // one was writing, our cache is however stale the last viewer poll left
174
+ // it, and the store is write-through — so the first write from here
175
+ // would push a whole document back over newer state. A device that
176
+ // booted straight into acting read everything a moment ago; asking
177
+ // again is one revalidation, not a second download of every document.
178
+ await this.store.load({ force: !!this._watched }).catch((e) => this.log(`re-reading state: ${e.message}`));
179
+ this._watched = false;
178
180
  // Own posts the outbox names and the timeline index lacks come back
179
181
  // here, before anything acts on the index.
180
182
  await this.publisher.healStatuses().catch((e) => this.log(`healing the timeline index: ${e.message}`));
@@ -202,6 +204,7 @@ export class BrowserAgent {
202
204
  demote() {
203
205
  if (this.viewer) return;
204
206
  this.viewer = true;
207
+ this._watched = true;
205
208
  this.log('another device took over — read-only here');
206
209
  this.lease.stopRenewal();
207
210
  clearInterval(this._openTimer); this._openTimer = null;
@@ -355,8 +358,9 @@ export class BrowserAgent {
355
358
  // somebody reads from counting as a quiet one. Only a closed address
356
359
  // stops the boot; a gateway that cannot be reached is no reason to
357
360
  // refuse a sign-in.
358
- this.gatewayApi = null;
359
- try { if (config.gateway?.url) this.gatewayApi = `${new URL(config.gateway.url).origin}/api`; } catch { this.gatewayApi = null; }
361
+ // At the page's own origin, as the relay is: a page on the test alias
362
+ // asked the real site instead and could not get past its preflight.
363
+ this.gatewayApi = config.gateway?.url ? `${frontOrigin.replace(/\/$/, '')}/api` : null;
360
364
  this.doorKey = doorKeyOf(config.gateway?.url) || config.handle;
361
365
  this.gatewayStanding = null;
362
366
  const standing = await this.openAtGateway();
@@ -476,6 +480,7 @@ export class BrowserAgent {
476
480
  // The lease decides: this device ACTS on the pod, or reads it read-only.
477
481
  this.viewer = !(await this.lease.acquire());
478
482
  if (this.viewer) {
483
+ this._watched = true;
479
484
  this.log(`read-only viewer: another device is active on @${config.handle}`);
480
485
  this.startViewerPoll(); // reload the feed, and promote if the lease frees
481
486
  return;
@@ -10,6 +10,9 @@
10
10
  import { Deliverer } from '../../lib/core/deliver.mjs';
11
11
  import { sign } from './shims/fedify-sig.mjs';
12
12
 
13
+ // What one relay call may carry (lib/gateway/front-core.mjs RELAY_MAX_REQUESTS).
14
+ const RELAY_MAX_REQUESTS = 20;
15
+
13
16
  /**
14
17
  * The name the front keys this account's row by, read off its door inbox:
15
18
  * `<front>/u/<key>/ap/inbox/`. A mail-door account is keyed by its full
@@ -30,6 +33,7 @@ export class RelayDeliverer extends Deliverer {
30
33
  this.relayUrl = opts.relayUrl; // <front>/api/relay
31
34
  this.handle = opts.handle;
32
35
  this.sessionFetch = opts.sessionFetch; // the DPoP session's fetch, to authenticate to the relay
36
+ this.batchSize = RELAY_MAX_REQUESTS;
33
37
  }
34
38
 
35
39
  // Same contract as Deliverer.signedFetch, DEFAULT INCLUDED: an init with no
@@ -40,32 +44,68 @@ export class RelayDeliverer extends Deliverer {
40
44
  // never saw the document it asked for: a Follow from anyone new was rejected
41
45
  // with "actor fetch failed", and nothing needing a lookup could be ingested.
42
46
  async signedFetch(url, init = {}) {
47
+ const req = await this._signedRequest(url, init);
48
+ const [r0] = await this._relay([req]);
49
+ return this._outcome(r0, url, init.method || 'GET');
50
+ }
51
+
52
+ // A fan-out in one call: the relay takes a list, so a post to twenty
53
+ // followers is one call, not twenty (Deliverer.deliverToAll, batchSize).
54
+ async deliverManyNow(targets) {
55
+ const reqs = await Promise.all(targets.map((t) => this._signedRequest(t.inbox, {
56
+ method: 'POST', headers: { 'content-type': 'application/activity+json' }, body: JSON.stringify(t.activity),
57
+ })));
58
+ let results;
59
+ try { results = await this._relay(reqs); } catch (error) { return targets.map(() => ({ error })); }
60
+ return targets.map((t, i) => {
61
+ try { this._outcome(results[i] || {}, t.inbox, 'POST'); return { ok: true }; }
62
+ catch (error) { return { error }; }
63
+ });
64
+ }
65
+
66
+ // Signed here, sent verbatim by the relay. Every signed header goes along,
67
+ // `accept` included: the signature covers it, so a relay request missing it
68
+ // carries an invalid signature — and a read without it gets the HTML page
69
+ // instead of the document.
70
+ async _signedRequest(url, init = {}) {
43
71
  const body = typeof init.body === 'string' ? init.body : (init.body ? new TextDecoder().decode(init.body) : '');
44
72
  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.
48
- const relayReq = {
73
+ return {
49
74
  url: s.url, method: s.method, body,
50
75
  headers: {
51
76
  date: s.headers.date, digest: s.headers.digest, accept: s.headers.accept,
52
77
  'content-type': s.headers['content-type'], signature: s.headers.signature,
53
78
  },
54
79
  };
80
+ }
81
+
82
+ // One relay call for a list of requests; the results in the same order.
83
+ // The relay's OWN answer, apart from the recipients': unreachable and a
84
+ // refusal are hiccups the queue retries. Its 404 is not — it says this
85
+ // account has no row here, and no retry changes that. It used to be read as
86
+ // a hiccup too, and a tab whose account the site did not know retried its
87
+ // deliveries every minute for three days.
88
+ async _relay(requests) {
55
89
  let res;
56
90
  try {
57
91
  res = await this.sessionFetch(this.relayUrl, {
58
92
  method: 'POST', headers: { 'content-type': 'application/json' },
59
- body: JSON.stringify({ handle: this.handle, requests: [relayReq] }),
93
+ body: JSON.stringify({ handle: this.handle, requests }),
60
94
  });
61
95
  } catch (e) { const err = new Error(`relay unreachable: ${e.message}`); err.status = 0; throw err; }
96
+ if (res.status === 404) { const err = new Error('relay: no such account here'); err.status = 404; throw err; }
62
97
  if (res.status >= 400) { const err = new Error(`relay ${res.status}`); err.status = 502; throw err; }
63
98
  const out = await res.json().catch(() => ({}));
64
- const r0 = (out.results && out.results[0]) || {};
99
+ return Array.isArray(out.results) ? out.results : [];
100
+ }
101
+
102
+ // What the far server answered, as the Node deliverer would have seen it:
103
+ // a Response for a read, a thrown error carrying the status for a refusal.
104
+ _outcome(r0, url, method) {
65
105
  const status = r0.status || 0;
66
106
  if (status === 0) { const err = new Error(r0.error || 'relay could not send'); err.status = 502; throw err; }
67
107
  if (status >= 400) {
68
- const err = new Error(`${init.method || 'POST'} ${url} → ${status}`); err.status = status;
108
+ const err = new Error(`${method} ${url} → ${status}`); err.status = status;
69
109
  // The receiving server's own answer to "when should I try again", carried
70
110
  // through the relay (lib/front-core.mjs). Spelled `retryAfterMs`, which is
71
111
  // what the delivery queue reads (lib/deliver.mjs) — the queue's ladder is
@@ -32822,7 +32822,29 @@ var PodTransport = class {
32822
32822
  const podTarget = this.toPod ? this.toPod(targetUrl) : targetUrl;
32823
32823
  const url = await this.aclUrlFor(podTarget);
32824
32824
  if (!await this.aclWritable(url)) return null;
32825
- return this.put(url, this.aclDoc(podTarget, publicModes, { ...opts, aclUrl: url }), "text/turtle");
32825
+ const doc = this.aclDoc(podTarget, publicModes, { ...opts, aclUrl: url });
32826
+ if (opts.ifChanged && await this.aclSame(url, doc)) return { status: 304, unchanged: true };
32827
+ return this.put(url, doc, "text/turtle");
32828
+ }
32829
+ // Whether the pod's rule at `aclUrl` states exactly what `doc` states.
32830
+ // Compared as graphs, not bytes: the pod serialises what it holds its own
32831
+ // way. Every rule this file writes names its subjects, so triple sets are
32832
+ // enough; anything unreadable or with blank nodes reads as different.
32833
+ async aclSame(aclUrl, doc) {
32834
+ try {
32835
+ const res = await this.fetch(aclUrl, { headers: { accept: "text/turtle" } });
32836
+ if (res.status !== 200) return false;
32837
+ const triples = (text) => {
32838
+ const g = graph();
32839
+ parse2(text, g, aclUrl, "text/turtle");
32840
+ if (g.statements.some((st2) => st2.subject.termType === "BlankNode" || st2.object.termType === "BlankNode")) return null;
32841
+ return g.statements.map((st2) => `${st2.subject.value} ${st2.predicate.value} ${st2.object.value}`).sort().join("\n");
32842
+ };
32843
+ const theirs = triples(await res.text());
32844
+ return theirs !== null && theirs === triples(doc);
32845
+ } catch {
32846
+ return false;
32847
+ }
32826
32848
  }
32827
32849
  // Child documents of an LDP container (URLs under it, excluding aux docs).
32828
32850
  // Revalidated: the inbox is polled every couple of minutes and is usually
@@ -32846,10 +32868,15 @@ var PodTransport = class {
32846
32868
  parse2(body, g, url, "text/turtle");
32847
32869
  const here = namedNode2(url);
32848
32870
  const seen = /* @__PURE__ */ new Set();
32871
+ const receipts = /* @__PURE__ */ new Set();
32849
32872
  const list = [];
32850
32873
  for (const child of g.each(here, LDP("contains"), null, here)) {
32851
32874
  const u = child.value;
32852
- if (!u.startsWith(url) || u === url || /\.(acl|meta|receipt\.json)$/.test(u) || seen.has(u)) continue;
32875
+ if (u.endsWith(".receipt.json")) {
32876
+ receipts.add(u);
32877
+ continue;
32878
+ }
32879
+ if (!u.startsWith(url) || u === url || /\.(acl|meta)$/.test(u) || seen.has(u)) continue;
32853
32880
  seen.add(u);
32854
32881
  list.push({
32855
32882
  url: u,
@@ -32857,10 +32884,16 @@ var PodTransport = class {
32857
32884
  modified: g.any(child, DC("modified"), null, here)?.value || null
32858
32885
  });
32859
32886
  }
32887
+ for (const item of list) item.receipt = receipts.has(item.url + ".receipt.json");
32888
+ const orphans = [...receipts].filter((r) => !seen.has(r.slice(0, -".receipt.json".length)));
32860
32889
  list.sort((a, b) => String(a.modified || "").localeCompare(String(b.modified || "")));
32861
- this._listCache.set(url, { etag: res.headers.get("etag"), children: list });
32890
+ this._listCache.set(url, { etag: res.headers.get("etag"), children: list, orphans });
32862
32891
  return list;
32863
32892
  }
32893
+ /** Receipts in the last listing of `url` whose item is gone. */
32894
+ orphanReceipts(url) {
32895
+ return this._listCache?.get(url)?.orphans ?? [];
32896
+ }
32864
32897
  /**
32865
32898
  * The WebID profile advertises the actor as an account:
32866
32899
  * <webId> foaf:account <actor> .