fedipod-server 0.7.0 → 0.9.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.
package/lib/setup.mjs CHANGED
@@ -11,6 +11,7 @@
11
11
  import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import { pathToFileURL } from 'node:url';
14
+ import * as $rdf from 'rdflib';
14
15
 
15
16
  import { createAccountWithPod as realCreateAccount } from './account.mjs';
16
17
  import { mintCredential as realMint } from './remote.mjs';
@@ -20,8 +21,48 @@ import { rootOf, recordLastUsed, writeJsonAtomic } from './home.mjs';
20
21
  import { insecureUrlReason } from './safefetch.mjs';
21
22
  import { CURRENT_LAYOUT, isCurrent } from './migrate.mjs';
22
23
 
24
+ const SOLID = $rdf.Namespace('http://www.w3.org/ns/solid/terms#');
25
+
23
26
  export const STEPS = ['account', 'credential', 'bootstrap', 'connect', 'publish', 'verify'];
24
27
 
28
+ // Read-only pre-check on the pod behind an existing-pod setup, before a
29
+ // credential is minted or the first write is attempted. Two failures it turns
30
+ // from a bare "PUT … → 401" into a plain sentence:
31
+ // 1. the pod host is unreachable — no account should be built on a pod that
32
+ // is not there;
33
+ // 2. the pod's WebID document declares no OIDC issuer. A pod verifies a token
34
+ // by dereferencing its WebID and looking for solid:oidcIssuer; an empty or
35
+ // issuer-less profile makes it reject every write with 401, so setup would
36
+ // stall on the first one. Only judged when the document is publicly
37
+ // readable — a protected one cannot be read here and is left to bootstrap.
38
+ export async function checkPodUsable(pod, { webId, fetch: doFetch = globalThis.fetch } = {}) {
39
+ const base = pod.endsWith('/') ? pod : pod + '/';
40
+ const wid = webId || new URL('profile/card#me', base).href;
41
+ const cardUrl = wid.split('#')[0];
42
+
43
+ let root;
44
+ try { root = await doFetch(base, { headers: { accept: 'text/turtle' } }); }
45
+ catch (e) { return { ok: false, error: `${base} could not be reached (${e.message}). Check the address and that the server is running.` }; }
46
+ if (root.status === 404) return { ok: false, error: `there is no pod at ${base} — the server answered 404.` };
47
+ if (root.status >= 500) return { ok: false, error: `the pod host at ${base} is not responding (HTTP ${root.status}).` };
48
+
49
+ let card;
50
+ try { card = await doFetch(cardUrl, { headers: { accept: 'text/turtle' } }); }
51
+ catch (e) { return { ok: false, error: `the pod's WebID document ${cardUrl} could not be read (${e.message}).` }; }
52
+ if (card.status === 404) return { ok: false, error: `the pod has no WebID document at ${cardUrl} — the identity it needs is missing.` };
53
+ if (card.status >= 500) return { ok: false, error: `the pod host is not serving ${cardUrl} (HTTP ${card.status}).` };
54
+ if (card.status === 401 || card.status === 403) return { ok: true }; // cannot read unauthenticated; leave it to bootstrap
55
+
56
+ const body = await card.text().catch(() => '');
57
+ const g = $rdf.graph();
58
+ try { $rdf.parse(body, g, cardUrl, (card.headers.get('content-type') || 'text/turtle').split(';')[0].trim()); }
59
+ catch { return { ok: false, error: `the pod's WebID document ${cardUrl} could not be parsed as RDF.` }; }
60
+ if (!g.each($rdf.sym(wid), SOLID('oidcIssuer'), null).length) {
61
+ return { ok: false, error: `the pod's WebID document ${cardUrl} declares no OIDC issuer, so the pod will reject every write. It looks empty or incomplete — restore it, or set up with a fresh pod.` };
62
+ }
63
+ return { ok: true };
64
+ }
65
+
25
66
  export const credentialPath = (home) => path.join(home, 'credential.json');
26
67
  export const hasCredential = (home) => fs.existsSync(credentialPath(home));
27
68
 
