fedipod-server 0.28.1 → 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 (61) 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/core/deliver.mjs +17 -1
  12. package/lib/core/intake/activities.mjs +18 -2
  13. package/lib/core/intake/group.mjs +3 -1
  14. package/lib/core/intake/index.mjs +33 -2
  15. package/lib/core/intake/notes.mjs +12 -5
  16. package/lib/core/place.mjs +82 -0
  17. package/lib/core/publisher/collections.mjs +37 -2
  18. package/lib/core/publisher/index.mjs +23 -1
  19. package/lib/core/publisher/notes.mjs +73 -7
  20. package/lib/core/publisher/own.mjs +143 -0
  21. package/lib/core/publisher/questions.mjs +5 -3
  22. package/lib/core/social.mjs +62 -32
  23. package/lib/core/store.mjs +11 -1
  24. package/lib/core/wire.mjs +51 -15
  25. package/lib/device/admin/routes/lifecycle.mjs +1 -1
  26. package/lib/device/admin/routes/setup.mjs +17 -1
  27. package/lib/device/cli/commands/setup.mjs +50 -8
  28. package/lib/device/cli/context.mjs +1 -1
  29. package/lib/device/migrate.mjs +1 -1
  30. package/lib/device/setup.mjs +45 -8
  31. package/lib/gateway/front-core.mjs +54 -58
  32. package/lib/gateway/gateway-core.mjs +87 -8
  33. package/lib/gateway/relay-extras.mjs +89 -0
  34. package/lib/gateway/token-claims.mjs +16 -0
  35. package/lib/pod/location.mjs +52 -0
  36. package/lib/pod/notes.mjs +2 -4
  37. package/lib/pod/transport.mjs +171 -22
  38. package/lib/pod/type-index.mjs +101 -0
  39. package/lib/pod/urls.mjs +6 -0
  40. package/lib/server/embed.mjs +7 -6
  41. package/lib/session/fedi-account.mjs +19 -10
  42. package/lib/session/package.json +1 -1
  43. package/package.json +1 -1
  44. package/run-agent.mjs +2 -0
  45. package/web/admin/actors.js +2 -0
  46. package/web/admin/index.html +9 -0
  47. package/web/admin/record.js +4 -1
  48. package/web/admin/setup/index.html +17 -1
  49. package/web/admin/setup/setup.js +18 -5
  50. package/web/app/README.md +1 -1
  51. package/web/app/admin-facade.mjs +1 -1
  52. package/web/app/agent.mjs +11 -2
  53. package/web/app/boot.mjs +72 -32
  54. package/web/app/dist/boot.js +430 -86
  55. package/web/app/dist/boot.js.map +4 -4
  56. package/web/app/dist/sw.js +3294 -2648
  57. package/web/app/dist/sw.js.map +4 -4
  58. package/web/app/index.html +16 -0
  59. package/web/app/signup.mjs +65 -26
  60. package/web/front/run.html +7 -1
  61. 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
@@ -445,16 +457,11 @@ export class PodTransport {
445
457
  * written back — an empty or foreign body must never become the new profile.
446
458
  */
447
459
  async linkAccountInProfile({ actorUrl, accountName, kind = 'person', outbox = null }) {
448
- const docUrl = this.webId.split('#')[0];
449
- const res = await this.fetch(docUrl, { headers: { accept: 'text/turtle' } });
450
- if (res.status >= 400) throw new Error(`[${this.label}] GET ${docUrl} → ${res.status}`);
451
- const g = $rdf.graph();
452
- $rdf.parse(await res.text(), g, docUrl, 'text/turtle');
453
- const doc = $rdf.sym(docUrl);
454
460
  const me = $rdf.sym(this.webId);
455
- if (!g.statementsMatching(me, null, null, doc).length) {
456
- throw new Error(`profile at ${docUrl} does not mention ${this.webId} — not rewriting it`);
457
- }
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));
458
465
  const actor = $rdf.sym(actorUrl);
