fedipod-server 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +27 -28
  2. package/config/server.json +22 -0
  3. package/dist/claims.js +3 -2
  4. package/dist/directory.d.ts +2 -0
  5. package/dist/handler.d.ts +4 -1
  6. package/dist/handler.js +32 -3
  7. package/dist/handler.jsonld +4 -0
  8. package/dist/store-pod.js +37 -0
  9. package/lib/client/c2s.mjs +97 -43
  10. package/lib/client/masto/accounts.mjs +1 -0
  11. package/lib/connections/bskyfeed.mjs +6 -0
  12. package/lib/core/deliver.mjs +68 -22
  13. package/lib/core/intake/activities.mjs +18 -2
  14. package/lib/core/intake/group.mjs +3 -1
  15. package/lib/core/intake/index.mjs +50 -6
  16. package/lib/core/intake/notes.mjs +12 -5
  17. package/lib/core/lease.mjs +7 -7
  18. package/lib/core/place.mjs +82 -0
  19. package/lib/core/publisher/collections.mjs +37 -2
  20. package/lib/core/publisher/index.mjs +23 -1
  21. package/lib/core/publisher/notes.mjs +73 -7
  22. package/lib/core/publisher/own.mjs +143 -0
  23. package/lib/core/publisher/questions.mjs +5 -3
  24. package/lib/core/social.mjs +62 -32
  25. package/lib/core/store.mjs +36 -6
  26. package/lib/core/wire.mjs +51 -15
  27. package/lib/device/admin/routes/lifecycle.mjs +1 -1
  28. package/lib/device/admin/routes/setup.mjs +17 -1
  29. package/lib/device/cli/commands/setup.mjs +50 -8
  30. package/lib/device/cli/context.mjs +1 -1
  31. package/lib/device/migrate.mjs +1 -1
  32. package/lib/device/setup.mjs +45 -8
  33. package/lib/gateway/caches.mjs +36 -0
  34. package/lib/gateway/front-core.mjs +80 -111
  35. package/lib/gateway/gateway-core.mjs +91 -9
  36. package/lib/gateway/headers.mjs +49 -0
  37. package/lib/gateway/quiet.mjs +7 -2
  38. package/lib/gateway/relay-extras.mjs +89 -0
  39. package/lib/gateway/token-claims.mjs +16 -0
  40. package/lib/pod/containers.mjs +5 -1
  41. package/lib/pod/inbox.mjs +22 -3
  42. package/lib/pod/location.mjs +52 -0
  43. package/lib/pod/notes.mjs +2 -4
  44. package/lib/pod/transport.mjs +207 -25
  45. package/lib/pod/type-index.mjs +101 -0
  46. package/lib/pod/urls.mjs +6 -0
  47. package/lib/server/embed.mjs +7 -6
  48. package/lib/session/README.md +14 -0
  49. package/lib/session/fedi-account.mjs +73 -2
  50. package/lib/session/package.json +10 -2
  51. package/package.json +1 -1
  52. package/run-agent.mjs +18 -0
  53. package/web/admin/actors.js +2 -0
  54. package/web/admin/index.html +9 -0
  55. package/web/admin/record.js +4 -1
  56. package/web/admin/setup/index.html +17 -1
  57. package/web/admin/setup/setup.js +18 -5
  58. package/web/app/README.md +1 -1
  59. package/web/app/admin-facade.mjs +1 -1
  60. package/web/app/agent.mjs +24 -10
  61. package/web/app/boot.mjs +72 -32
  62. package/web/app/deliver-relay.mjs +47 -7
  63. package/web/app/dist/boot.js +466 -89
  64. package/web/app/dist/boot.js.map +4 -4
  65. package/web/app/dist/sw.js +2792 -1996
  66. package/web/app/dist/sw.js.map +4 -4
  67. package/web/app/index.html +16 -0
  68. package/web/app/signup.mjs +65 -26
  69. package/web/app/update.js +4 -0
  70. package/web/front/run.html +7 -1
  71. package/web/front/run.js +30 -4
@@ -15,6 +15,7 @@
15
15
  import * as $rdf from 'rdflib';
16
16
  import { readCapped, retryAfterMs } from './http.mjs';
17
17
  import { linkTargets, REL } from './links.mjs';
18
+ import { podBaseOfWebId } from './urls.mjs';
18
19
 
19
20
  const LDP = $rdf.Namespace('http://www.w3.org/ns/ldp#');
20
21
  const DC = $rdf.Namespace('http://purl.org/dc/terms/');
@@ -23,6 +24,17 @@ const RDF = $rdf.Namespace('http://www.w3.org/1999/02/22-rdf-syntax-ns#');
23
24
  const ACL = $rdf.Namespace('http://www.w3.org/ns/auth/acl#');