@@ -118,6 +159,7 @@ export function preflight({ mode, pod, issuer, podName, handle, kind }) {
118
159
  export async function runSetup({ home, agent, answers, run, deps = {}, log = () => {} }) {
119
160
  const createAccount = deps.createAccountWithPod || realCreateAccount;
120
161
  const mint = deps.mintCredential || realMint;
162
+ const checkPod = deps.checkPodUsable || checkPodUsable;
121
163
 
122
164
  const at = (key) => run.steps.find(s => s.key === key);
123
165
  const begin = (key) => { at(key).state = 'running'; };
@@ -166,14 +208,21 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
166
208
  }
167
209
  done('account', pod);
168
210
  } else {
211
+ // A pod you bring must be there and carry a usable WebID before a
212
+ // credential is minted against it — no account on a pod that is not
213
+ // reachable, and no silent 401 later on a pod whose profile is empty.
214
+ const usable = await checkPod(pod);
215
+ if (!usable.ok) throw new Error(usable.error);
169
216
  skip('account', 'using the pod you already have');
170
217
  }
171
218
 
172
219
  // --- credential: the point of no return, and the durability boundary ---
220
+ let resumeWebId = null;
173
221
  if (resuming) {
174
222
  const rec = JSON.parse(fs.readFileSync(credPath, 'utf8'));
175
223
  pod = rec.remotePod;
176
224
  root = rec.root;
225
+ resumeWebId = rec.webId || null;
177
226
  skip('credential', `already minted — ${credPath}`);
178
227
  } else {
179
228
  begin('credential');
@@ -212,6 +261,13 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
212
261
 
213
262
  // --- provision the pod and bring federation up ---
214
263
  begin('bootstrap');
264
+ // Resuming skipped the checks the fresh paths ran, and the credential it
265
+ // carries may be the one bound to a pod whose profile is empty — the write
266
+ // below would 401. Catch it here, as this step, with a sentence.
267
+ if (resuming) {
268
+ const ready = await checkPod(pod, { webId: resumeWebId || undefined });
269
+ if (!ready.ok) throw new Error(ready.error);
270
+ }
215
271
  await agent.bootstrap({ handle, name: name || handle, root, kind, approveJoins, summary, icon, gateway });
216
272
  done('bootstrap');
217
273
 
package/lib/storage.mjs CHANGED
@@ -61,9 +61,15 @@ export class HttpStorage {
61
61
  return { notModified: false, names, etag: res.headers.get('etag') };
62
62
  }
63
63
 
64
- async read(p, { etag } = {}) {
64
+ // `accept` is for callers reading something that is RDF but is wanted as it
65
+ // was written: asking turtle-first for a JSON-LD document gets turtle back,
66
+ // because the server is entitled to convert between two RDF syntaxes.
67
+ async read(p, { etag, accept } = {}) {
65
68
  const res = await this.fetchImpl(this._url(p), {
66
- headers: { accept: 'text/turtle, application/json;q=0.9, */*;q=0.8', ...(etag ? { 'if-none-match': etag } : {}) },
69
+ headers: {
70
+ accept: accept || 'text/turtle, application/json;q=0.9, */*;q=0.8',
71
+ ...(etag ? { 'if-none-match': etag } : {}),
72
+ },
67
73
  });
68
74
  if (res.status === 304) return { ok: true, notModified: true, status: 304, body: null, etag };
69
75
  if (res.status >= 400) return { ok: false, notModified: false, status: res.status, body: null, etag: null };
package/lib/store.mjs CHANGED
@@ -343,7 +343,8 @@ export class PodStore {
343
343
  }
344
344
  addStatus(s) {
345
345
  const all = this.getStatuses();
346
- if (all.some(x => x.noteId === s.noteId)) return;
346
+ const at = all.findIndex(x => x.noteId === s.noteId);
347
+ if (at >= 0) return this._mergeStatus(all, at, s);
347
348
  // Same reason as cacheActor: statuses.json is serialized whole on every
348
349
  // change, and remote content is only bounded by the 5 MB fetch ceiling.
349
350
  if (typeof s.content === 'string' && s.content.length > MAX_CONTENT) {
@@ -352,6 +353,30 @@ export class PodStore {
352
353
  all.unshift(s);
353
354
  this.write('statuses.json', all.slice(0, 1000));
354
355
  this.onEvent?.('status', s); // streaming subscribers
356
+ return { added: true, merged: false, status: s };
357
+ }
358
+
359
+ // The same post arrives twice when two of the owner's accounts follow its
360
+ // author, and the second arrival is the only record that the other one saw
361
+ // it. Merged in place — the per-kind prune tails take the tail to be the
362
+ // oldest — and with no stream event, which would show the post twice.
363
+ _mergeStatus(all, at, s) {
364
+ const row = all[at];
365
+ const known = new Set((row.sourceAccts || []).map(v => v.acct));
366
+ const fresh = (s.sourceAccts || []).filter(v => v && !known.has(v.acct));
367
+ // Only our own verified intake may raise a row's kind. A general ladder
368
+ // would let a source that merely SAW a post promote a stranger's mention
369
+ // into the home timeline, which is the route tagfeed had to close.
370
+ const first = (k) => k === 'post' || k === 'timeline';
371
+ const raise = first(s.kind) && !first(row.kind);
372
+ if (!fresh.length && !raise) return { added: false, merged: false, status: row };
373
+ all[at] = {
374
+ ...row,
375
+ ...(fresh.length ? { sourceAccts: [...(row.sourceAccts || []), ...fresh] } : {}),
376
+ ...(raise ? { kind: s.kind, ...(s.slug ? { slug: s.slug } : {}) } : {}),
377
+ };
378
+ this.write('statuses.json', all);
379
+ return { added: false, merged: true, status: all[at] };
355
380
  }
356
381
  updateStatus(noteId, patch) {
357
382
  const all = this.getStatuses();
package/lib/wire.mjs CHANGED
@@ -108,7 +108,7 @@ export const assertionKeyId = (urls) => urls.actor + '#ed25519-key';
108
108
  export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null, movedTo = null, kind = 'person',
109
109
  approveJoins = false, summary = null, icon = null, image = null, fields = [],
110
110
  webId = null, aliases = [], moderators = null, pendingFollowers = null, pendingFollowing = null,
111
- blocked = null, inbox = null }) {
111
+ blocked = null, inbox = null, outbox = null, oauthAuthorize = null, oauthToken = null }) {
112
112
  // manuallyApprovesFollowers is NOT in the base AS2 context, so it is declared
113
113
  // inline exactly as Mastodon declares it — and only when we actually use it.
114
114
  // It is what makes a client show "Request to follow" rather than "Follow" and
@@ -188,8 +188,23 @@ export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null
188
188
  inbox: inbox || urls.inbox,
189
189
  // Every Mastodon actor publishes one. With a single actor per pod ours is
190
190
  // just the inbox, but its absence is the non-standard thing.
191
- endpoints: { sharedInbox: inbox || urls.inbox },
192
- outbox: urls.outbox,
191
+ //
192
+ // The two OAuth entries are how a client-to-server client finds its way in
193
+ // without being told anything by hand (ActivityPub 4.1). They appear only
194
+ // where this identity's client surface answers on an address a stranger
195
+ // can reach, which is why the caller supplies them rather than this
196
+ // building them from the pod URL.
197
+ endpoints: {
198
+ sharedInbox: inbox || urls.inbox,
199
+ ...(oauthAuthorize ? { oauthAuthorizationEndpoint: oauthAuthorize } : {}),
200
+ ...(oauthToken ? { oauthTokenEndpoint: oauthToken } : {}),
201
+ },
202
+ // Where a client sends what this actor writes, which the protocol says is
203
+ // this same address. The pod holds the collection and answers reads of it;
204
+ // a client-to-server write has to reach the agent, and the pod cannot take
205
+ // one. So where the agent is reachable it is named here, and a read of it
206
+ // is sent straight on to the pod's own document.
207
+ outbox: outbox || urls.outbox,
193
208
  // The pinned posts, as the collection other servers read when they show
194
209
  // this profile. Mastodon's term, declared the way Mastodon declares it.
195
210
  featured: urls.featured,
@@ -243,8 +258,8 @@ ${icon ? `<img class="avatar" src="${esc(icon)}" alt="">` : ''}
243
258
  <h1>${esc(name)}</h1>
244
259
  <p class="address">${esc(address)}</p>
245
260
  ${summary ? `<div>${summary}</div>` : ''}
246
- <p>This is ${what} on the fediverse. To follow it, paste the address above
247
- into the search box of Mastodon or any fediverse app — or use the form.</p>
261
+ <p>This is ${what} on the Fediverse. To follow it, paste the address above
262
+ into the search box of Mastodon or any Fediverse app — or use the form.</p>
248
263
  <form id="follow">
249
264
  <label for="server">your server</label>
250
265
  <input id="server" type="text" placeholder="mastodon.social" autocomplete="off"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod-server",
3
- "version": "0.7.0",
3
+ "version": "0.9.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",