459
466
  const wanted = [
460
467
  [me, FOAF('account'), actor],
@@ -465,22 +472,24 @@ export class PodTransport {
465
472
  // WebID is what dokieli reads); only where a door exists to take it.
466
473
  ...(outbox ? [[me, AS('outbox'), $rdf.sym(outbox)]] : []),
467
474
  ];
468
- 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));
469
476
  // A handle change leaves the old accountName behind; ours is replaced.
470
477
  // Likewise an outbox that moved.
471
- const stale = [
472
- ...g.statementsMatching(actor, FOAF('accountName'), null, doc).filter(st => st.object.value !== accountName),
473
- ...(outbox ? g.statementsMatching(me, AS('outbox'), null, doc).filter(st => st.object.value !== outbox) : []),
474
- ];
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
+ }
475
489
  if (!missing.length && !stale.length) return false;
476
- // A patch touches these statements and nothing else. Rewriting the whole
477
- // profile re-serialises statements that are not ours — the OIDC issuer
478
- // among them — and a server is entitled to refuse a write that would.
479
- const deletes = stale.map(st => [st.subject, st.predicate, st.object]);
480
- if (await this.patchDocument(docUrl, missing, deletes)) return true;
481
- for (const st of stale) g.remove(st);
482
- for (const [s, p, o] of missing) g.add(s, p, o, doc);
483
- 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 });
484
493
  return true;
485
494
  }
486
495
 