24
25
  const FOAF = $rdf.Namespace('http://xmlns.com/foaf/0.1/');
25
26
  const AS = $rdf.Namespace('https://www.w3.org/ns/activitystreams#');
27
+ const RDFS = $rdf.Namespace('http://www.w3.org/2000/01/rdf-schema#');
28
+
29
+ // An inserted term must be something a reader can take for what it says it
30
+ // is: an absolute http(s) IRI, or a literal.
31
+ function termProblem(t) {
32
+ if (t?.termType === 'Literal') return null;
33
+ if (t?.termType !== 'NamedNode') return `${t?.value ?? t} is not an IRI or a literal`;
34
+ try {
35
+ return /^https?:$/.test(new URL(t.value).protocol) ? null : `${t.value} is not an http(s) IRI`;
36
+ } catch { return `${t.value} is not an absolute IRI`; }
37
+ }
26
38
 
27
39
  // A pod whose access rules are ACP policies, not WAC authorizations. This
28
40
  // library writes WAC; over an ACP resource that would be noise where the pod's
@@ -345,7 +357,31 @@ export class PodTransport {
345
357
  const podTarget = this.toPod ? this.toPod(targetUrl) : targetUrl;
346
358
  const url = await this.aclUrlFor(podTarget);
347
359
  if (!await this.aclWritable(url)) return null;
348
- return this.put(url, this.aclDoc(podTarget, publicModes, { ...opts, aclUrl: url }), 'text/turtle');
360
+ const doc = this.aclDoc(podTarget, publicModes, { ...opts, aclUrl: url });
361
+ // `ifChanged`: read the rule the pod holds and write only when it differs.
362
+ // For a rule restated at every start — the inbox door — a read is the
363
+ // whole cost, where a write took the pod's lock to say the same thing.
364
+ if (opts.ifChanged && await this.aclSame(url, doc)) return { status: 304, unchanged: true };
365
+ return this.put(url, doc, 'text/turtle');
366
+ }
367
+
368
+ // Whether the pod's rule at `aclUrl` states exactly what `doc` states.
369
+ // Compared as graphs, not bytes: the pod serialises what it holds its own
370
+ // way. Every rule this file writes names its subjects, so triple sets are
371
+ // enough; anything unreadable or with blank nodes reads as different.
372
+ async aclSame(aclUrl, doc) {
373
+ try {
374
+ const res = await this.fetch(aclUrl, { headers: { accept: 'text/turtle' } });
375
+ if (res.status !== 200) return false;
376
+ const triples = (text) => {
377
+ const g = $rdf.graph();
378
+ $rdf.parse(text, g, aclUrl, 'text/turtle');
379
+ if (g.statements.some((st) => st.subject.termType === 'BlankNode' || st.object.termType === 'BlankNode')) return null;
380
+ return g.statements.map((st) => `${st.subject.value} ${st.predicate.value} ${st.object.value}`).sort().join('\n');
381
+ };
382
+ const theirs = triples(await res.text());
383
+ return theirs !== null && theirs === triples(doc);
384
+ } catch { return false; }
349
385
  }
350
386
 
351
387
  // Child documents of an LDP container (URLs under it, excluding aux docs).
