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
@@ -18,7 +18,10 @@ export async function setup() {
18
18
  if (process.stdin.isTTY && !has('cli') && !IDENTITY_FLAGS.some(f => args.includes('--' + f))) {
19
19
  return runBrowserSetup();
20
20
  }
21
- const root = flag('root') || 'fedipod/'; // new installs default to the fedipod/ container
21
+ // Where on the pod: --root names it outright (scripts); otherwise the person
22
+ // names the container that holds `fedipod/`, asked below once the pod is known.
23
+ let root = flag('root') || null;
24
+ const place = await import(new URL('../../../../lib/core/place.mjs', import.meta.url));
22
25
  const kind = has('group') ? 'group' : 'person';
23
26
  const approveJoins = has('group') && has('approve-joins');
24
27
  const summary = flag('summary');
@@ -48,10 +51,20 @@ if (!newAccount && !pod) {
48
51
  }
49
52
  }
50
53
  if (!newAccount && !pod) { console.error('no pod given'); process.exit(2); }
54
+ const CONTAINER_Q = 'Store your data in a container named fedipod, inside this container';
55
+ let container = flag('in');
51
56
  if (!newAccount) {
57
+ const podBase = pod.endsWith('/') ? pod : pod + '/';
58
+ if (!root) {
59
+ if (container == null) container = interactive ? await ask(`${CONTAINER_Q} (empty: ${new URL(podBase).pathname})`, '') : '';
60
+ const chosen = place.chosenRoot(podBase, container);
61
+ if (chosen.problem) { console.error(`Where to store it: ${chosen.problem}.`); process.exit(2); }
62
+ root = chosen.root;
63
+ }
52
64
  const { resourceExists } = await import(new URL('../../../../lib/pod/root.mjs', import.meta.url));
53
65
  const { apUrls, DEFAULT_ROOT: DR } = await import(new URL('../../../../lib/core/wire.mjs', import.meta.url));
54
- if (await resourceExists(fetch, apUrls(pod, DR).actor)) {
66
+ // An account at the chosen place, or an older one where accounts used to go.
67
+ if (await resourceExists(fetch, apUrls(pod, root).actor) || await resourceExists(fetch, apUrls(pod, DR).actor)) {
55
68
  if (String(flag('address') || 'pod').toLowerCase() !== 'front') {
56
69
  console.error('The pod already hosts a FediPod account. If you want a second account, put it on a different pod.');
57
70
  console.error('To move an account whose address is at another gateway to this one, add --address front.');
@@ -115,8 +128,8 @@ if (fronted && kind === 'group') {
115
128
  // warning is their call to accept. Nobody could ever find this group, and the
116
129
  // people it would fail are not the operator reading the warning.
117
130
  if (kind === 'group' && !newAccount && !wfHost) {
118
- console.error(`${pod} is a path on ${new URL(pod).host}, not the root of its own host.`);
119
- console.error('WebFinger is answered only at a host root, so nobody could find this group.');
131
+ console.error(`${pod} is a suffixed pod, not a subdomained one.`);
132
+ console.error('WebFinger is answered only for a subdomained pod, so nobody could find this group.');
120
133
  console.error('Give the group a pod of its own: fedipod setup --group --new-account');
121
134
  process.exit(2);
122
135
  }
@@ -133,6 +146,26 @@ if (fronted) {
133
146
  console.log(`pods at ${issuerHost}/${podName}/ instead cannot answer WebFinger for an address,`);
134
147
  console.log(`so setup takes an address at ${gatewayHost} for you and says so.\n`);
135
148
  }
149
+ // Where the account lives is recorded in the pod's public type index. A new
150
+ // pod has none; a pod you bring is asked. With none, only a yes goes on.
151
+ let createIndex = has('create-type-index');
152
+ if (!createIndex) {
153
+ const known = newAccount ? false : await place.publicIndexKnown(fetch, pod.endsWith('/') ? pod : pod + '/');
154
+ if (known !== true) {
155
+ const why = newAccount ? 'A new pod has no public type index' : 'Your pod has no public type index';
156
+ if (!interactive) {
157
+ console.error(`${why}. FediPod records where your account lives in one; add --create-type-index to make it.`);
158
+ process.exit(2);
159
+ }
160
+ const yes = await ask(`${why}, the list Solid apps use to find your things. FediPod needs one to record `
161
+ + 'where your account lives. Create it? It will be readable by anyone, like your profile. (y/n)', 'n');
162
+ if (!/^y/i.test(yes)) { console.log('Setup stopped. Nothing was written to your pod.'); process.exit(0); }
163
+ createIndex = true;
164
+ }
165
+ }
166
+ if (newAccount && !root && container == null) {
167
+ container = await ask(`${CONTAINER_Q} (empty: the new pod's root)`, '');
168
+ }
136
169
  console.log('The display name can be changed later; the handle and pod cannot.');
137
170
  const go = await ask(newAccount
138
171
  ? (kind === 'group' ? 'create pod and group? (y/n)' : 'create pod and Fediverse account? (y/n)')
@@ -147,16 +180,21 @@ if (newAccount) {
147
180
  const made = await createAccountWithPod({ issuer, email, password, podName });
148
181
  pod = made.pod;
149
182
  console.log(`account + pod created: ${pod}`);
183
+ if (!root) {
184
+ const chosen = place.chosenRootInNewPod(pod.endsWith('/') ? pod : pod + '/', container);
185
+ if (chosen.problem) { console.error(`Where to store it: ${chosen.problem}. The pod was made; nothing else was.`); process.exit(2); }
186
+ root = chosen.root;
187
+ }
150
188
  if (!webfingerHost(pod)) {
151
189
  if (kind === 'group') {
152
- console.log(`\n${issuerHost} created the pod at ${pod} — a suffix-based host.`);
153
- console.log('WebFinger is answered only at a host root, so nobody could find this group.');
190
+ console.log(`\n${issuerHost} created the pod at ${pod} — a suffixed pod.`);
191
+ console.log('WebFinger is answered only for a subdomained pod, so nobody could find this group.');
154
192
  console.log('The pod exists; no actor was published.');
155
193
  process.exit(0);
156
194
  }
157
195
  // A path pod's address lives at the gateway; nothing to warn about.
158
196
  fronted = true;
159
- console.log(`${issuerHost} puts pods on paths, so your address will be @${handle}@${gatewayHost}.`);
197
+ console.log(`${issuerHost} makes suffixed pods, so your address will be @${handle}@${gatewayHost}.`);
160
198
  }
161
199
  }
162
200
 
@@ -207,11 +245,15 @@ await agent.bootstrap({ handle, name, root, kind, approveJoins, summary, icon, g
207
245
  await agent.connect({ repair: false }); // publishProfile below is the publish
208
246
  await agent.publisher.publishProfile();
209
247
  await agent.store.flush();
248
+ // Where the account lives, in the pod's public type index — made first on the yes.
249
+ await place.recordPlace(agent.remote, rec.remotePod, `${rec.remotePod}${root}ap/actor`, { create: createIndex })
250
+ .then(() => console.log('recorded where the account lives in your public type index'))
251
+ .catch((e) => { console.error(`could not record where the account lives in your type index: ${e.message}`); process.exitCode = 1; });
210
252
  const finalHost = gatewayCfg ? gatewayHost : webfingerHost(rec.remotePod);
211
253
  const what = kind === 'group' ? 'group' : 'actor';
212
254
  console.log(finalHost
213
255
  ? `${what} published: @${handle}@${finalHost}`
214
- : `${what} published, but not reachable as a handle \u2014 ${rec.remotePod} is not a host root`);
256
+ : `${what} published, but not reachable as a handle \u2014 ${rec.remotePod} is a suffixed pod`);
215
257
 
216
258
  // Straight into serving — setup ends with a working client in the browser.
217
259
  const { startAdmin } = await import(new URL('../../../../lib/device/admin/index.mjs', import.meta.url));
@@ -215,7 +215,7 @@ export async function finish(agent, code = 0) {
215
215
  // unpack-and-go line depend on that. At a terminal with none of them, setup
216
216
  // asks the two things it needs to open a browser and asks the rest there.
217
217
  export const IDENTITY_FLAGS = ['new-account', 'pod', 'issuer', 'email', 'name', 'pod-name',
218
- 'group', 'approve-joins', 'summary', 'icon', 'root', 'keys', 'rotate-key'];
218
+ 'group', 'approve-joins', 'summary', 'icon', 'root', 'in', 'create-type-index', 'keys', 'rotate-key'];
219
219
 
220
220
  // Refuse before anything is asked, let alone typed: setup used to overwrite
221
221
  // credential.json in place, and a minted credential is only shown once — so
@@ -65,7 +65,7 @@ export const MOVED_STATE_DOCS = new Set([
65
65
  'config.json', 'queue.json', 'blocklist.json', 'contacts.json', 'muted.json',
66
66
  'pending.json', 'requests.json', 'deadletter.json', 'statuses.json',
67
67
  'notifications.json', 'media.json', 'actors.json', 'ids.json', 'published.json',
68
- 'outbox.json', 'outbox-removed.json', 'parked.json', 'tagfeed.json',
68
+ 'outbox.json', 'outbox-removed.json', 'outbox-own.json', 'liked.json', 'c2s-seen.json', 'parked.json', 'tagfeed.json',
69
69
  'intake-attempts.json', 'inbox-channel.json', 'masto-tokens.json', 'masto-markers.json',
70
70
  'lists.json', 'filters.json', 'scheduled.json', 'webpush.json',
71
71
  'import-state.json',
@@ -22,6 +22,7 @@ import { insecureUrlReason } from '../shared/safefetch.mjs';
22
22
  import { resourceExists } from '../pod/root.mjs';
23
23
  import { CURRENT_LAYOUT, isCurrent } from './migrate.mjs';
24
24
  import { completeGatewayMove } from './gateway-move.mjs';
25
+ import { chosenRoot, chosenRootInNewPod, publicIndexKnown, recordPlace, needsIndex } from '../core/place.mjs';
25
26
 
26
27
  const SOLID = $rdf.Namespace('http://www.w3.org/ns/solid/terms#');
27
28
 
@@ -234,10 +235,14 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
234
235
  mode, issuer, email, password, handle, name, podName,
235
236
  kind = 'person', approveJoins = false, summary, icon, keys, uiPassword,
236
237
  gateway = null, shape = 'pod', gatewayOrigin = 'https://fedipod.net',
238
+ // The container that holds `fedipod/` (blank: the pod's root), and the
239
+ // person's yes to a new public type index when their profile names none.
240
+ container = '', createIndex = false,
237
241
  } = answers;
238
242
  let { pod, root } = answers;
239
- if (!root) root = 'fedipod/'; // new installs default to the fedipod/ container; a
240
- // resuming run overwrites this with the credential's own root below.
243
+ const rootGiven = !!root;
244
+ if (!root) root = 'fedipod/'; // until the pod is known; a resuming run
245
+ // overwrites this with the credential's own root below.
241
246
  let accountWebId = null; // what createAccountWithPod reported, when it ran
242
247
  // The private half always starts here, beside the credential and the keys —
243
248
  // not on the pod. Every activity you receive would otherwise cost the pod
@@ -265,6 +270,9 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
265
270
  skip('account', 'the credential is already on disk');
266
271
  } else if (mode === 'new') {
267
272
  begin('account');
273
+ // A new pod has no public type index: without a yes to making one,
274
+ // nothing is created at all.
275
+ if (!createIndex) throw needsIndex('A new pod has no public type index.');
268
276
  const made = await createAccount({ issuer, email, password, podName: podName || handle });
269
277
  pod = made.pod;
270
278
  accountWebId = made.webId || null;
@@ -273,10 +281,15 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
273
281
  // nobody could ever find this group. A person was warned before we got
274
282
  // here and chose to continue; a group cannot.
275
283
  if (kind === 'group' && !webfingerHost(pod)) {
276
- throw new Error(`${issuer} created the pod at ${pod} — a suffix-based host, `
277
- + 'not a host root. WebFinger is only answered at a host root, so nobody '
284
+ throw new Error(`${issuer} created the pod at ${pod} — a suffixed pod. `
285
+ + 'WebFinger is only answered for a subdomained pod, so nobody '
278
286
  + 'could find this group. The pod exists; no actor was published.');
279
287
  }
288
+ if (!rootGiven) {
289
+ const place = chosenRootInNewPod(pod.endsWith('/') ? pod : pod + '/', container);
290
+ if (place.problem) throw new Error(`Where to store it: ${place.problem}.`);
291
+ root = place.root;
292
+ }
280
293
  done('account', pod);
281
294
  } else {
282
295
  // A pod you bring must be there and carry a usable WebID before a
@@ -284,7 +297,15 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
284
297
  // reachable, and no silent 401 later on a pod whose profile is empty.
285
298
  const usable = await checkPod(pod);
286
299
  if (!usable.ok) throw new Error(usable.error);
287
- const held = await (deps.resourceExists || resourceExists)(deps.fetch || fetch, apUrls(pod, root || DEFAULT_ROOT).actor);
300
+ const podBase = pod.endsWith('/') ? pod : pod + '/';
301
+ if (!rootGiven) {
302
+ const place = chosenRoot(podBase, container);
303
+ if (place.problem) throw new Error(`Where to store it: ${place.problem}.`);
304
+ root = place.root;
305
+ }
306
+ // An account at the chosen place, or an older one where accounts used to go.
307
+ const exists = (r) => (deps.resourceExists || resourceExists)(deps.fetch || fetch, apUrls(pod, r).actor);
308
+ const held = await exists(root) || (root !== DEFAULT_ROOT && await exists(DEFAULT_ROOT));
288
309
  if (held && !frontedAddress({ pod, shape })) {
289
310
  throw new Error('The pod already hosts a FediPod account. If you want a second account, put it on a different pod. '
290
311
  + 'To move an account whose address is at another gateway to this one, ask for an address at this gateway.');
@@ -292,6 +313,12 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
292
313
  if (held && kind === 'group') throw new Error('The pod already hosts a FediPod account, and a group cannot move between gateways.');
293
314
  movingIn = held;
294
315
  if (movingIn) { privateRoot = null; }
316
+ // Asked before anything is minted: a profile with no public type index
317
+ // gets one only on its person's yes.
318
+ if (!movingIn && !createIndex
319
+ && await (deps.publicIndexKnown || publicIndexKnown)(deps.fetch || fetch, podBase) !== true) {
320
+ throw needsIndex('Your profile names no public type index.');
321
+ }
295
322
  skip('account', movingIn ? 'the pod already holds the account — its address moves here' : 'using the pod you already have');
296
323
  }
297
324
 
@@ -366,8 +393,8 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
366
393
  let gatewayCfg = gateway;
367
394
  if (!gatewayCfg && frontedAddress({ pod, shape })) {
368
395
  if (kind === 'group') {
369
- throw new Error(`a group needs a pod at its own host — a Gateway address for a group is not supported yet`
370
- + (webfingerHost(pod) ? '' : `; ${pod} is a suffix-based host`) + '.');
396
+ throw new Error(`a group needs a subdomained pod — a Gateway address for a group is not supported yet`
397
+ + (webfingerHost(pod) ? '' : `; ${pod} is suffixed`) + '.');
371
398
  }
372
399
  const urls = apUrls(pod, root || DEFAULT_ROOT);
373
400
  const cred = JSON.parse(fs.readFileSync(credPath, 'utf8'));
@@ -414,6 +441,16 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
414
441
  }
415
442
  const published = await agent.publisher.publishProfile();
416
443
  await agent.store.flush();
444
+ // Where the account lives, in the owner's public type index — made first
445
+ // if they said yes to one.
446
+ if (!movingIn) {
447
+ const urlsAt = apUrls(pod, root);
448
+ try {
449
+ await (deps.recordPlace || recordPlace)(agent.publisher?.remote || agent.remote, urlsAt.base, urlsAt.home + 'ap/actor', { create: !!createIndex });
450
+ } catch (e) {
451
+ throw new Error(`could not record where the account lives in your type index (${e.message})`);
452
+ }
453
+ }
417
454
  if (movingIn) {
418
455
  // Tell the old gateway and the followers. Not fatal: left pending, it
419
456
  // is tried again when the agent next starts acting.
@@ -448,7 +485,7 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
448
485
  log(wfHost
449
486
  ? `${kind === 'group' ? 'group' : 'actor'} published: @${handle}@${wfHost}`
450
487
  : `${kind === 'group' ? 'group' : 'actor'} published, but not reachable as a handle — `
451
- + `${pod} is not a host root`);
488
+ + `${pod} is a suffixed pod`);
452
489
  } catch (e) {
453
490
  for (const s of run.steps) if (s.state === 'running') s.state = 'error';
454
491
  run.error = scrub(e.message);
@@ -0,0 +1,36 @@
1
+ // caches.mjs — what one warm gateway process keeps between requests: the
2
+ // pod-token verifier, and the public keys of the servers that deliver here.
3
+ // Built per request, each costs round trips to somebody else's server for
4
+ // every call; built once, a process pays them once.
5
+
6
+ // The Solid-OIDC verifier remembers the issuers' key sets it has seen. A
7
+ // fresh one per call re-read the caller's WebID document, the issuer's
8
+ // discovery document and its keys on every signed-in call.
9
+ let verifier = null;
10
+ export async function podTokenVerifier() {
11
+ if (!verifier) verifier = (await import('@solid/access-token-verifier')).createSolidTokenVerifier();
12
+ return verifier;
13
+ }
14
+
15
+ // The keys deliveries are signed with, by key id, in the shape Fedify's
16
+ // verifier asks for. A key is held an hour; a key that could not be fetched
17
+ // is held five minutes, so a server that was down is asked again soon.
18
+ // Fedify re-fetches on its own when a held key no longer verifies, so a
19
+ // rotated key costs one failed check, not an hour of refused mail.
20
+ const KEY_TTL_MS = 60 * 60_000;
21
+ const MISS_TTL_MS = 5 * 60_000;
22
+ const KEY_CACHE_MAX = 500;
23
+ const keys = new Map(); // key id → { key, until }
24
+ export const senderKeys = {
25
+ async get(keyId) {
26
+ const hit = keys.get(keyId.href);
27
+ if (!hit) return undefined;
28
+ if (hit.until < Date.now()) { keys.delete(keyId.href); return undefined; }
29
+ return hit.key;
30
+ },
31
+ async set(keyId, key) {
32
+ if (keys.size >= KEY_CACHE_MAX) keys.delete(keys.keys().next().value);
33
+ keys.set(keyId.href, { key, until: Date.now() + (key ? KEY_TTL_MS : MISS_TTL_MS) });
34
+ },
35
+ size: () => keys.size,
36
+ };
@@ -23,6 +23,10 @@ import * as podPolicy from '../pod/policy.mjs';
23
23
  import { podBaseOfWebId } from '../pod/urls.mjs';
24
24
  import { routeQuietApi, noteOpened, noteReceived, closedState, accountState, closedAnswer } from './quiet.mjs';
25
25
  import { routeNoticesApi } from './notices.mjs';
26
+ import { withSecurityHeaders } from './headers.mjs';
27
+ import { podTokenVerifier } from './caches.mjs';
28
+ import { claimedWebId } from './token-claims.mjs';
29
+ import { withdrawn, overLimit, relayOne } from './relay-extras.mjs';
26
30
 
27
31
  // The one WebFinger document, spelled out here rather than imported from
28
32
  // wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
@@ -61,7 +65,7 @@ async function verifyPodToken(request, pathname, verifier) {
61
65
  // Solid-OIDC binds the token to a key the client proves on every request;
62
66
  // a token shown without the proof is one anyone who saw it could show.
63
67
  if (!dpop) { console.log(`front: pod token without a DPoP proof refused on ${pathname}`); return null; }
64
- const v = verifier || (await import('@solid/access-token-verifier')).createSolidTokenVerifier();
68
+ const v = verifier || await podTokenVerifier();
65
69
  const url = request.url;
66
70
  const { webid } = await v(authz, { header: dpop, method: request.method, url });
67
71
  return webid || null;
@@ -89,9 +93,13 @@ const podOwners = (podBase, fetchImpl = fetch) =>
89
93
  podRoot.readOwnerLinks(fetchImpl, podBase, { timeoutMs: OWNER_LOOKUP_MS });
90
94
 
91
95
  // Whether a token's WebID vouches for this pod, on the evidence of where it
92
- // lives. Weak on purpose, and never used alone any more — see provesPod.
96
+ // lives: the same origin, and — for a suffixed pod — inside
97
+ // the WebID's own pod, wherever in it the account was put. Weak on purpose,
98
+ // and never used alone for an attach — see provesPod.
93
99
  function webidUnderPod(webid, podHome) {
94
- try { return new URL(webid).origin === new URL(podHome).origin; } catch { return false; }
100
+ try {
101
+ return new URL(webid).origin === new URL(podHome).origin && String(podHome).startsWith(podBaseOfWebId(webid));
102
+ } catch { return false; }
95
103
  }
96
104
 
97
105
  /**
@@ -142,15 +150,8 @@ function podHomeProblem(podHome, frontOrigin) {
142
150
  }
143
151
 
144
152
  const RELAY_MAX_REQUESTS = 20;
145
- const RELAY_MAX_BODY = 1024 * 1024;
146
- const RELAY_TIMEOUT_MS = 8_000;
147
- // The only headers a relayed request may carry to the remote server. Host
148
- // comes from the URL; the user agent from safeFetch.
149
153
  const SERVER_PROBE_MS = 6000;
150
154
  const SERVER_PROBE_BYTES = 64 * 1024;
151
- const RELAY_HEADERS = new Set(['date', 'digest', 'signature', 'content-type', 'accept']);
152
-
153
- const keyIdOf = (signature) => (/keyId="([^"]+)"/.exec(signature || '') || [])[1] || null;
154
155
 
155
156
  // The owner's APIs a browser at ANOTHER origin may call: an address moving
156
157
  // to a new gateway is driven from the new gateway's page, and it has to tell
@@ -165,49 +166,11 @@ const API_CORS = {
165
166
  const withApiCors = (out) => ({ ...out, headers: { ...(out.headers || {}), ...API_CORS } });
166
167
  const apiPreflight = () => ({ status: 204, headers: { ...API_CORS, allow: 'POST, OPTIONS' }, body: null });
167
168
 
168
- async function relayOne(item, rec, fetchImpl) {
169
- const url = String(item?.url || '');
170
- const method = String(item?.method || 'POST').toUpperCase();
171
- if (method !== 'GET' && method !== 'POST') return { url, status: 0, error: 'method must be GET or POST' };
172
- let u;
173
- try { u = new URL(url); } catch { return { url, status: 0, error: 'not a URL' }; }
174
- if (u.protocol !== 'https:' && !(u.protocol === 'http:' && process.env.AP_ALLOW_PRIVATE_TARGETS === '1')) {
175
- return { url, status: 0, error: 'https only' };
176
- }
177
- const headers = {};
178
- for (const [k, v] of Object.entries(item?.headers || {})) {
179
- const name = k.toLowerCase();
180
- if (RELAY_HEADERS.has(name) && typeof v === 'string') headers[name] = v;
181
- }
182
- const body = method === 'POST' ? String(item?.body ?? '') : undefined;
183
- if (body !== undefined && Buffer.byteLength(body) > RELAY_MAX_BODY) return { url, status: 0, error: 'body too large' };
184
- const keyId = keyIdOf(headers.signature);
185
- if (method === 'POST' && !keyId) return { url, status: 0, error: 'a delivery must be signed' };
186
- if (keyId && !keyId.startsWith(rec.actorUrl + '#')) return { url, status: 0, error: "signed with a key that is not this account's" };
187
- if (headers.digest) {
188
- const want = 'SHA-256=' + crypto.createHash('sha256').update(body || '').digest('base64');
189
- if (headers.digest !== want) return { url, status: 0, error: 'digest does not match the body' };
190
- }
191
- try {
192
- const res = await safeFetch(url, { method, headers, body, signal: AbortSignal.timeout(RELAY_TIMEOUT_MS) }, fetchImpl);
193
- const out = { url, method, status: res.status };
194
- // The far server asking to be left alone has to reach the agent that will
195
- // do the asking again. Without this the browser build could not honour a
196
- // Retry-After at all — every delivery it makes goes through here — and fell
197
- // back to its own ladder against a server that had already said how long.
198
- const retryAfter = res.headers.get('retry-after');
199
- if (retryAfter) out.retryAfter = retryAfter;
200
- if (method === 'GET') {
201
- out.contentType = res.headers.get('content-type') || null;
202
- out.body = await readCapped(res, RELAY_MAX_BODY);
203
- }
204
- return out;
205
- } catch (e) { return { url, method, status: 0, error: e.message }; }
206
- }
207
-
208
169
  const j = (status, obj, ct = 'application/json') =>
209
170
  ({ status, headers: { 'content-type': ct, 'cache-control': 'no-store' }, body: JSON.stringify(obj) });
210
- const notFound = () => ({ status: 404, headers: { 'content-type': 'text/plain' }, body: 'not found\n' });
171
+ // Held at the edge like any other missing document: a bot's scan, a dead
172
+ // handle, a typo in a WebFinger query each cost one call, not one per asker.
173
+ const notFound = () => ({ status: 404, headers: { 'content-type': 'text/plain', ...publicFor(MISSING_EDGE_SECONDS) }, body: 'not found\n' });
211
174
 
212
175
  // A user's public base on the front, a 1:1 mirror of their pod home.
213
176
  // The directory key is the FULL fediverse address — handle@host — never the
@@ -243,7 +206,9 @@ function parseUserPath(pathname) {
243
206
  const BROWSER_MAX_AGE = 60;
244
207
  const publicFor = (seconds, stale = seconds * 4) => ({
245
208
  'cache-control': `public, max-age=${Math.min(seconds, BROWSER_MAX_AGE)}`,
246
- 'netlify-cdn-cache-control': `public, s-maxage=${seconds}, stale-while-revalidate=${stale}`,
209
+ // `durable`: one copy for every region, and one that a deploy does not
210
+ // empty. Without it each region held its own and every deploy started cold.
211
+ 'netlify-cdn-cache-control': `public, durable, s-maxage=${seconds}, stale-while-revalidate=${stale}`,
247
212
  });
248
213
  // How long the edge holds a public document, and a handle's WebFinger
249
214
  // answer. Every server that has heard of an account asks for its actor and
@@ -252,6 +217,15 @@ const publicFor = (seconds, stale = seconds * 4) => ({
252
217
  // can afford.
253
218
  const PUBLIC_EDGE_SECONDS = 600;
254
219
  const WEBFINGER_EDGE_SECONDS = 3600;
220
+ // A closed or moved address stays gone; a picture's address stays where it is.
221
+ const GONE_EDGE_SECONDS = 3600;
222
+ const MEDIA_EDGE_SECONDS = 86400;
223
+ // A public document the pod would not give (missing, or not public) is asked
224
+ // for again and again by every server that shows the account — a pinned-posts
225
+ // collection an old account never wrote, a forum count nobody published. Held
226
+ // briefly, so the edge absorbs the asking; briefly, so a document published a
227
+ // moment later is not "missing" for long.
228
+ const MISSING_EDGE_SECONDS = 120;
255
229
 
256
230
  // Where a pod owner opts their identity in. An operator may name it, because
257
231
  // the path it takes is one their pod server can no longer serve; the dot says
@@ -344,51 +318,6 @@ export async function routeFront(request, ctx) {
344
318
  return { ...out, headers: withSecurityHeaders(out.headers, out.body) };
345
319
  }
346
320
 
347
- // Every response this file makes, hardened in one place rather than in each of
348
- // the dozen shapes below.
349
- //
350
- // `nosniff` matters most: the front serves user-supplied JSON straight from
351
- // somebody's pod (the proxied actor and object documents), and without it a
352
- // browser is free to decide for itself that a document is HTML and run what is
353
- // inside it. The rest is the same posture the app already has — nothing may be
354
- // framed, no base tag may be rewritten, no plugin content.
355
- //
356
- // A content-security-policy goes on the HTML only: it would mean nothing on a
357
- // JSON document, and `frame-ancestors` has to be a header rather than a meta
358
- // tag anyway.
359
- //
360
- // `script-src 'self'` is the one that matters, and it is only possible because
361
- // none of these pages carries inline script any more — each has its own file and
362
- // its own route above. A policy cannot tell an inline block the author wrote
363
- // from one an attacker injected, so as long as any inline script has to run,
364
- // every inline script may.
365
- //
366
- // `connect-src` allows https: because the pages sign in against the user's own
367
- // pod, which is a different origin by definition and not one we can name here.
368
- function withSecurityHeaders(headers = {}, body = null) {
369
- const ct = String(headers['content-type'] || '');
370
- const isHtml = ct.startsWith('text/html');
371
- return {
372
- ...headers,
373
- 'x-content-type-options': 'nosniff',
374
- 'referrer-policy': 'same-origin',
375
- 'x-frame-options': 'SAMEORIGIN',
376
- ...(isHtml && body ? {
377
- 'content-security-policy': [
378
- "default-src 'self'",
379
- "script-src 'self'", // no inline script: see above
380
- "style-src 'self' 'unsafe-inline'",
381
- "img-src 'self' https: data:",
382
- "connect-src 'self' https:", // sign-in goes to the user's own pod
383
- "object-src 'none'", // no plugin content, ever
384
- "base-uri 'none'", // no rewriting where relative URLs resolve
385
- "frame-ancestors 'self'", // nobody else may frame these pages
386
- "form-action 'self'", // a form here submits here
387
- ].join('; '),
388
- } : {}),
389
- };
390
- }
391
-
392
321
  async function route(request, ctx) {
393
322
  const url = new URL(request.url);
394
323
  const { pathname } = url;
@@ -643,8 +572,8 @@ async function route(request, ctx) {
643
572
  }
644
573
 
645
574
  // The owner's say over an account that goes quiet (quiet.mjs).
646
- const quiet = await routeQuietApi(request, pathname, ctx, { j, verifyPodToken, webidUnderPod });
647
- if (quiet) return quiet;
575
+ const quiet = await routeQuietApi(request, pathname, ctx, { j, verifyPodToken, webidUnderPod, apiPreflight });
576
+ if (quiet) return withApiCors(quiet);
648
577
 
649
578
  // The relay: the front sends requests a browser has already signed. A page
650
579
  // may not set the Date or Host header, and both are inside an HTTP
@@ -669,7 +598,11 @@ async function route(request, ctx) {
669
598
  // Acting through the relay is being here. Hourly at most; see noteOpened.
670
599
  await noteOpened(ctx, handle, rec).catch((e) => console.log(`relay @${handle}: stamp not written: ${e?.message || e}`));
671
600
  if (items.length > RELAY_MAX_REQUESTS) return withApiCors(j(400, { error: `at most ${RELAY_MAX_REQUESTS} requests per call` }));
601
+ const slow = overLimit(handle, items.length) && j(429, { error: 'too many relayed requests — slow down' });
602
+ if (slow) return withApiCors({ ...slow, headers: { ...slow.headers, 'retry-after': '60' } });
672
603
  const results = await Promise.all(items.map((it) => relayOne(it, rec, ctx.fetchImpl || fetch)));
604
+ // Something of the account's withdrawn: the edge's copies go.
605
+ if (withdrawn(items, rec.actorUrl)) await ctx.purge?.([`u-${handle}`]).catch((e) => console.log(`purge @${handle}: ${e?.message || e}`));
673
606
  // One line per relayed request, so a lookup that fails on the far side is
674
607
  // visible here and not only as an empty result in someone's browser.
675
608
  for (const r of results) console.log(`relay @${handle}: ${r.method || ''} ${r.url} → ${r.status}${r.error ? ` (${r.error})` : ''}`);
@@ -754,7 +687,8 @@ async function route(request, ctx) {
754
687
  return j(403, { error: 'the token proves a different pod than the one you listed' });
755
688
  }
756
689
  if (action === 'opt-in') {
757
- const { httpStatus, ...reply } = await ctx.agentControl.optIn({ podBase, webId: webid });
690
+ const { httpStatus, ...reply } = await ctx.agentControl.optIn({ podBase, webId: webid,
691
+ container: String(body.container || ''), createIndex: body.createIndex === true });
758
692
  if (reply.doorSecret) {
759
693
  // The secret appears here and nowhere else; the command is the
760
694
  // paste-and-run way to open the owner door once.
@@ -808,7 +742,7 @@ async function route(request, ctx) {
808
742
  if (!rec) return notFound();
809
743
  // A closed address is gone, and says so rather than pretending never to
810
744
  // have existed: the name stays taken.
811
- if ((await closedState(ctx, m[1], rec)).closed) return closedAnswer({ 'access-control-allow-origin': '*' });
745
+ if ((await closedState(ctx, m[1], rec)).closed) return closedAnswer({ 'access-control-allow-origin': '*', ...publicFor(GONE_EDGE_SECONDS) });
812
746
  // A fronted identity's documents live on its pod; the pod's own actor id
813
747
  // is the alias, so a client signing in by the fronted address can find
814
748
  // the pod (and its login) without a lookup only the host could answer.
@@ -906,13 +840,28 @@ async function route(request, ctx) {
906
840
  if (!webid) return json(401, { error: 'a Solid-OIDC token proving this account\'s owner is required' });
907
841
  const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
908
842
  if (!owner) return json(403, { error: 'this outbox belongs to its owner alone' });
909
- const { status, reason, location } = await handleOwnerPost(request, identFor(rec),
910
- { podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), ownerWebId: webid });
843
+ const onPod = (u) => u.replace(rec.actorUrl.replace(/ap\/actor$/u, ''), rec.podHome);
844
+ const { status, reason, location, object } = await handleOwnerPost(request, identFor(rec),
845
+ { podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), ownerWebId: webid,
846
+ exists: async (u) => ((await (ctx.podGet || fetch)(onPod(u), { podHome: rec.podHome })).status !== 404) });
911
847
  console.log(`door @${up.handle}: owner post → ${status} (${reason})`);
912
- if (status !== 202) return json(status, { error: reason });
913
- return json(202, { accepted: true, ...(location ? { object: location } : {}),
848
+ if (status !== 201) return json(status, { error: reason });
849
+ return json(201, { accepted: true, ...(location ? { id: location } : {}), ...(object ? { object } : {}),
914
850
  note: 'it goes out when your FediPod agent next runs' }, location ? { location } : {});
915
851
  }
852
+ // Its owner, signed in, reads every message the account produced (§5.1:
853
+ // the outbox is filtered by who asks); that view is on the pod, under the
854
+ // owner's own rule. Anyone else reads the public one.
855
+ // The token is only read here, not verified: the redirect grants nothing,
856
+ // the pod checks the credential when the owner arrives there, and a
857
+ // verification per read would let anyone make the front fetch keys from a
858
+ // server they name.
859
+ if ((request.method === 'GET' || request.method === 'HEAD') && request.headers.get('authorization')) {
860
+ const webid = claimedWebId(request);
861
+ if (webid && (rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome))) {
862
+ return { status: 303, headers: { ...cors, location: rec.podHome + 'ap/private/outbox', 'cache-control': 'no-store' }, body: '' };
863
+ }
864
+ }
916
865
  if (rec.inboxOnly && (request.method === 'GET' || request.method === 'HEAD')) {
917
866
  return { status: 303, headers: { ...cors, location: rec.podHome + 'ap/outbox', 'cache-control': 'no-store' }, body: '' };
918
867
  }
@@ -935,14 +884,20 @@ async function route(request, ctx) {
935
884
  if (movedBase && up.rest !== 'ap/actor') {
936
885
  return { status: 301, headers: { ...open, location: movedBase + up.rest, 'cache-control': 'no-store' }, body: '' };
937
886
  }
938
- if (closed) return gone(open, CLOSED);
887
+ if (closed) return gone({ ...open, ...publicFor(GONE_EDGE_SECONDS) }, CLOSED);
939
888
  const podTarget = rec.podHome + up.rest;
889
+ // The owner's own documents — the full outbox, the liked list, the pending
890
+ // lists — are read at the pod, which decides who may; never held here,
891
+ // where one reader's answer would be served to the next.
892
+ if (up.rest.startsWith('ap/private/')) {
893
+ return { status: 303, headers: { ...open, location: podTarget, 'cache-control': 'no-store' }, body: '' };
894
+ }
940
895
  // Media stays on the pod (lib/pod/urls.mjs keeps `media` off the front), but
941
896
  // the id rewrite below turns media links onto the front like every other
942
897
  // pod url in a document. Answer those by pointing at the pod: bytes are not
943
898
  // a document to cap and relabel, and remotes follow a redirect for a picture.
944
899
  if (up.rest.startsWith('ap/media/')) {
945
- return { status: 302, headers: { location: podTarget, 'cache-control': 'no-store' }, body: '' };
900
+ return { status: 302, headers: { location: podTarget, ...publicFor(MEDIA_EDGE_SECONDS) }, body: '' };
946
901
  }
947
902
  // The pod this read belongs to travels with it: an adapter reading a store
948
903
  // directly (the CSS server component) has no access control of its own and
@@ -958,7 +913,11 @@ async function route(request, ctx) {
958
913
  const got = await podRoot.readPublicDocument(
959
914
  ctx.podGet || ((u) => fetch(u, { headers: { accept } })),
960
915
  podTarget, { podHome: rec.podHome, accept });
961
- if (got.text === null) return { status: got.status, headers: open, body: '' };
916
+ if (got.text === null) {
917
+ const hold = [401, 403, 404, 410].includes(got.status) ? { ...publicFor(MISSING_EDGE_SECONDS), 'netlify-cache-tag': `u-${up.handle}` } : {};
918
+ // A stranger is not told a document exists that they may not read (§3.2).
919
+ return { status: got.status === 401 || got.status === 403 ? 404 : got.status, headers: { ...open, ...hold }, body: '' };
920
+ }
962
921
  let text = got.text;
963
922
  text = swap(text, rec.podHome, base);
964
923
  // The moved stub: the pod's actor now carries the NEW gateway's ids, and a
@@ -973,6 +932,9 @@ async function route(request, ctx) {
973
932
  doc.preferredUsername = up.handle; // so @handle@front cross-checks
974
933
  doc.inbox = base + 'ap/inbox/'; // deliveries come to the front to be verified
975
934
  doc.endpoints = { ...(doc.endpoints || {}), sharedInbox: base + 'ap/inbox/' };
935
+ // The owner's own documents are read at the pod, where their credential
936
+ // works; they keep the pod's address.
937
+ for (const k of ['liked', 'ownerOutbox']) if (typeof doc[k] === 'string') doc[k] = doc[k].replace(base, rec.podHome);
976
938
  if (movedBase) {
977
939
  doc.movedTo = rec.movedTo;
978
940
  const aka = new Set([].concat(doc.alsoKnownAs || []).filter((a) => typeof a === 'string' && a !== doc.id));
@@ -988,9 +950,16 @@ async function route(request, ctx) {
988
950
  } catch { /* leave the rewritten text as-is if it will not parse */ }
989
951
  }
990
952
  // `readPublicDocument` asked for it with no credential, so a document that
991
- // came back is one anybody can read — which is what makes it cacheable.
992
- return { status: 200,
993
- headers: { ...open, 'content-type': got.type || (container ? 'text/turtle' : page ? 'text/html' : AP_CT), ...publicFor(PUBLIC_EDGE_SECONDS) },
953
+ // came back is one anybody can read — which is what makes it cacheable. A
954
+ // deleted post's Tombstone answers 410 Gone, and still says what it was.
955
+ let kind = null;
956
+ try { kind = JSON.parse(text)?.type; } catch { /* not JSON: a page or a container */ }
957
+ return { status: kind === 'Tombstone' ? 410 : 200,
958
+ headers: { ...open, 'content-type': got.type || (container ? 'text/turtle' : page ? 'text/html' : AP_CT), ...publicFor(PUBLIC_EDGE_SECONDS),
959
+ // The outbox answers its signed-in owner differently, so the edge keeps
960
+ // the public copy for those who show no credential.
961
+ ...(up.rest === 'ap/outbox' ? { 'netlify-vary': 'header=Authorization', vary: 'Authorization' } : {}),
962
+ 'netlify-cache-tag': `u-${up.handle}` },
994
963
  body: text };
995
964
  }
996
965