@@ -522,4 +531,144 @@ export class PodTransport {
522
531
  if (res.status === 405 || res.status === 415 || res.status === 501) return false;
523
532
  throw new Error(`[${this.label}] PATCH ${docUrl} → ${res.status}`);
524
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
+
525
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;
@@ -60,6 +60,14 @@ export function fediAccount({
60
60
  return body;
61
61
  };
62
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
+
63
71
  // Only loaded when `outbox({ rdf: true })` is actually called, so nobody
64
72
  // pays for a JSON-LD parser just by importing this file. Not in
65
73
  // `dependencies` — the app supplies "jsonld" if it wants this flag to work.
@@ -76,15 +84,16 @@ export function fediAccount({
76
84
  if (!actorUrl) return null;
77
85
  const jsonld = await loadJsonLd();
78
86
  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;
87
+ const head = await asDoc(actorDoc?.outbox);
88
+ let page = head?.first ?? null;
80
89
  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;
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;
88
97
  }
89
98
  return quads;
90
99
  };
@@ -226,7 +235,7 @@ export function fediAccount({
226
235
  let actor = p.actor || pick(me, FOAF_ACCOUNT);
227
236
  let door = pick(me, AS_OUTBOX);
228
237
  const doc = actor ? await json(actor, { headers: { accept: 'application/activity+json' } }) : null;
229
- if (!door && doc?.outbox) door = doc.outbox;
238
+ if (!door && doc?.outbox) door = idOf(doc.outbox);
230
239
  let root = p.root || null;
231
240
  if (!root && doc?.preferredUsername && actor) {
232
241
  const { aliases } = await webfinger(doc.preferredUsername, new URL(actor).host).catch(() => ({ aliases: [] }));
@@ -340,7 +349,7 @@ export function fediAccount({
340
349
  fetch: session.fetch,
341
350
  async profile() {
342
351
  const doc = facts.actor ? await json(facts.actor, { headers: { accept: 'application/activity+json' } }) : null;
343
- 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;
344
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 || '',
345
354
  followers: await count(doc?.followers), following: await count(doc?.following), posts: await count(doc?.outbox), actor: facts.actor, webId: facts.webId };
346
355
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fediverse-account",
3
- "version": "0.2.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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod-server",
3
- "version": "0.28.1",
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",
package/run-agent.mjs CHANGED
@@ -552,6 +552,8 @@ export class Agent {
552
552
  // Own posts the outbox names and the timeline index lacks come back here,
553
553
  // before anything acts on the index.
554
554
  await this.publisher.healStatuses().catch(e => this.log(`healing the timeline index: ${e.message}`));
555
+ // Likes made before the liked list existed become its first entries.
556
+ try { this.publisher.backfillLiked(); } catch (e) { this.log(`liked list: ${e.message}`); }
555
557
  this.lease.onLost = () => this.demote();
556
558
  this.lease.startRenewal();
557
559
  this.deliverer.startQueue();
@@ -136,6 +136,8 @@ $('new-actor-form').addEventListener('submit', async (ev) => {
136
136
  };
137
137
  if (answers.mode === 'new') answers.podName = $('new-podname').value.trim() || handle;
138
138
  else answers.pod = $('new-pod').value.trim();
139
+ answers.container = $('new-container').value.trim();
140
+ answers.createIndex = $('new-createIndex').checked;
139
141
  $('new-actor-go').disabled = true;
140
142
  say(`setting up ${handle || 'the new actor'} — this takes a while`);
141
143
  const r = await write('/new-actor', answers, `setting up ${handle}`);
@@ -272,6 +272,15 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
272
272
  <label for="new-pod">Pod address</label>
273
273
  <input type="url" id="new-pod" autocomplete="url" placeholder="https://you.solidcommunity.net/">
274
274
  </div>
275
+ <label for="new-container">Store your data in a container named fedipod, inside this container</label>
276
+ <input type="text" id="new-container" autocomplete="off" spellcheck="false" placeholder="/"
277
+ aria-describedby="new-container-hint">
278
+ <p class="hint" id="new-container-hint">Leave it empty for your pod's root. This is permanent.</p>
279
+ <p id="new-typeindex-text">FediPod records where the account lives in your pod's public type index,
280
+ the list Solid apps use to find your things. A new pod has none; setup stops if yours has none
281
+ and you have not said yes here. It will be readable by anyone, like your profile.</p>
282
+ <label class="choice"><input type="checkbox" id="new-createIndex" aria-describedby="new-typeindex-text">
283
+ If my pod has no public type index, create one</label>
275
284
  </fieldset>
276
285
 
277
286
  <p>
@@ -269,6 +269,9 @@ function render() {
269
269
  ['Fediverse identity', config.address || `@${config.handle} — no resolvable address`,
270
270
  config.accountId && config.address ? { href: `/admin/client/#/a/${config.accountId}` } : null],
271
271
  ['Solid identity', config.webId, config.remotePod ? { href: config.remotePod, blank: true } : null],
272
+ // Where on the pod the account's data lives: the container its owner chose.
273
+ ['stored at', config.remotePod && config.root ? config.remotePod + config.root : null,
274
+ config.remotePod && config.root ? { href: config.remotePod + config.root, blank: true } : null],
272
275
  ['Other identities', 'ctl'],
273
276
  ['local store', config.home],
274
277
  // The address you actually open, not the bare number — the named origin when
@@ -362,7 +365,7 @@ function render() {
362
365
  if (!config.address) {
363
366
  const p = document.createElement('p');
364
367
  p.className = 'warn';
365
- p.textContent = `${config.remotePod} is not the root of its own host, so this actor cannot be `
368
+ p.textContent = `${config.remotePod} is a suffixed pod, so this actor cannot be `
366
369
  + 'discovered as a handle by other servers. Posting and reading still work.';
367
370
  $('pane-identity').appendChild(p);
368
371
  }
@@ -151,7 +151,7 @@ button:disabled { opacity: .5; cursor: default; }
151
151
  <input type="url" id="issuerOther" name="issuerOther" autocomplete="url" placeholder="https://pods.example.org">
152
152
  </div>
153
153
  <p class="hint" id="issuer-new-hint">The Community Solid Server providers from
154
- solidproject.org. A provider that puts pods on paths gives your address at a
154
+ solidproject.org. A provider that makes suffixed pods gives your address at a
155
155
  gateway instead.</p>
156
156
  </div>
157
157
 
@@ -184,6 +184,22 @@ button:disabled { opacity: .5; cursor: default; }
184
184
  <label for="pod">Pod address</label>
185
185
  <input type="url" id="pod" name="pod" autocomplete="url" placeholder="https://you.solidcommunity.net/">
186
186
  </div>
187
+
188
+ <div id="row-container">
189
+ <label for="container">Store your data in a container named fedipod, inside this container</label>
190
+ <input type="text" id="container" name="container" autocomplete="off" spellcheck="false"
191
+ placeholder="/" aria-describedby="container-hint">
192
+ <p class="hint" id="container-hint">Leave it empty for your pod's root. This is permanent.
193
+ <span id="place"></span></p>
194
+ </div>
195
+
196
+ <div id="row-typeindex" hidden>
197
+ <p id="typeindex-text">Your pod has no public type index, the list Solid apps use to find your
198
+ things. FediPod needs one to record where your account lives. It will be readable by anyone,
199
+ like your profile.</p>
200
+ <label class="choice"><input type="checkbox" id="createIndex" name="createIndex"
201
+ aria-describedby="typeindex-text"> Create a public type index on my pod</label>
202
+ </div>
187
203
  </fieldset>
188
204
 
189
205
  <p id="row-reenter" class="warn" hidden>Finishing a setup that already has an account credential.
@@ -108,6 +108,9 @@ function answers() {
108
108
  };
109
109
  if (mode === 'new') a.podName = f.podName.value.trim() || a.handle;
110
110
  else a.pod = f.pod.value.trim();
111
+ // The container that holds fedipod/, and the yes to a new public type index.
112
+ a.container = f.container.value.trim();
113
+ a.createIndex = f.createIndex.checked;
111
114
  if (!state.resumable && !state.passwordSupplied) a.password = f.password.value;
112
115
  return a;
113
116
  }
@@ -116,13 +119,13 @@ function answers() {
116
119
  // account?", in the same words, as you type.
117
120
  const NOTES = {
118
121
  'pod-is-a-path': (a) =>
119
- `This pod is a path on ${host(a.pod)}, not the root of its own host. WebFinger is `
120
- + 'answered only at a host root, which this pod cannot write to, so other servers '
122
+ `This pod is suffixed (on ${host(a.pod)}), not subdomained. WebFinger is `
123
+ + 'answered only for a subdomained pod, so other servers '
121
124
  + 'will not find this address. Posting and reading still work; being discovered does not.',
122
125
  };
123
126
  const REFUSALS = {
124
127
  'group-needs-host-root': () =>
125
- 'A group needs a pod at the root of its own host, or nobody could ever find it. '
128
+ 'A group needs a subdomained pod, or nobody could ever find it. '
126
129
  + 'Give the group a pod of its own, or set this up as a person.',
127
130
  };
128
131
  const host = (u) => { try { return new URL(u).host; } catch { return u; } };
@@ -149,6 +152,11 @@ async function preview() {
149
152
  const { json } = await postJson('/setup/check', a);
150
153
  if (!json) return;
151
154
  $('preview').textContent = json.address || '…';
155
+ // Where the data will go, and the type index question when the pod has
156
+ // none (a new pod never does).
157
+ $('place').textContent = json.placeProblem ? json.placeProblem : (json.place ? `Your data will be at ${json.place}.` : '');
158
+ $('row-typeindex').hidden = json.typeIndex === true;
159
+ try { $('container').placeholder = a.mode === 'existing' && a.pod ? new URL(a.pod).pathname : '/'; } catch { /* not a URL yet */ }
152
160
  // The address-shape choice: hidden for a group (a group cannot front yet),
153
161
  // locked to the gateway for a pod on a suffix-based host, an open choice
154
162
  // for a pod at its own host.
@@ -165,7 +173,7 @@ async function preview() {
165
173
  if (note) {
166
174
  note.hidden = !forced;
167
175
  note.textContent = forced
168
- ? 'Your pod is on a suffix-based host, so its address lives at the gateway. Your posts, key and data stay on your pod.'
176
+ ? 'Your pod is suffixed, so its address lives at the gateway. Your posts, key and data stay on your pod.'
169
177
  : '';
170
178
  }
171
179
  }
@@ -188,6 +196,11 @@ async function preview() {
188
196
  async function onSubmit(ev) {
189
197
  ev.preventDefault();
190
198
  $('form-error').textContent = '';
199
+ // No type index and no yes: stop here, before anything is sent.
200
+ if (!$('row-typeindex').hidden && !$('createIndex').checked) {
201
+ $('form-error').textContent = 'Setup stopped. Nothing was written to your pod. FediPod needs a public type index to record where your account lives.';
202
+ return;
203
+ }
191
204
  $('submit').disabled = true;
192
205
  const { status, json } = await postJson('/setup', answers());
193
206
  if (status !== 202) {
@@ -300,7 +313,7 @@ function paneDone(result) {
300
313
  $('done-address').textContent = result?.address || `@${result?.handle} — no resolvable address`;
301
314
  const notes = [];
302
315
  if (!result?.resolvable) {
303
- notes.push(`${result?.pod} is not the root of its own host, so this address cannot be `
316
+ notes.push(`${result?.pod} is a suffixed pod, so this address cannot be `
304
317
  + 'discovered by other servers. Posting and reading still work.');
305
318
  }
306
319
  if (result?.unreachable?.length) {
package/web/app/README.md CHANGED
@@ -77,5 +77,5 @@ See the `MastoApi` options in `agent.mjs`.
77
77
  | **Web push** | `shims/web-push.mjs` is a no-op. `vapid` is omitted, and a client that subscribes anyway gets a 422 rather than a subscription nothing will push to. |
78
78
  | **Scheduled posts** | nothing runs between now and the scheduled time. A `scheduled_at` is refused with a 422 that says so — accepting one was silent loss. |
79
79
  | **Groups** | sign-up makes personal identities only (`signup.mjs`), and the moderation surface is not here. Joining a group works; hosting one needs the DeviceAgent. See `groups.md`. |
80
- | **Changing where the address lives** | the shape is chosen at sign-up (`signup.mjs`): on the pod, `@you@yourpod` with the gateway as a mail door, or at the gateway, `@you@front` — and a pod on a suffix-based host is always fronted. `admin-facade.mjs` refuses changing it afterwards, because a rename needs a restart a browser does not have. |
80
+ | **Changing where the address lives** | the shape is chosen at sign-up (`signup.mjs`): on the pod, `@you@yourpod` with the gateway as a mail door, or at the gateway, `@you@front` — and a suffixed pod is always fronted. `admin-facade.mjs` refuses changing it afterwards, because a rename needs a restart a browser does not have. |
81
81
  | **Moving the private half** | `/state-move` is about filesystem paths and `credential.json`. A browser has neither; its private half is always on the pod. |
@@ -356,7 +356,7 @@ export class AdminFacade {
356
356
  const aliases = [...(cfg.aliases || [])];
357
357
  if (body.add) {
358
358
  if (!webfingerHost(urls.base) && !cfg.gateway?.frontActor) {
359
- return json(400, { error: 'this pod is a suffix-based host, so other servers could never resolve it as a Move target' });
359
+ return json(400, { error: 'this pod is suffixed, so other servers could never resolve it as a Move target' });
360
360
  }
361
361
  const id = await this.resolveActor(body.add);
362
362
  if (!id) return json(400, { error: `could not fetch the old account (${body.add}) — enter its URL or @user@host, and it must answer` });