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
package/run-agent.mjs CHANGED
@@ -53,6 +53,7 @@ import { exposureProblem, hostLabel } from './lib/shared/guard.mjs';
53
53
  import { pendingSteps } from './lib/device/migrate.mjs';
54
54
  import { apUrls, assertionKeyId , publicHandle } from './lib/core/wire.mjs';
55
55
  import { followActor, unfollowActor, resolveHandle } from './lib/core/social.mjs';
56
+ import * as podInbox from './lib/pod/inbox.mjs';
56
57
 
57
58
  export class Agent {
58
59
  constructor({ home, log }) {
@@ -510,6 +511,19 @@ export class Agent {
510
511
  return true;
511
512
  }
512
513
 
514
+ // The inbox's door must be open to the world (or to the gateway alone, in
515
+ // locked mode) or nothing anyone sends ever lands. The permission is written
516
+ // at setup, and a container made again later — a root move, a pod rebuilt —
517
+ // came back without it: the group's inbox on fp2 refused every delivery for
518
+ // nine days while its agent ran on, healthy-looking. One write per start,
519
+ // and it is idempotent.
520
+ async ensureInboxOpen() {
521
+ const g = this.store.getConfig()?.gateway;
522
+ const posture = g?.mode === 'locked' && g.webId ? { gatewayWebId: g.webId } : 'open';
523
+ await podInbox.setPosture(this.remote, this.urls, posture);
524
+ return posture;
525
+ }
526
+
513
527
  // Read-only mode: refresh the state cache periodically, and take over the
514
528
  // moment the active agent's lease frees.
515
529
  startViewer() {
@@ -538,6 +552,8 @@ export class Agent {
538
552
  // Own posts the outbox names and the timeline index lacks come back here,
539
553
  // before anything acts on the index.
540
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}`); }
541
557
  this.lease.onLost = () => this.demote();
542
558
  this.lease.startRenewal();
543
559
  this.deliverer.startQueue();
@@ -607,6 +623,8 @@ export class Agent {
607
623
  .catch(e => this.log(`actor check failed: ${e.message}`));
608
624
  this.ensureFeaturedPublished()
609
625
  .catch(e => this.log(`featured check failed: ${e.message}`));
626
+ this.ensureInboxOpen()
627
+ .catch(e => this.log(`inbox door check failed: ${e.message}`));
610
628
  // The human page, rewritten only when what it shows has changed: one
611
629
  // local digest compare, and a write the first time after an upgrade.
612
630
  this.publisher.publishProfilePage()
@@ -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` });
package/web/app/agent.mjs CHANGED
@@ -6,7 +6,8 @@
6
6
  // the relay (deliver-relay). Everything between — wire, the store, the
7
7
  // publisher, intake, the Mastodon facade — is lib/, unchanged.
8
8
  import { kvGet, kvPut } from './idb-kv.mjs';
9
- import { apUrls } from '../../lib/core/wire.mjs';
9
+ import { apUrls, DEFAULT_ROOT } from '../../lib/core/wire.mjs';
10
+ import { findAccount } from '../../lib/core/place.mjs';
10
11
  import * as containers from '../../lib/pod/containers.mjs';
11
12
  import * as podState from '../../lib/pod/state.mjs';
12
13
  import { PodStore } from '../../lib/core/store.mjs';
@@ -169,15 +170,19 @@ export class BrowserAgent {
169
170
  this.lease.onLost = () => this.demote();
170
171
  this.lease.startRenewal();
171
172
  try {
172
- // Forced, not revalidated. While this device watched, the ACTIVE one was
173
- // writing; our cache is however stale the last viewer poll left it, and
174
- // the store is write-through — so the first write from here would push a
175
- // whole document back over newer state. Read what is actually there
176
- // before acting on it.
177
- await this.store.load({ force: true }).catch((e) => this.log(`re-reading state: ${e.message}`));
173
+ // Forced, not revalidated, when this device WATCHED first: the active
174
+ // one was writing, our cache is however stale the last viewer poll left
175
+ // it, and the store is write-through — so the first write from here
176
+ // would push a whole document back over newer state. A device that
177
+ // booted straight into acting read everything a moment ago; asking
178
+ // again is one revalidation, not a second download of every document.
179
+ await this.store.load({ force: !!this._watched }).catch((e) => this.log(`re-reading state: ${e.message}`));
180
+ this._watched = false;
178
181
  // Own posts the outbox names and the timeline index lacks come back
179
182
  // here, before anything acts on the index.
180
183
  await this.publisher.healStatuses().catch((e) => this.log(`healing the timeline index: ${e.message}`));
184
+ // Likes made before the liked list existed become its first entries.
185
+ try { this.publisher.backfillLiked(); } catch (e) { this.log(`liked list: ${e.message}`); }
181
186
  await this.publisher.publishProfilePage().catch((e) => this.log(`profile page: ${e.message}`));
182
187
  // And start delivering again, since demote() stopped it. startQueue() is
183
188
  // idempotent, so a goActive() that was already active costs nothing.
@@ -202,6 +207,7 @@ export class BrowserAgent {
202
207
  demote() {
203
208
  if (this.viewer) return;
204
209
  this.viewer = true;
210
+ this._watched = true;
205
211
  this.log('another device took over — read-only here');
206
212
  this.lease.stopRenewal();
207
213
  clearInterval(this._openTimer); this._openTimer = null;
@@ -286,8 +292,14 @@ export class BrowserAgent {
286
292
  // The pod session's own fetch, for the gateway APIs that take it as
287
293
  // proof of the pod (attach, move) — the transport below is for the pod.
288
294
  this.sessionFetch = session.fetch;
289
- const root = (config && config.root) || 'fedipod/';
290
295
  this.remote = new BrowserRemotePod(session, { webId, log: this.log });
296
+ // Where the account lives: handed in on sign-up, else what its owner's type
297
+ // index records, else `fedipod/` for an account older than the choice.
298
+ const root = (config && config.root)
299
+ || (await findAccount(this.remote, remotePod,
300
+ (r) => podState.readConfig(this.remote, { state: `${remotePod}${r}ap-state/` }),
301
+ (c) => (c.kind || 'person') === 'person').catch(() => null))?.root
302
+ || DEFAULT_ROOT;
291
303
  // Pod-native for now: the state store below is read with these, and only
292
304
  // the config it holds says whether this identity is fronted.
293
305
  this.urls = apUrls(remotePod, root);
@@ -355,8 +367,9 @@ export class BrowserAgent {
355
367
  // somebody reads from counting as a quiet one. Only a closed address
356
368
  // stops the boot; a gateway that cannot be reached is no reason to
357
369
  // refuse a sign-in.
358
- this.gatewayApi = null;
359
- try { if (config.gateway?.url) this.gatewayApi = `${new URL(config.gateway.url).origin}/api`; } catch { this.gatewayApi = null; }
370
+ // At the page's own origin, as the relay is: a page on the test alias
371
+ // asked the real site instead and could not get past its preflight.
372
+ this.gatewayApi = config.gateway?.url ? `${frontOrigin.replace(/\/$/, '')}/api` : null;
360
373
  this.doorKey = doorKeyOf(config.gateway?.url) || config.handle;
361
374
  this.gatewayStanding = null;
362
375
  const standing = await this.openAtGateway();
@@ -476,6 +489,7 @@ export class BrowserAgent {
476
489
  // The lease decides: this device ACTS on the pod, or reads it read-only.
477
490
  this.viewer = !(await this.lease.acquire());
478
491
  if (this.viewer) {
492
+ this._watched = true;
479
493
  this.log(`read-only viewer: another device is active on @${config.handle}`);
480
494
  this.startViewerPoll(); // reload the feed, and promote if the lease frees
481
495
  return;
package/web/app/boot.mjs CHANGED
@@ -14,7 +14,8 @@
14
14
  // fedipodMoveIn(answers) brings the address here.
15
15
  // Returning: fedipodSignin({ address }) — redirect to the pod's login.
16
16
  // On every load: fedipodOnLoad() — finish a redirect, or restore, then boot.
17
- import { signUp, moveIn, readAccount, handleProblem, AP_ROOT } from './signup.mjs';
17
+ import { signUp, moveIn, readAccount, handleProblem } from './signup.mjs';
18
+ import { podRootPath, chosenRoot } from '../../lib/core/place.mjs';
18
19
  import * as podActor from '../../lib/pod/actor.mjs';
19
20
  import * as podState from '../../lib/pod/state.mjs';
20
21
  import { podBaseOfWebId } from '../../lib/pod/urls.mjs';
@@ -76,16 +77,15 @@ async function bootWorker({ reset = false } = {}) {
76
77
  async function readAccountState() {
77
78
  const session = await getSession();
78
79
  if (!session) throw new Error('Sign in first.');
79
- const podFromWebId = podBaseOfWebId(session.webId); // a suffix-based host, or its own host
80
- const state = `${podFromWebId}${AP_ROOT}ap-state/`;
80
+ // Wherever its owner put it: the type index says, or it is an older account
81
+ // at `fedipod/` (signup.mjs readAccount).
82
+ const here = await readAccount(session);
83
+ if (!here) throw new Error(`could not find a FediPod account on ${podBaseOfWebId(session.webId)}`);
81
84
  const remote = new BrowserRemotePod(session, { webId: session.webId, role: 'signup', log: () => {} });
82
- const urls = { state };
83
- const [cfg, doc] = await Promise.all([
84
- podState.readConfig(remote, urls), podState.readKeys(remote, urls),
85
- ]);
86
- if (!cfg) throw new Error(`could not read this account's config under ${state}`);
87
- const actorUrl = `${cfg.remotePod}${cfg.root || AP_ROOT}ap/actor`;
88
- return { remote, urls, cfg, doc, actorUrl };
85
+ const urls = { state: `${here.pod}${here.root}ap-state/` };
86
+ const doc = await podState.readKeys(remote, urls);
87
+ const actorUrl = `${here.config.remotePod || here.pod}${here.config.root || here.root}ap/actor`;
88
+ return { remote, urls, cfg: here.config, doc, actorUrl };
89
89
  }
90
90
 
91
91
  window.fedipodUnlock = async (password) => {
@@ -161,22 +161,33 @@ export function parseAddress(input) {
161
161
  if (!handle || !host || !host.includes('.')) return null;
162
162
  return { handle, host };
163
163
  }
164
- async function issuerForPod(pod) {
164
+ async function issuerForActor(actorUrl) {
165
165
  // The pod's actor says where a client signs in (oauthAuthorizationEndpoint's
166
- // origin); failing that, the account provider is the pod host's parent
167
- // domain for a subdomain pod, and the host itself for a pod on a path.
166
+ // origin). Failing that: a host that answers OpenID discovery itself is the
167
+ // provider (a suffixed pod), and otherwise the provider is
168
+ // the pod host's parent domain (a subdomain pod).
168
169
  try {
169
- const authz = await podActor.readIssuer(`${pod}${AP_ROOT}ap/actor`);
170
+ const authz = await podActor.readIssuer(actorUrl);
170
171
  if (authz) return new URL(authz).origin;
171
172
  } catch { /* fall through */ }
172
- const u = new URL(pod);
173
- if (u.pathname !== '/') return u.origin;
173
+ const u = new URL(actorUrl);
174
+ const own = await fetch(`${u.origin}/.well-known/openid-configuration`, { headers: { accept: 'application/json' } }).catch(() => null);
175
+ if (own?.ok) return u.origin;
174
176
  const parent = u.host.split('.').slice(1).join('.');
175
177
  return `https://${parent || u.host}`;
176
178
  }
179
+ // An address on a pod names its actor through the pod host's own WebFinger;
180
+ // an older pod that does not answer is taken to keep it at `fedipod/`.
181
+ async function actorForPodAddress(handle, host) {
182
+ const res = await fetch(`https://${host}/.well-known/webfinger?resource=${encodeURIComponent(`acct:${handle}@${host}`)}`,
183
+ { headers: { accept: 'application/jrd+json, application/json' } }).catch(() => null);
184
+ const doc = res?.ok ? await res.json().catch(() => ({})) : {};
185
+ const self = (doc.links || []).find((l) => l.rel === 'self' && /activity\+json|ld\+json/u.test(l.type || ''));
186
+ return self?.href || `https://${host}/fedipod/ap/actor`;
187
+ }
177
188
  // An address at this site names a fronted identity. Its documents live on a
178
189
  // pod this site's WebFinger names as an alias — the pod's own actor id.
179
- async function podForFrontedAddress(handle) {
190
+ async function actorForFrontedAddress(handle) {
180
191
  const res = await fetch(`/.well-known/webfinger?resource=${encodeURIComponent(`acct:${handle}@${location.host}`)}`,
181
192
  { headers: { accept: 'application/jrd+json, application/json' } }).catch(() => null);
182
193
  if (res?.status === 410) throw new Error(`@${handle}@${location.host} is closed: nothing on the pod behind it was touched, but the address is gone for good.`);
@@ -184,19 +195,17 @@ async function podForFrontedAddress(handle) {
184
195
  const doc = await res.json().catch(() => ({}));
185
196
  const podActorId = (doc.aliases || []).find((a) => /\/ap\/actor$/u.test(String(a)));
186
197
  if (!podActorId) throw new Error(`@${handle}@${location.host} lives here but names no pod to sign in to`);
187
- const tail = `${AP_ROOT}ap/actor`;
188
- if (!podActorId.endsWith(tail)) throw new Error(`the pod actor ${podActorId} is not where a FediPod pod keeps one`);
189
- return podActorId.slice(0, -tail.length);
198
+ return podActorId;
190
199
  }
191
200
  window.fedipodSignin = async ({ address }) => {
192
201
  const parsed = parseAddress(address);
193
202
  if (!parsed) throw new Error('Enter your address as @you@yourpod (for example @alice@alice.solidcommunity.net).');
194
203
  const bad = handleProblem(parsed.handle);
195
204
  if (bad) throw new Error(bad);
196
- const pod = parsed.host === location.host.toLowerCase()
197
- ? await podForFrontedAddress(parsed.handle)
198
- : `https://${parsed.host}/`;
199
- const issuer = await issuerForPod(pod);
205
+ const actor = parsed.host === location.host.toLowerCase()
206
+ ? await actorForFrontedAddress(parsed.handle)
207
+ : await actorForPodAddress(parsed.handle, parsed.host);
208
+ const issuer = await issuerForActor(actor);
200
209
  const { authorizationUrl } = await beginLogin({ issuer, redirectUri: REDIRECT });
201
210
  location.href = authorizationUrl;
202
211
  };
@@ -445,6 +454,10 @@ if (typeof document !== 'undefined') (async () => {
445
454
  $('loading').hidden = true; $('hero').hidden = true; $('landing').hidden = true; $('running').hidden = true;
446
455
  $('brand').hidden = false; $('pane-form').hidden = false;
447
456
  $('signed-pod').textContent = signedPod;
457
+ // The container that holds `fedipod/`: the pod's own root unless they say
458
+ // otherwise. A move keeps the place the account already has.
459
+ $('container').placeholder = podRootPath(signedPod);
460
+ $('container-field').hidden = !!moveFrom;
448
461
  $('movein-note').hidden = !moveFrom;
449
462
  if (moveFrom) {
450
463
  $('movein-from').textContent = moveFrom.address;
@@ -484,7 +497,13 @@ if (typeof document !== 'undefined') (async () => {
484
497
  const pathPod = () => { try { return new URL(signedPod).pathname !== '/'; } catch { return false; } };
485
498
  // A move between gateways is an address at a gateway: no choice either.
486
499
  const shape = () => ((pathPod() || moveFrom) ? 'front' : f().shape.value);
487
- const answers = () => ({ handle: f().handle.value.trim().toLowerCase(), shape: shape() });
500
+ const answers = () => ({ handle: f().handle.value.trim().toLowerCase(), shape: shape(), container: f().container.value.trim() });
501
+ // Where the data will be, in full, as the person types.
502
+ const previewPlace = () => {
503
+ if (!signedPod) return;
504
+ const r = chosenRoot(signedPod, f().container.value);
505
+ $('place').textContent = r.problem ? '…' : signedPod + r.root;
506
+ };
488
507
  const previewAddr = () => {
489
508
  const handle = f().handle.value.trim().toLowerCase();
490
509
  let host = '';
@@ -507,7 +526,7 @@ if (typeof document !== 'undefined') (async () => {
507
526
  $('register-link').href = url || '#';
508
527
  $('register-link').textContent = providerHost() ? `Create your pod at ${providerHost()}` : 'Create your pod at your provider';
509
528
  };
510
- for (const el of $('form').elements) for (const evt of ['input', 'change']) el.addEventListener(evt, () => { applyMode(); applyShape(); previewAddr(); });
529
+ for (const el of $('form').elements) for (const evt of ['input', 'change']) el.addEventListener(evt, () => { applyMode(); applyShape(); previewAddr(); previewPlace(); });
511
530
  applyMode();
512
531
 
513
532
  // Step machine: one screen at a time, each gated by its own validation.
@@ -516,7 +535,7 @@ if (typeof document !== 'undefined') (async () => {
516
535
  const goStep = (n) => {
517
536
  STEP_IDS.forEach((id, i) => { $(id).hidden = i !== n - 1; });
518
537
  $('err-1').textContent = ''; $('form-error').textContent = '';
519
- if (n === 2) { applyShape(); previewAddr(); }
538
+ if (n === 2) { applyShape(); previewAddr(); previewPlace(); $('index-ask').hidden = true; }
520
539
  if (FOCUS[n]) $(FOCUS[n]).focus();
521
540
  };
522
541
  const validateStep1 = () => {
@@ -526,6 +545,10 @@ if (typeof document !== 'undefined') (async () => {
526
545
  const validateStep2 = () => {
527
546
  const hp = window.fedipodHandleProblem(f().handle.value.trim().toLowerCase());
528
547
  if (hp) return `Fediverse handle: ${hp}`;
548
+ if (!moveFrom) {
549
+ const where = chosenRoot(signedPod, f().container.value).problem;
550
+ if (where) return `Where to store it: ${where}`;
551
+ }
529
552
  return null;
530
553
  };
531
554
  $('create').addEventListener('click', showForm);
@@ -549,12 +572,12 @@ if (typeof document !== 'undefined') (async () => {
549
572
  $('run-retry').addEventListener('click', () => backToForm($('run-error').textContent));
550
573
  $('run-back').addEventListener('click', () => { showLanding(); goStep(1); });
551
574
 
552
- const LABELS = { pod: 'Checking your pod', keys: 'Making your signing key', gateway: 'Connecting your mail door' };
575
+ const LABELS = { pod: 'Checking your pod', keys: 'Making your signing key', gateway: 'Connecting your mail door',
576
+ place: 'Recording where your account lives' };
553
577
  const MOVE_LABELS = { pod: 'Reading your account on your pod', gateway: 'Taking your address here', keys: 'Moving your key and account record' };
554
- $('form').addEventListener('submit', async (e) => {
555
- e.preventDefault(); $('form-error').textContent = '';
556
- const e2 = validateStep2(); if (e2) { $('form-error').textContent = e2; goStep(2); return; }
557
- const a = answers();
578
+ // Run the setup. `createIndex` is the person's yes to a new public type index.
579
+ const run = async (createIndex = false) => {
580
+ const a = { ...answers(), ...(createIndex ? { createIndex: true } : {}) };
558
581
  $('pane-form').hidden = true; $('running').hidden = false;
559
582
  $('running-title').textContent = 'Setting up…'; $('run-error').textContent = ''; $('run-actions').hidden = true;
560
583
  const steps = $('steps'); steps.textContent = ''; const mark = {};
@@ -566,10 +589,27 @@ if (typeof document !== 'undefined') (async () => {
566
589
  location.href = '/admin/client/';
567
590
  }
568
591
  catch (err) {
592
+ // No type index: nothing has been written. Ask, on the form.
593
+ if (err?.code === 'needs-index') {
594
+ backToForm();
595
+ $('index-ask').hidden = false;
596
+ $('index-yes').focus();
597
+ return;
598
+ }
569
599
  $('running-title').textContent = 'Setup did not finish';
570
600
  $('run-error').textContent = err.message || String(err);
571
601
  $('run-actions').hidden = false; // the way out of the failure screen
572
602
  }
603
+ };
604
+ $('form').addEventListener('submit', async (e) => {
605
+ e.preventDefault(); $('form-error').textContent = '';
606
+ const e2 = validateStep2(); if (e2) { $('form-error').textContent = e2; goStep(2); return; }
607
+ await run();
608
+ });
609
+ $('index-yes').addEventListener('click', () => { $('index-ask').hidden = true; run(true); });
610
+ $('index-no').addEventListener('click', () => {
611
+ $('index-ask').hidden = true;
612
+ $('form-error').textContent = 'Sign-up stopped. Nothing was written to your pod.';
573
613
  });
574
614
 
575
615
  if (pendingIdentity) await showIdentity();
@@ -10,6 +10,9 @@
10
10
  import { Deliverer } from '../../lib/core/deliver.mjs';
11
11
  import { sign } from './shims/fedify-sig.mjs';
12
12
 
13
+ // What one relay call may carry (lib/gateway/front-core.mjs RELAY_MAX_REQUESTS).
14
+ const RELAY_MAX_REQUESTS = 20;
15
+
13
16
  /**
14
17
  * The name the front keys this account's row by, read off its door inbox:
15
18
  * `<front>/u/<key>/ap/inbox/`. A mail-door account is keyed by its full
@@ -30,6 +33,7 @@ export class RelayDeliverer extends Deliverer {
30
33
  this.relayUrl = opts.relayUrl; // <front>/api/relay
31
34
  this.handle = opts.handle;
32
35
  this.sessionFetch = opts.sessionFetch; // the DPoP session's fetch, to authenticate to the relay
36
+ this.batchSize = RELAY_MAX_REQUESTS;
33
37
  }
34
38
 
35
39
  // Same contract as Deliverer.signedFetch, DEFAULT INCLUDED: an init with no
@@ -40,32 +44,68 @@ export class RelayDeliverer extends Deliverer {
40
44
  // never saw the document it asked for: a Follow from anyone new was rejected
41
45
  // with "actor fetch failed", and nothing needing a lookup could be ingested.
42
46
  async signedFetch(url, init = {}) {
47
+ const req = await this._signedRequest(url, init);
48
+ const [r0] = await this._relay([req]);
49
+ return this._outcome(r0, url, init.method || 'GET');
50
+ }
51
+
52
+ // A fan-out in one call: the relay takes a list, so a post to twenty
53
+ // followers is one call, not twenty (Deliverer.deliverToAll, batchSize).
54
+ async deliverManyNow(targets) {
55
+ const reqs = await Promise.all(targets.map((t) => this._signedRequest(t.inbox, {
56
+ method: 'POST', headers: { 'content-type': 'application/activity+json' }, body: JSON.stringify(t.activity),
57
+ })));
58
+ let results;
59
+ try { results = await this._relay(reqs); } catch (error) { return targets.map(() => ({ error })); }
60
+ return targets.map((t, i) => {
61
+ try { this._outcome(results[i] || {}, t.inbox, 'POST'); return { ok: true }; }
62
+ catch (error) { return { error }; }
63
+ });
64
+ }
65
+
66
+ // Signed here, sent verbatim by the relay. Every signed header goes along,
67
+ // `accept` included: the signature covers it, so a relay request missing it
68
+ // carries an invalid signature — and a read without it gets the HTML page
69
+ // instead of the document.
70
+ async _signedRequest(url, init = {}) {
43
71
  const body = typeof init.body === 'string' ? init.body : (init.body ? new TextDecoder().decode(init.body) : '');
44
72
  const s = await sign({ url, method: init.method || 'GET', headers: init.headers || {}, body }, this.rsaPrivate, this.keyId);
45
- // Every signed header goes to the relay, `accept` included: the signature
46
- // covers it, so a relay request missing it carries an invalid signature —
47
- // and a read without it gets the HTML page instead of the document.
48
- const relayReq = {
73
+ return {
49
74
  url: s.url, method: s.method, body,
50
75
  headers: {
51
76
  date: s.headers.date, digest: s.headers.digest, accept: s.headers.accept,
52
77
  'content-type': s.headers['content-type'], signature: s.headers.signature,
53
78
  },
54
79
  };
80
+ }
81
+
82
+ // One relay call for a list of requests; the results in the same order.
83
+ // The relay's OWN answer, apart from the recipients': unreachable and a
84
+ // refusal are hiccups the queue retries. Its 404 is not — it says this
85
+ // account has no row here, and no retry changes that. It used to be read as
86
+ // a hiccup too, and a tab whose account the site did not know retried its
87
+ // deliveries every minute for three days.
88
+ async _relay(requests) {
55
89
  let res;
56
90
  try {
57
91
  res = await this.sessionFetch(this.relayUrl, {
58
92
  method: 'POST', headers: { 'content-type': 'application/json' },
59
- body: JSON.stringify({ handle: this.handle, requests: [relayReq] }),
93
+ body: JSON.stringify({ handle: this.handle, requests }),
60
94
  });
61
95
  } catch (e) { const err = new Error(`relay unreachable: ${e.message}`); err.status = 0; throw err; }
96
+ if (res.status === 404) { const err = new Error('relay: no such account here'); err.status = 404; throw err; }
62
97
  if (res.status >= 400) { const err = new Error(`relay ${res.status}`); err.status = 502; throw err; }
63
98
  const out = await res.json().catch(() => ({}));
64
- const r0 = (out.results && out.results[0]) || {};
99
+ return Array.isArray(out.results) ? out.results : [];
100
+ }
101
+
102
+ // What the far server answered, as the Node deliverer would have seen it:
103
+ // a Response for a read, a thrown error carrying the status for a refusal.
104
+ _outcome(r0, url, method) {
65
105
  const status = r0.status || 0;
66
106
  if (status === 0) { const err = new Error(r0.error || 'relay could not send'); err.status = 502; throw err; }
67
107
  if (status >= 400) {
68
- const err = new Error(`${init.method || 'POST'} ${url} → ${status}`); err.status = status;
108
+ const err = new Error(`${method} ${url} → ${status}`); err.status = status;
69
109
  // The receiving server's own answer to "when should I try again", carried
70
110
  // through the relay (lib/front-core.mjs). Spelled `retryAfterMs`, which is
71
111
  // what the delivery queue reads (lib/deliver.mjs) — the queue's ladder is