@@ -382,12 +418,14 @@ export class PodTransport {
382
418
  $rdf.parse(body, g, url, 'text/turtle');
383
419
  const here = $rdf.sym(url);
384
420
  const seen = new Set();
421
+ const receipts = new Set();
385
422
  const list = [];
386
423
  for (const child of g.each(here, LDP('contains'), null, here)) {
387
424
  const u = child.value;
388
425
  // `.receipt.json` is a verification receipt a gateway wrote beside an
389
426
  // 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;
427
+ if (u.endsWith('.receipt.json')) { receipts.add(u); continue; }
428
+ if (!u.startsWith(url) || u === url || /\.(acl|meta)$/.test(u) || seen.has(u)) continue;
391
429
  seen.add(u);
392
430
  list.push({
393
431
  url: u,
@@ -395,14 +433,21 @@ export class PodTransport {
395
433
  modified: g.any(child, DC('modified'), null, here)?.value || null,
396
434
  });
397
435
  }
436
+ // Whether a receipt sits beside each item, so the drain asks for one only
437
+ // where there is one; and the receipts nothing sits beside any more.
438
+ for (const item of list) item.receipt = receipts.has(item.url + '.receipt.json');
439
+ const orphans = [...receipts].filter((r) => !seen.has(r.slice(0, -'.receipt.json'.length)));
398
440
  // Oldest first. An LDP listing is a set, so without this a drain works in
399
441
  // whatever order the graph happened to parse — a mention from last week
400
442
  // after one from today, and no way to make progress predictable.
401
443
  list.sort((a, b) => String(a.modified || '').localeCompare(String(b.modified || '')));
402
- this._listCache.set(url, { etag: res.headers.get('etag'), children: list });
444
+ this._listCache.set(url, { etag: res.headers.get('etag'), children: list, orphans });
403
445
  return list;
404
446
  }
405
447
 
448
+ /** Receipts in the last listing of `url` whose item is gone. */
449
+ orphanReceipts(url) { return this._listCache?.get(url)?.orphans ?? []; }
450
+
406
451
  /**
407
452
  * The WebID profile advertises the actor as an account:
408
453
  * <webId> foaf:account <actor> .
@@ -412,16 +457,11 @@ export class PodTransport {
412
457
  * written back — an empty or foreign body must never become the new profile.
413
458
  */
414
459
  async linkAccountInProfile({ actorUrl, accountName, kind = 'person', outbox = null }) {
415
- const docUrl = this.webId.split('#')[0];
416
- const res = await this.fetch(docUrl, { headers: { accept: 'text/turtle' } });
417
- if (res.status >= 400) throw new Error(`[${this.label}] GET ${docUrl} → ${res.status}`);
418
- const g = $rdf.graph();
419
- $rdf.parse(await res.text(), g, docUrl, 'text/turtle');
420
- const doc = $rdf.sym(docUrl);
421
460
  const me = $rdf.sym(this.webId);
422
- if (!g.statementsMatching(me, null, null, doc).length) {
423
- throw new Error(`profile at ${docUrl} does not mention ${this.webId} — not rewriting it`);
424
- }
461
+ const podBase = podBaseOfWebId(this.webId);
462
+ // The profile and what its seeAlso names: what either says is said.
463
+ const docs = await this.profileDocs(podBase);
464
+ const says = (s, p, o) => docs.some(({ g }) => g?.holds(s, p, o));
425
465
  const actor = $rdf.sym(actorUrl);
426
466
  const wanted = [
427
467
  [me, FOAF('account'), actor],
@@ -432,22 +472,24 @@ export class PodTransport {
432
472
  // WebID is what dokieli reads); only where a door exists to take it.
433
473
  ...(outbox ? [[me, AS('outbox'), $rdf.sym(outbox)]] : []),
434
474
  ];
435
- const missing = wanted.filter(([s, p, o]) => !g.holds(s, p, o, doc));
475
+ const missing = wanted.filter(([s, p, o]) => !says(s, p, o));
436
476
  // A handle change leaves the old accountName behind; ours is replaced.
437
477
  // Likewise an outbox that moved.
438
- const stale = [
439
- ...g.statementsMatching(actor, FOAF('accountName'), null, doc).filter(st => st.object.value !== accountName),
440
- ...(outbox ? g.statementsMatching(me, AS('outbox'), null, doc).filter(st => st.object.value !== outbox) : []),
441
- ];
478
+ const stale = [];
479
+ for (const { g } of docs) {
480
+ for (const st of g?.statementsMatching(actor, FOAF('accountName'), null) || []) {
481
+ if (st.object.value !== accountName) stale.push([st.subject, st.predicate, st.object]);
482
+ }
483
+ if (outbox) {
484
+ for (const st of g?.statementsMatching(me, AS('outbox'), null) || []) {
485
+ if (st.object.value !== outbox) stale.push([st.subject, st.predicate, st.object]);
486
+ }
487
+ }
488
+ }
442
489
  if (!missing.length && !stale.length) return false;
443
- // A patch touches these statements and nothing else. Rewriting the whole
444
- // profile re-serialises statements that are not ours — the OIDC issuer
445
- // among them — and a server is entitled to refuse a write that would.
446
- const deletes = stale.map(st => [st.subject, st.predicate, st.object]);
447
- if (await this.patchDocument(docUrl, missing, deletes)) return true;
448
- for (const st of stale) g.remove(st);
449
- for (const [s, p, o] of missing) g.add(s, p, o, doc);
450
- await this.put(docUrl, $rdf.serialize(doc, g, docUrl, 'text/turtle'), 'text/turtle');
490
+ // Checked before it is written, to the profile or else to a document its
491
+ // seeAlso names (writeAboutWebId); a patch carries only these statements.
492
+ await this.writeAboutWebId(podBase, { inserts: missing, deletes: stale });
451
493
  return true;
452
494
  }
453
495
 
@@ -489,4 +531,144 @@ export class PodTransport {
489
531
  if (res.status === 405 || res.status === 415 || res.status === 501) return false;
490
532
  throw new Error(`[${this.label}] PATCH ${docUrl} → ${res.status}`);
491
533
  }
534
+
535
+ // ---- writing a profile or a type index: valid RDF, or nothing ----
536
+ //
537
+ // Two rules. A change is written only if the document it leaves behind is
538
+ // valid RDF: the graph as it would be afterwards is serialised and parsed
539
+ // back, and the subject that must stay described still is. And a profile
540
+ // that will not take a write is not the end: the statements go to the first
541
+ // document its rdfs:seeAlso names, inside the same pod, that takes them —
542
+ // under the same check. A profile is what every Solid app signs in through;
543
+ // one left broken breaks them all. Reading follows the same links.
544
+
545
+ /** An IRI or a literal as a term, for the operations that pass plain values. */
546
+ sym(iri) {
547
+ try { return $rdf.sym(iri); } catch { throw new Error(`not written: ${iri} is not an absolute IRI`); }
548
+ }
549
+ literal(value) { return $rdf.literal(value); }
550
+
551
+ /** A document as a graph, or null when it is not there. */
552
+ async readRdf(docUrl) {
553
+ const res = await this.fetch(docUrl, { headers: { accept: 'text/turtle' } });
554
+ if (res.status === 404 || res.status === 410) return null;
555
+ if (res.status >= 400) throw new Error(`[${this.label}] GET ${docUrl} → ${res.status}`);
556
+ const g = $rdf.graph();
557
+ $rdf.parse(await res.text(), g, docUrl, 'text/turtle');
558
+ return g;
559
+ }
560
+
561
+ /**
562
+ * The document as it would be after the change, checked. Returns the Turtle
563
+ * to write, or throws saying why nothing may be written.
564
+ */
565
+ checkedRdf(g, docUrl, { inserts = [], deletes = [], mustDescribe = null }) {
566
+ const doc = $rdf.sym(docUrl);
567
+ for (const [s, p, o] of inserts) {
568
+ const bad = termProblem(s) || termProblem(p) || termProblem(o)
569
+ || (s?.termType === 'Literal' || p?.termType !== 'NamedNode' ? 'a literal subject or predicate' : null);
570
+ if (bad) throw new Error(`not written: ${bad}`);
571
+ }
572
+ const after = $rdf.graph();
573
+ for (const st of g.statementsMatching(null, null, null, doc)) after.add(st.subject, st.predicate, st.object, doc);
574
+ for (const [s, p, o] of deletes) for (const st of after.statementsMatching(s, p, o, doc)) after.remove(st);
575
+ for (const [s, p, o] of inserts) if (!after.holds(s, p, o, doc)) after.add(s, p, o, doc);
576
+ let text;
577
+ try { text = $rdf.serialize(doc, after, docUrl, 'text/turtle'); } catch (e) {
578
+ throw new Error(`not written: ${docUrl} would not serialise (${e.message})`);
579
+ }
580
+ const back = $rdf.graph();
581
+ try { $rdf.parse(text, back, docUrl, 'text/turtle'); } catch (e) {
582
+ throw new Error(`not written: ${docUrl} would not parse back (${e.message})`);
583
+ }
584
+ if (back.statementsMatching(null, null, null, doc).length !== after.statementsMatching(null, null, null, doc).length) {
585
+ throw new Error(`not written: ${docUrl} would not read back as written`);
586
+ }
587
+ if (mustDescribe && !back.statementsMatching($rdf.sym(mustDescribe), null, null, doc).length) {
588
+ throw new Error(`not written: ${docUrl} would no longer describe ${mustDescribe}`);
589
+ }
590
+ return text;
591
+ }
592
+
593
+ /**
594
+ * Change one document, or refuse. `g` is the document as read (null for a
595
+ * new one). A PATCH carries only these statements; where the pod cannot
596
+ * patch, the whole checked document is written. Returns the write's status.
597
+ */
598
+ async writeRdfChecked(docUrl, g, { inserts = [], deletes = [], mustDescribe = null }) {
599
+ const current = g || $rdf.graph();
600
+ const doc = $rdf.sym(docUrl);
601
+ const todo = inserts.filter(([s, p, o]) => !current.holds(s, p, o, doc));
602
+ const gone = deletes.filter(([s, p, o]) => current.holds(s, p, o, doc));
603
+ if (!todo.length && !gone.length) return 200;
604
+ const text = this.checkedRdf(current, docUrl, { inserts: todo, deletes: gone, mustDescribe });
605
+ if (g) {
606
+ let res = null;
607
+ try {
608
+ res = await this.fetch(docUrl, { method: 'PATCH', headers: { 'content-type': 'text/n3' },
609
+ body: this.n3Patch(docUrl, todo, gone) });
610
+ } catch { res = null; }
611
+ if (res && res.status < 300) return res.status;
612
+ // A pod that cannot patch gets the whole checked document; any other
613
+ // answer — a refusal, a conflict — is the answer.
614
+ if (res && ![405, 415, 501].includes(res.status)) return res.status;
615
+ }
616
+ const put = await this.fetch(docUrl, { method: 'PUT', headers: { 'content-type': 'text/turtle' }, body: text });
617
+ return put.status;
618
+ }
619
+
620
+ /**
621
+ * The profile and the documents its rdfs:seeAlso names inside this pod,
622
+ * each with its graph, the profile first. What any of them says about the
623
+ * WebID is what the profile says.
624
+ */
625
+ async profileDocs(podBase) {
626
+ const profileUrl = this.webId.split('#')[0];
627
+ const g = await this.readRdf(profileUrl);
628
+ if (!g) throw new Error(`no profile at ${profileUrl}`);
629
+ const out = [{ url: profileUrl, g, profile: true }];
630
+ const seen = new Set([profileUrl]);
631
+ for (const st of g.statementsMatching(null, RDFS('seeAlso'), null)) {
632
+ const url = st.object.value.split('#')[0];
633
+ if (seen.has(url) || !url.startsWith(podBase)) continue; // inside this pod only
634
+ seen.add(url);
635
+ out.push({ url, g: await this.readRdf(url).catch(() => null), profile: false });
636
+ }
637
+ return out;
638
+ }
639
+
640
+ /** Every value the WebID has for `predicate`, across the profile and its seeAlso documents. */
641
+ webIdValues(docs, predicate) {
642
+ const me = $rdf.sym(this.webId);
643
+ const p = $rdf.sym(predicate);
644
+ const out = [];
645
+ for (const { g } of docs) for (const st of g?.statementsMatching(me, p, null) || []) out.push(st.object.value);
646
+ return [...new Set(out)];
647
+ }
648
+
649
+ /**
650
+ * Write statements about the WebID: to the profile, or — if the profile will
651
+ * not take them — to the first of its seeAlso documents that will. Every
652
+ * write checked. Returns the document written to, or null when nothing
653
+ * needed writing; throws when none would take it.
654
+ */
655
+ async writeAboutWebId(podBase, { inserts = [], deletes = [] }) {
656
+ const docs = await this.profileDocs(podBase);
657
+ const holds = ([s, p, o]) => docs.some(({ g }) => g?.holds(s, p, o));
658
+ const todo = inserts.filter(t => !holds(t));
659
+ const gone = deletes.filter(holds);
660
+ if (!todo.length && !gone.length) return null;
661
+ const refusals = [];
662
+ for (const d of docs) {
663
+ const here = gone.filter(([s, p, o]) => d.g?.holds(s, p, o));
664
+ const status = await this.writeRdfChecked(d.url, d.g, {
665
+ inserts: todo, deletes: here, mustDescribe: d.profile ? this.webId : null,
666
+ });
667
+ if (status < 300) return d.url;
668
+ refusals.push(`${d.url} → ${status}`);
669
+ if (status !== 401 && status !== 403) break; // a real answer, not "not yours to write"
670
+ }
671
+ throw new Error(`not written: neither the profile nor a document it names would take it (${refusals.join('; ')})`);
672
+ }
673
+
492
674
  }
@@ -0,0 +1,101 @@
1
+ // type-index.mjs — an account's place, recorded in the person's public type
2
+ // index: the list Solid apps read to find what kind of thing lives where.
3
+ //
4
+ // The record is a registration saying there is an instance of `as:Actor` at the
5
+ // account's actor on the pod:
6
+ //
7
+ // <#actor-…> a solid:TypeRegistration;
8
+ // solid:forClass as:Actor;
9
+ // solid:instance <…/ap/actor>.
10
+ //
11
+ // The instance is the actor at the pod, not a gateway's address for it, so the
12
+ // place can be read off it. It is also how the account is found again.
13
+ //
14
+ // Every write goes through the transport's checked writers: valid RDF, to the
15
+ // profile or a document its seeAlso names, or nothing.
16
+
17
+ const SOLID = 'http://www.w3.org/ns/solid/terms#';
18
+ const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
19
+ const AS_ACTOR = 'https://www.w3.org/ns/activitystreams#Actor';
20
+ const PUBLIC_READ = ['Read'];
21
+
22
+ /** Where a new public type index goes when a person has none. */
23
+ export const newIndexUrl = (podBase) => `${podBase}settings/publicTypeIndex.ttl`;
24
+
25
+ /** The person's public type index, or null when their profile names none. */
26
+ export async function findPublicIndex(pod, podBase) {
27
+ const docs = await pod.profileDocs(podBase);
28
+ return pod.webIdValues(docs, SOLID + 'publicTypeIndex')[0] || null;
29
+ }
30
+
31
+ /** Every actor the index registers, as the instances it names. */
32
+ export async function actorsIn(pod, indexUrl) {
33
+ const g = await pod.readRdf(indexUrl).catch(() => null);
34
+ if (!g) return [];
35
+ const out = [];
36
+ for (const reg of g.each(null, pod.sym(SOLID + 'forClass'), pod.sym(AS_ACTOR))) {
37
+ for (const inst of g.each(reg, pod.sym(SOLID + 'instance'), null)) out.push(inst.value);
38
+ }
39
+ return [...new Set(out)];
40
+ }
41
+
42
+ // One registration per actor, named by the actor, so registering again finds
43
+ // the same one.
44
+ function regFor(indexUrl, actorUrl) {
45
+ let h = 0;
46
+ for (const c of actorUrl) h = (h * 31 + c.charCodeAt(0)) >>> 0;
47
+ return `${indexUrl}#actor-${h.toString(36)}`;
48
+ }
49
+
50
+ /** Add the account's registration, unless the index already has one for it. */
51
+ export async function register(pod, indexUrl, actorUrl) {
52
+ const g = await pod.readRdf(indexUrl);
53
+ if (!g) throw new Error(`no type index at ${indexUrl}`);
54
+ if ((await actorsIn(pod, indexUrl)).includes(actorUrl)) return false;
55
+ const reg = pod.sym(regFor(indexUrl, actorUrl));
56
+ const status = await pod.writeRdfChecked(indexUrl, g, {
57
+ inserts: [
58
+ [reg, pod.sym(RDF_TYPE), pod.sym(SOLID + 'TypeRegistration')],
59
+ [reg, pod.sym(SOLID + 'forClass'), pod.sym(AS_ACTOR)],
60
+ [reg, pod.sym(SOLID + 'instance'), pod.sym(actorUrl)],
61
+ ],
62
+ mustDescribe: indexUrl,
63
+ });
64
+ if (status >= 300) throw new Error(`the type index at ${indexUrl} did not take the registration (${status})`);
65
+ return true;
66
+ }
67
+
68
+ /**
69
+ * Make a public type index and name it from the profile. Only ever called
70
+ * after the person has said yes. An index already at the usual place is kept,
71
+ * not overwritten; it is only named.
72
+ */
73
+ export async function createPublicIndex(pod, podBase) {
74
+ const url = newIndexUrl(podBase);
75
+ const doc = pod.sym(url);
76
+ const existing = await pod.readRdf(url).catch(() => null);
77
+ if (!existing) {
78
+ const status = await pod.writeRdfChecked(url, null, {
79
+ inserts: [[doc, pod.sym(RDF_TYPE), pod.sym(SOLID + 'TypeIndex')],
80
+ [doc, pod.sym(RDF_TYPE), pod.sym(SOLID + 'ListedDocument')]],
81
+ mustDescribe: url,
82
+ });
83
+ if (status >= 300) throw new Error(`could not make a type index at ${url} (${status})`);
84
+ }
85
+ // Public, as the profile is: an app finds your things by reading it.
86
+ await pod.setAcl(url, PUBLIC_READ);
87
+ await pod.writeAboutWebId(podBase, {
88
+ inserts: [[pod.sym(pod.webId), pod.sym(SOLID + 'publicTypeIndex'), doc]],
89
+ });
90
+ return url;
91
+ }
92
+
93
+ /**
94
+ * The actors this person's index says live in this pod. Empty when there is no
95
+ * index or nothing registered.
96
+ */
97
+ export async function registeredActors(pod, podBase) {
98
+ const index = await findPublicIndex(pod, podBase).catch(() => null);
99
+ if (!index) return [];
100
+ return (await actorsIn(pod, index)).filter(a => a.startsWith(podBase) && a.endsWith('ap/actor'));
101
+ }
package/lib/pod/urls.mjs CHANGED
@@ -46,6 +46,12 @@ export function apUrls(remotePod, root, { publicBase = null } = {}) {
46
46
  pendingFollowers: face + 'ap/private/pending-followers',
47
47
  pendingFollowing: face + 'ap/private/pending-following',
48
48
  blocked: face + 'ap/private/blocked',
49
+ // The outbox as its owner reads it (every message, §5.1) and what the
50
+ // owner has liked (§5.5): the owner's alone, so they sit here too — and
51
+ // at the pod's own address even for a fronted identity, because the owner
52
+ // reads them there, with a credential the pod checks.
53
+ ownOutbox: home + 'ap/private/outbox',
54
+ liked: home + 'ap/private/liked',
49
55
  profileHtml: face + 'ap/profile.html',
50
56
  // Media stays on the pod even when fronted: attachment urls are not
51
57
  // identity-checked by remotes, and proxying blobs would be pure cost.
@@ -45,10 +45,10 @@ const mintSecret = () => crypto.randomBytes(32).toString('base64');
45
45
  * secret is moved into the pod and the host's copy removed, so the owner's
46
46
  * existing door link goes on working.
47
47
  */
48
- export async function ensureDoorSecret(session, podBase, { rotate = false, dataDir = null, handle = null, log = () => {} } = {}) {
48
+ export async function ensureDoorSecret(session, podBase, { rotate = false, dataDir = null, handle = null, root = 'fedipod/', log = () => {} } = {}) {
49
49
  const base = podBase.endsWith('/') ? podBase : podBase + '/';
50
- // Under the identity's own tree (fedipod/), where the gate reads it back.
51
- const url = apUrls(base, 'fedipod/').state + 'door-secret.json';
50
+ // Under the identity's own tree, where the gate reads it back.
51
+ const url = apUrls(base, root).state + 'door-secret.json';
52
52
  const onHost = dataDir && handle ? path.join(dataDir, handle, 'door-secret.json') : null;
53
53
 
54
54
  if (!rotate) {
@@ -125,7 +125,7 @@ export function handleFor(podBase) {
125
125
  * pod does not travel to, so handing the pod over hands over an identity that
126
126
  * cannot sign. An identity set up before this gets the mode added.
127
127
  */
128
- function ensureCredential(home, { podBase, webId }) {
128
+ function ensureCredential(home, { podBase, webId, root = 'fedipod/' }) {
129
129
  const file = path.join(home, 'credential.json');
130
130
  try {
131
131
  const rec = JSON.parse(fs.readFileSync(file, 'utf8'));
@@ -138,7 +138,7 @@ function ensureCredential(home, { podBase, webId }) {
138
138
  webId,
139
139
  remotePod: podBase.endsWith('/') ? podBase : podBase + '/',
140
140
  createdAt: new Date().toISOString(),
141
- root: 'fedipod/',
141
+ root,
142
142
  keysMode: 'pod',
143
143
  };
144
144
  writeJsonAtomic(file, rec, { mode: 0o600 });
@@ -269,6 +269,7 @@ export async function deliverToInbox(agent, request, { podPut, gatewayWebId = nu
269
269
  */
270
270
  export async function startEmbeddedAgent({
271
271
  podBase,
272
+ root = 'fedipod/',
272
273
  dataDir,
273
274
  session,
274
275
  resourceStore = null,
@@ -285,7 +286,7 @@ export async function startEmbeddedAgent({
285
286
  fs.mkdirSync(home, { recursive: true, mode: 0o700 });
286
287
 
287
288
  const webId = base + webIdSuffix.replace(/^\//u, '');
288
- const cred = ensureCredential(home, { podBase: base, webId });
289
+ const cred = ensureCredential(home, { podBase: base, webId, root });
289
290
 
290
291
  const agent = new Agent({ home, log });
291
292
  agent.log = log;
@@ -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,44 @@ export function fediAccount({
58
60
  return body;
59
61
  };
60
62
 
63
+ // A collection, or a page of one, may be named by its address or given
64
+ // whole in place (ActivityPub allows either); the whole one is used as it
65
+ // is, an address is fetched.
66
+ const idOf = (v) => (typeof v === 'string' ? v : v?.id || null);
67
+ const asDoc = async (v) => (v && typeof v === 'object'
68
+ ? (v.type ? v : await asDoc(v.id))
69
+ : (typeof v === 'string' ? await json(v, { headers: { accept: 'application/activity+json' } }) : null));
70
+
71
+ // Only loaded when `outbox({ rdf: true })` is actually called, so nobody
72
+ // pays for a JSON-LD parser just by importing this file. Not in
73
+ // `dependencies` — the app supplies "jsonld" if it wants this flag to work.
74
+ const loadJsonLd = async () => {
75
+ try { return (await import('jsonld')).default; }
76
+ catch { throw new Error('outbox({ rdf: true }) needs the "jsonld" package available to the app — it is not bundled with this library'); }
77
+ };
78
+
79
+ // The account's real outbox, read from the pod: the head collection names
80
+ // `first`, each page names `orderedItems` and (while there is more) `next`.
81
+ // Walked only far enough to cover `limit`, and run through jsonld.toRDF so
82
+ // the result is a genuine RDF/JS quad array, not this library's own shape.
83
+ const fetchOutboxRdf = async (actorUrl, limit) => {
84
+ if (!actorUrl) return null;
85
+ const jsonld = await loadJsonLd();
86
+ const actorDoc = await json(actorUrl, { headers: { accept: 'application/activity+json' } });
87
+ const head = await asDoc(actorDoc?.outbox);
88
+ let page = head?.first ?? null;
89
+ const quads = []; const seen = new Set(); let collected = 0;
90
+ while (page && collected < limit && !seen.has(idOf(page) || page)) {
91
+ seen.add(idOf(page) || page);
92
+ const doc = await asDoc(page);
93
+ if (!doc) break;
94
+ quads.push(...(await jsonld.toRDF(doc)));
95
+ collected += (doc.orderedItems || []).length;
96
+ page = doc.next ?? null;
97
+ }
98
+ return quads;
99
+ };
100
+
61
101
  // ---- finding out what an address is ----
62
102
 
63
103
  // Does this host speak the Mastodon API? Any server of that family says so
@@ -195,7 +235,7 @@ export function fediAccount({
195
235
  let actor = p.actor || pick(me, FOAF_ACCOUNT);
196
236
  let door = pick(me, AS_OUTBOX);
197
237
  const doc = actor ? await json(actor, { headers: { accept: 'application/activity+json' } }) : null;
198
- if (!door && doc?.outbox) door = doc.outbox;
238
+ if (!door && doc?.outbox) door = idOf(doc.outbox);
199
239
  let root = p.root || null;
200
240
  if (!root && doc?.preferredUsername && actor) {
201
241
  const { aliases } = await webfinger(doc.preferredUsername, new URL(actor).host).catch(() => ({ aliases: [] }));
@@ -255,6 +295,20 @@ export function fediAccount({
255
295
  const list = await call(`/api/v1/timelines/home?limit=${Math.min(40, limit)}`).then((r) => said(r, a.host));
256
296
  return (Array.isArray(list) ? list : []).map(item);
257
297
  },
298
+ // This account's own posts, newest first — Mastodon's equivalent of an
299
+ // outbox. A Mastodon server IS an ActivityPub server, so its actor and
300
+ // outbox are real AS2/JSON-LD too, same as a pod's; `rdf: true` reads
301
+ // that, not the REST API above. It comes back empty on an instance that
302
+ // requires a signed request just to read the public actor document
303
+ // (some do — mastodon.social among them); `fetchOutboxRdf`'s plain GET
304
+ // then gets 401 and the loop below finds nothing to walk.
305
+ async outbox({ limit = 20, rdf = false } = {}) {
306
+ const me = await call('/api/v1/accounts/verify_credentials').then((r) => said(r, a.host));
307
+ const list = await call(`/api/v1/accounts/${me.id}/statuses?limit=${Math.min(40, limit)}`).then((r) => said(r, a.host));
308
+ const items = (Array.isArray(list) ? list : []).map(item);
309
+ if (rdf) items.rdf = await fetchOutboxRdf(a.actor, limit);
310
+ return items;
311
+ },
258
312
  async follow(handle) {
259
313
  const who = parseAddress(handle);
260
314
  if (!who?.at) throw new Error('a handle looks like @you@your.server');
@@ -295,7 +349,7 @@ export function fediAccount({
295
349
  fetch: session.fetch,
296
350
  async profile() {
297
351
  const doc = facts.actor ? await json(facts.actor, { headers: { accept: 'application/activity+json' } }) : null;
298
- const count = async (url) => (url ? (await json(url, { headers: { accept: 'application/activity+json' } }))?.totalItems ?? null : null);
352
+ const count = async (v) => (await asDoc(v))?.totalItems ?? null;
299
353
  return { handle: facts.handle, name: doc?.name || null, url: doc?.url || null, avatar: doc?.icon?.url || (typeof doc?.icon === 'string' ? doc.icon : null), bio: doc?.summary || '',
300
354
  followers: await count(doc?.followers), following: await count(doc?.following), posts: await count(doc?.outbox), actor: facts.actor, webId: facts.webId };
301
355
  },
@@ -314,6 +368,23 @@ export function fediAccount({
314
368
  .slice(0, limit)
315
369
  .map((s) => ({ id: s.noteId, url: s.noteId, published: s.published || null, author: who(s.actor), html: s.content || '', inReplyTo: s.inReplyTo || null }));
316
370
  },
371
+ // This account's own posts, newest first, read from the same local
372
+ // record `timeline` uses (fast, no network call). With `rdf: true`,
373
+ // also fetches the real outbox from the pod and parses it into RDF/JS
374
+ // quads on the returned array's `.rdf` — a second, live document, not
375
+ // derived from the local record, so it needs `jsonld` (see loadJsonLd).
376
+ async outbox({ limit = 20, rdf = false } = {}) {
377
+ const rows = await state('statuses.json');
378
+ const actors = (await state('actors.json')) || {};
379
+ 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 }; };
380
+ const items = (Array.isArray(rows) ? rows : [])
381
+ .filter((s) => s.kind === 'post')
382
+ .sort((x, y) => String(y.published || '').localeCompare(String(x.published || '')))
383
+ .slice(0, limit)
384
+ .map((s) => ({ id: s.noteId, url: s.noteId, published: s.published || null, author: who(s.actor), html: s.content || '', inReplyTo: s.inReplyTo || null }));
385
+ if (rdf) items.rdf = await fetchOutboxRdf(facts.actor, limit);
386
+ return items;
387
+ },
317
388
  async follow(handle) {
318
389
  const who = parseAddress(handle);
319
390
  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.1",
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-server",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
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",