fedipod 1.36.6 → 1.40.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 (94) hide show
  1. package/README.md +29 -8
  2. package/architecture.md +8 -0
  3. package/bin/fedipod.mjs +6 -0
  4. package/cli.md +7 -1
  5. package/device-agent.md +3 -3
  6. package/gateway.md +69 -8
  7. package/groups.md +2 -1
  8. package/gui.md +3 -1
  9. package/lib/client/c2s.mjs +97 -43
  10. package/lib/client/masto/accounts.mjs +1 -0
  11. package/lib/client/masto/bridge.mjs +61 -0
  12. package/lib/client/masto/index.mjs +4 -1
  13. package/lib/client/masto/oauth.mjs +1 -1
  14. package/lib/client/masto/statuses.mjs +3 -0
  15. package/lib/connections/acctfeed.mjs +13 -9
  16. package/lib/connections/bskyfeed.mjs +13 -9
  17. package/lib/connections/tagfeed.mjs +14 -9
  18. package/lib/core/deliver.mjs +17 -1
  19. package/lib/core/intake/activities.mjs +18 -2
  20. package/lib/core/intake/group.mjs +3 -1
  21. package/lib/core/intake/index.mjs +226 -48
  22. package/lib/core/intake/notes.mjs +12 -5
  23. package/lib/core/intake/verify.mjs +11 -0
  24. package/lib/core/lease.mjs +15 -1
  25. package/lib/core/place.mjs +82 -0
  26. package/lib/core/pod-only.mjs +4 -0
  27. package/lib/core/publisher/collections.mjs +37 -2
  28. package/lib/core/publisher/index.mjs +25 -1
  29. package/lib/core/publisher/notes.mjs +73 -7
  30. package/lib/core/publisher/own.mjs +143 -0
  31. package/lib/core/publisher/questions.mjs +5 -3
  32. package/lib/core/scheduled.mjs +39 -0
  33. package/lib/core/social.mjs +62 -32
  34. package/lib/core/storage.mjs +67 -0
  35. package/lib/core/store.mjs +77 -5
  36. package/lib/core/wire.mjs +51 -15
  37. package/lib/device/admin/routes/lifecycle.mjs +1 -1
  38. package/lib/device/admin/routes/setup.mjs +17 -1
  39. package/lib/device/cli/commands/setup.mjs +50 -8
  40. package/lib/device/cli/context.mjs +1 -1
  41. package/lib/device/migrate.mjs +1 -1
  42. package/lib/device/setup.mjs +45 -8
  43. package/lib/gateway/account-agent.mjs +111 -0
  44. package/lib/gateway/copy.mjs +381 -0
  45. package/lib/gateway/front-core.mjs +74 -63
  46. package/lib/gateway/gateway-core.mjs +107 -9
  47. package/lib/gateway/held-mail.mjs +198 -0
  48. package/lib/gateway/keeper-due.mjs +39 -0
  49. package/lib/gateway/keeper-session.mjs +10 -0
  50. package/lib/gateway/keeper.mjs +72 -0
  51. package/lib/gateway/masto-gateway.mjs +510 -0
  52. package/lib/gateway/quiet.mjs +7 -2
  53. package/lib/gateway/relay-extras.mjs +89 -0
  54. package/lib/gateway/state-api.mjs +207 -0
  55. package/lib/gateway/token-claims.mjs +16 -0
  56. package/lib/pod/containers.mjs +17 -0
  57. package/lib/pod/location.mjs +52 -0
  58. package/lib/pod/notes.mjs +2 -4
  59. package/lib/pod/transport.mjs +214 -24
  60. package/lib/pod/type-index.mjs +101 -0
  61. package/lib/pod/urls.mjs +6 -0
  62. package/lib/server/embed.mjs +7 -6
  63. package/lib/session/README.md +5 -5
  64. package/lib/session/demo.html +1 -1
  65. package/lib/session/fedi-account.mjs +19 -10
  66. package/lib/session/package.json +2 -2
  67. package/package.json +2 -2
  68. package/run-agent.mjs +6 -16
  69. package/scripts/stage-site.mjs +17 -4
  70. package/web/admin/actors.js +2 -0
  71. package/web/admin/gateway.js +14 -1
  72. package/web/admin/index.html +13 -0
  73. package/web/admin/oauth-signin.mjs +1 -1
  74. package/web/admin/record.js +4 -1
  75. package/web/admin/setup/index.html +17 -1
  76. package/web/admin/setup/setup.js +18 -5
  77. package/web/app/README.md +2 -2
  78. package/web/app/admin-facade.mjs +12 -2
  79. package/web/app/agent.mjs +228 -51
  80. package/web/app/boot.mjs +75 -32
  81. package/web/app/copy-mode.mjs +223 -0
  82. package/web/app/dist/boot.js +498 -88
  83. package/web/app/dist/boot.js.map +4 -4
  84. package/web/app/dist/sw.js +4016 -2539
  85. package/web/app/dist/sw.js.map +4 -4
  86. package/web/app/index.html +16 -0
  87. package/web/app/signup.mjs +65 -26
  88. package/web/app/sw-src.mjs +15 -53
  89. package/web/app/update.js +3 -2
  90. package/web/app/warm-start.mjs +115 -0
  91. package/web/app-signin/app-signin.mjs +78 -0
  92. package/web/app-signin/index.html +41 -0
  93. package/web/front/run.html +7 -1
  94. package/web/front/run.js +30 -4
@@ -191,6 +191,13 @@ a { color: var(--accent); }
191
191
  <span id="signed-pod-label" class="grouplabel">Your pod</span>
192
192
  <p id="signed-pod" role="status" aria-labelledby="signed-pod-label"></p>
193
193
 
194
+ <div id="container-field">
195
+ <label for="container">Store your data in a container named fedipod, inside this container</label>
196
+ <input type="text" id="container" name="container" autocomplete="off" spellcheck="false"
197
+ aria-describedby="container-hint">
198
+ <p class="hint" id="container-hint">Your data will be at <span id="place">…</span>. This is permanent.</p>
199
+ </div>
200
+
194
201
  <!-- Shown when the pod's account lives at another gateway: this is a
195
202
  move, not a new account. -->
196
203
  <p id="movein-note" hidden>Moving <strong id="movein-from"></strong> to this site.
@@ -217,6 +224,15 @@ a { color: var(--accent); }
217
224
  <p class="hint">The handle and the pod cannot be changed afterwards. Everything else — display
218
225
  name, bio, pictures — you set later in the client.</p>
219
226
  <p class="err" id="form-error" role="alert"></p>
227
+ <div id="index-ask" role="alertdialog" aria-labelledby="index-ask-text" hidden>
228
+ <p id="index-ask-text">Your pod has no public type index, the list Solid apps use to find your
229
+ things. FediPod needs one to record where your account lives. Create it? It will be readable
230
+ by anyone, like your profile.</p>
231
+ <p>
232
+ <button type="button" id="index-no">No, stop</button>
233
+ <button type="button" class="primary" id="index-yes">Create it and continue</button>
234
+ </p>
235
+ </div>
220
236
  <p>
221
237
  <button type="button" id="back-1">Back</button>
222
238
  <button type="submit" class="primary" id="submit">Create your Fediverse account</button>
@@ -16,14 +16,18 @@ import * as podState from '../../lib/pod/state.mjs';
16
16
  import { resourceExists } from '../../lib/pod/root.mjs';
17
17
  import { podBaseOfWebId } from '../../lib/pod/urls.mjs';
18
18
  import { cacheOpenedKeys } from './keys-browser.mjs';
19
+ import { chosenRoot, findAccount, recordPlace, hasPublicIndex } from '../../lib/core/place.mjs';
19
20
 
20
- // The container everything the agent publishes hangs under. New pods made here
21
- // use `fedipod/`; new installs default to `fedipod/` too, and older ones keep
22
- // that predate this, so those pods are untouched. The name is stored on the
23
- // config, so the agent reads it rather than guessing.
24
- export const AP_ROOT = 'fedipod/';
25
- const actorUrlFor = (pod) => `${pod}${AP_ROOT}ap/actor`;
26
- const keysDocFor = (pod) => `${pod}${AP_ROOT}ap-state/keys.json`;
21
+ // Everything the agent publishes hangs under a container named `fedipod`, in
22
+ // the container its owner chose (lib/core/place.mjs). The root is stored on the
23
+ // config, so the agent reads it rather than guessing, and the place is recorded
24
+ // in the owner's public type index, which is how the account is found again.
25
+ const actorUrlFor = (pod, root) => `${pod}${root}ap/actor`;
26
+ const keysDocFor = (pod, root) => `${pod}${root}ap-state/keys.json`;
27
+ const stateOf = (pod, root) => `${pod}${root}ap-state/`;
28
+
29
+ /** The account's config as it sits under `root`, read as its owner. */
30
+ const readConfigAt = (remote, pod) => (root) => podState.readConfig(remote, { state: stateOf(pod, root) });
27
31
 
28
32
  const HANDLE_RE = /^[a-z0-9-]{2,30}$/;
29
33
 
@@ -42,7 +46,7 @@ export function handleProblem(handle) {
42
46
  // identity being built, so changing the handle on the form starts a clean
43
47
  // attempt.
44
48
  const PROGRESS = new Map();
45
- const progressKey = (webId, a) => [webId, a.handle, a.shape || 'pod'].join('|');
49
+ const progressKey = (webId, a) => [webId, a.handle, a.shape || 'pod', a.container || ''].join('|');
46
50
 
47
51
  /** A fronted name is one per gateway; a taken one is refused before anything is made. */
48
52
  async function assertFrontNameFree(frontOrigin, handle) {
@@ -58,7 +62,12 @@ async function assertFrontNameFree(frontOrigin, handle) {
58
62
  * note) so a page can draw the same tick list the CLI setup shows. Resumable:
59
63
  * see PROGRESS above.
60
64
  *
61
- * answers: { handle, shape?: 'pod'|'front', gateway? }
65
+ * answers: { handle, shape?: 'pod'|'front', gateway?, container?, createIndex? }
66
+ * container the path of the container that holds `fedipod/` (the pod root
67
+ * when empty)
68
+ * createIndex the person's yes to making a public type index when their
69
+ * profile names none; without it, sign-up stops before writing
70
+ * anything and says so (error code 'needs-index')
62
71
  * session: { webId, issuer, fetch } — the Solid-OIDC session from the pod's
63
72
  * own login (oidc-session.mjs). The pod is where the WebID lives.
64
73
  *
@@ -91,11 +100,27 @@ export async function signUp(answers, { session, onStep = () => {}, frontOrigin
91
100
  skip: (note) => onStep(key, 'skipped', note),
92
101
  });
93
102
 
103
+ const place = chosenRoot(pod, answers.container);
104
+ if (place.problem) throw new Error(`Where to store it: ${place.problem}.`);
105
+ const root = place.root;
106
+ const remote = new BrowserRemotePod(session, { webId, role: 'signup', log: () => {} });
107
+
94
108
  // --- the pod --- (the session came from it; what is checked is that it has no account yet)
95
109
  const podStep = step('pod');
96
110
  if (!prog.pod) {
97
111
  podStep.running('checking your pod');
98
- if (await resourceExists(session.fetch, actorUrlFor(pod))) throw new Error('The pod already hosts a FediPod account. If you want a second account, put it on a different pod.');
112
+ if (await findAccount(remote, pod, readConfigAt(remote, pod))
113
+ || await resourceExists(session.fetch, actorUrlFor(pod, root))) {
114
+ throw new Error('The pod already hosts a FediPod account. If you want a second account, put it on a different pod.');
115
+ }
116
+ if (await resourceExists(session.fetch, `${pod}${root}`)) {
117
+ throw new Error(`${new URL(pod + root).pathname} is already there and is not a FediPod account — choose another container.`);
118
+ }
119
+ // Where the account lives is recorded in the public type index. With none,
120
+ // the person is asked first; nothing is written before they have said yes.
121
+ if (!answers.createIndex && !await hasPublicIndex(remote, pod)) {
122
+ throw Object.assign(new Error('Your profile names no public type index.'), { code: 'needs-index' });
123
+ }
99
124
  prog.pod = pod;
100
125
  podStep.ok(pod);
101
126
  } else {
@@ -108,11 +133,11 @@ export async function signUp(answers, { session, onStep = () => {}, frontOrigin
108
133
  const pathPod = new URL(pod).pathname !== '/';
109
134
  const fronted = pathPod || wantsFront;
110
135
  if (fronted && !frontOrigin) {
111
- throw new Error(`${pod} is a suffix-based host, so its address must live at a gateway, and this page has none.`);
136
+ throw new Error(`${pod} is a suffixed pod, so its address must live at a gateway, and this page has none.`);
112
137
  }
113
138
  if (pathPod && !wantsFront) await assertFrontNameFree(frontOrigin, handle);
114
139
 
115
- const actorUrl = actorUrlFor(pod); // where the documents live, always
140
+ const actorUrl = actorUrlFor(pod, root); // where the documents live, always
116
141
  const frontActor = fronted ? `${frontOrigin.replace(/\/$/, '')}/u/${handle}/ap/actor` : null;
117
142
 
118
143
  // Connect the gateway (fedipod.net) before the key is made, because a
@@ -133,7 +158,7 @@ export async function signUp(answers, { session, onStep = () => {}, frontOrigin
133
158
  // bare pod root sends this identity's mail to <pod>/ap/inbox/ — outside
134
159
  // the container the agent drains, where nothing would ever read it. The
135
160
  // manage surface has always sent `urls.home`; this is the same value.
136
- body: JSON.stringify({ handle, podHome: `${pod}${AP_ROOT}`, actorUrl, kind: 'person', fronted }),
161
+ body: JSON.stringify({ handle, podHome: `${pod}${root}`, actorUrl, kind: 'person', fronted }),
137
162
  });
138
163
  const d = await res.json().catch(() => ({}));
139
164
  if (res.status !== 201 || !d.hmacSecret) {
@@ -163,7 +188,6 @@ export async function signUp(answers, { session, onStep = () => {}, frontOrigin
163
188
  // The opened copy is kept in this browser (IndexedDB) so the worker can boot
164
189
  // itself after an idle kill with no read. A browser that has no copy reads
165
190
  // the pod's with its session — see keys-browser.mjs.
166
- const remote = new BrowserRemotePod(session, { webId, role: 'signup', log: () => {} });
167
191
  const keysStep = step('keys');
168
192
  let keys;
169
193
  if (!prog.keysStored) {
@@ -177,13 +201,13 @@ export async function signUp(answers, { session, onStep = () => {}, frontOrigin
177
201
  // pod this key may sit on.
178
202
  try {
179
203
  await podState.provisionKey(remote, {
180
- stateUrl: `${pod}${AP_ROOT}ap-state/`,
181
- keysUrl: keysDocFor(pod),
204
+ stateUrl: stateOf(pod, root),
205
+ keysUrl: keysDocFor(pod, root),
182
206
  keys,
183
207
  });
184
208
  } catch (e) {
185
209
  throw new Error(`could not store the signing key on the pod (${e.message}). `
186
- + `You are signed in as ${webId} — that WebID must own ${pod} and its ${AP_ROOT} must be writable by it.`);
210
+ + `You are signed in as ${webId} — that WebID must own ${pod} and ${root} in it must be writable by it.`);
187
211
  }
188
212
  // This browser's own opened copy, so the boot that follows needs no read.
189
213
  // Best effort: a browser that refuses IndexedDB (private mode) reads the
@@ -197,7 +221,7 @@ export async function signUp(answers, { session, onStep = () => {}, frontOrigin
197
221
  }
198
222
 
199
223
  const config = {
200
- remotePod: pod, root: AP_ROOT, handle, name: handle, issuer: String(session.issuer || '').replace(/\/+$/, ''),
224
+ remotePod: pod, root, handle, name: handle, issuer: String(session.issuer || '').replace(/\/+$/, ''),
201
225
  createdAt: new Date().toISOString(),
202
226
  ...(gateway ? { gateway } : {}),
203
227
  };
@@ -205,11 +229,24 @@ export async function signUp(answers, { session, onStep = () => {}, frontOrigin
205
229
  // sign-in — which arrives with only an OIDC session — can read the account's
206
230
  // config and key from the pod and boot.
207
231
  try {
208
- await podState.writeConfig(remote, { state: `${pod}${AP_ROOT}ap-state/` }, config);
232
+ await podState.writeConfig(remote, { state: stateOf(pod, root) }, config);
209
233
  } catch (e) {
210
234
  throw new Error(`could not store the config on the pod (${e.message})`);
211
235
  }
212
236
 
237
+ // --- where it lives, in the public type index ---
238
+ if (!prog.placed) {
239
+ const placeStep = step('place');
240
+ placeStep.running('recording where your account lives');
241
+ try {
242
+ await recordPlace(remote, pod, actorUrl, { create: !!answers.createIndex });
243
+ } catch (e) {
244
+ throw new Error(`could not record where your account lives in your type index (${e.message})`);
245
+ }
246
+ prog.placed = true;
247
+ placeStep.ok();
248
+ }
249
+
213
250
  PROGRESS.delete(key); // finished — nothing left to resume
214
251
 
215
252
  const host = gateway?.frontActor ? new URL(gateway.frontActor).host : new URL(pod).host;
@@ -232,14 +269,16 @@ export async function signUp(answers, { session, onStep = () => {}, frontOrigin
232
269
  export async function readAccount(session) {
233
270
  const pod = podBaseOfWebId(session.webId);
234
271
  const remote = new BrowserRemotePod(session, { webId: session.webId, role: 'signup', log: () => {} });
235
- const config = await podState.readConfig(remote, { state: `${pod}${AP_ROOT}ap-state/` }).catch(() => null);
236
- if (!config) return null;
272
+ // A person account: the browser build is persons only.
273
+ const found = await findAccount(remote, pod, readConfigAt(remote, pod), (c) => (c.kind || 'person') === 'person');
274
+ if (!found) return null;
275
+ const { root, config } = found;
237
276
  const frontActor = config.gateway?.frontActor || null;
238
277
  let frontHost = null;
239
278
  try { frontHost = frontActor ? new URL(frontActor).host : null; } catch { frontHost = null; }
240
279
  let podHost = '';
241
280
  try { podHost = new URL(config.remotePod || pod).host; } catch { podHost = ''; }
242
- return { pod, config, frontActor, frontHost, address: `@${config.handle}@${frontHost || podHost}` };
281
+ return { pod, root, config, frontActor, frontHost, address: `@${config.handle}@${frontHost || podHost}` };
243
282
  }
244
283
 
245
284
  /**
@@ -268,9 +307,9 @@ export async function moveIn(answers, { session, onStep = () => {}, frontOrigin
268
307
  const here = await readAccount(session);
269
308
  if (!here?.frontActor) throw new Error('this pod holds no account at another gateway to move here');
270
309
  if (here.frontHost === new URL(origin).host) throw new Error('this account already lives at this gateway');
271
- const { pod, config: old } = here;
310
+ const { pod, root, config: old } = here;
272
311
  const remote = new BrowserRemotePod(session, { webId: session.webId, role: 'signup', log: () => {} });
273
- const urls = { state: `${pod}${AP_ROOT}ap-state/` };
312
+ const urls = { state: stateOf(pod, root) };
274
313
  const keys = await podState.readKeys(remote, urls);
275
314
  if (!keys?.rsa) throw new Error('could not read this account\'s signing key on the pod');
276
315
  if (isKeyEnvelope(keys)) throw new Error(`this account's key is still under a password from an earlier version — sign in once at ${here.frontHost} first, then come back`);
@@ -282,7 +321,7 @@ export async function moveIn(answers, { session, onStep = () => {}, frontOrigin
282
321
  await assertFrontNameFree(origin, handle);
283
322
  const res = await session.fetch(`${origin}/api/attach`, {
284
323
  method: 'POST', headers: { 'content-type': 'application/json' },
285
- body: JSON.stringify({ handle, podHome: `${pod}${AP_ROOT}`, actorUrl: actorUrlFor(pod), kind: old.kind || 'person', fronted: true }),
324
+ body: JSON.stringify({ handle, podHome: `${pod}${root}`, actorUrl: actorUrlFor(pod, root), kind: old.kind || 'person', fronted: true }),
286
325
  });
287
326
  const d = await res.json().catch(() => ({}));
288
327
  if (res.status !== 201 || !d.hmacSecret) throw new Error(`could not attach to this gateway (HTTP ${res.status}): ${d.error || ''}`);
@@ -311,7 +350,7 @@ export async function moveIn(answers, { session, onStep = () => {}, frontOrigin
311
350
  } catch (e) {
312
351
  throw new Error(`could not update your account on the pod (${e.message})`);
313
352
  }
314
- await cacheOpenedKeys(actorUrlFor(pod), keys);
353
+ await cacheOpenedKeys(actorUrlFor(pod, root), keys);
315
354
  keysStep.ok();
316
355
 
317
356
  return { config, address: `@${handle}@${new URL(origin).host}`, movedFrom: config.movedFrom };
@@ -9,6 +9,7 @@
9
9
  import { BrowserAgent } from './agent.mjs';
10
10
  import { getSession } from './oidc-session.mjs';
11
11
  import { ADMIN_PATHS } from './admin-facade.mjs';
12
+ import { bridge } from '../../lib/client/masto/bridge.mjs';
12
13
 
13
14
  let agent = null;
14
15
  let booting = null;
@@ -105,17 +106,19 @@ self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
105
106
  // browsers kill an idle service worker and restart it fresh, and the client at
106
107
  // /app/ never posts 'boot' — so without on-demand boot every request after a
107
108
  // restart 503s. On a boot failure `booting` is cleared so the next request retries.
108
- async function bootFromSession(frontOrigin) {
109
+ async function bootFromSession(frontOrigin, restart) {
109
110
  const oidc = await getSession();
110
111
  if (!oidc) throw new Error('no session — sign in first');
111
112
  const a = new BrowserAgent({ log: (m) => console.log('[sw-agent]', m) });
112
- await a.boot({ oidc, frontOrigin: frontOrigin || self.location.origin });
113
+ await a.boot({ oidc, frontOrigin: frontOrigin || self.location.origin, restart });
113
114
  agent = a;
114
115
  return a;
115
116
  }
116
- function ensureBooting(frontOrigin) {
117
+ // `restart`: booting on demand after the browser killed the worker, not
118
+ // because the owner just signed in.
119
+ function ensureBooting(frontOrigin, { restart = false } = {}) {
117
120
  if (agent) return Promise.resolve(agent);
118
- if (!booting) booting = bootFromSession(frontOrigin).catch((err) => { booting = null; throw err; });
121
+ if (!booting) booting = bootFromSession(frontOrigin, restart).catch((err) => { booting = null; throw err; });
119
122
  return booting;
120
123
  }
121
124
 
@@ -144,6 +147,10 @@ self.addEventListener('message', (e) => {
144
147
  self.addEventListener('fetch', (e) => {
145
148
  const url = new URL(e.request.url);
146
149
  if (url.origin !== self.location.origin || !(isFacade(url.pathname) || isAdmin(url.pathname))) return; // static → network
150
+ // A Mastodon app somewhere else signing in at this address, or the pod
151
+ // sending its owner back to that sign-in: the gateway's, not this browser's
152
+ // agent, whose own client signs in from a page on this origin.
153
+ if (url.pathname.startsWith('/oauth/') && !sameOriginReferrer(e.request)) return;
147
154
  e.respondWith(serve(e.request, url));
148
155
  });
149
156
 
@@ -156,63 +163,18 @@ async function serve(request, url) {
156
163
  }
157
164
  // Boot on demand from the stored session, so the client works even when the
158
165
  // worker was restarted (idle-killed) and nobody posted 'boot' this time.
159
- if (!agent) { try { await ensureBooting(); } catch { /* no session → 503 below */ } }
166
+ if (!agent) { try { await ensureBooting(undefined, { restart: true }); } catch { /* no session → 503 below */ } }
160
167
  if (!agent) return json(503, { error: 'not signed in on this browser — sign in at the front page' });
161
- // Bytes, not text. A multipart upload is binary — read as text it comes back
162
- // through a UTF-8 round trip that replaces every byte that is not valid UTF-8,
163
- // which is most of a JPEG, so the boundary search found nothing and every
164
- // media and avatar upload answered "422 file required". readBody() does
165
- // `data += chunk`, which decodes a Buffer the same way it always did, so the
166
- // JSON and form paths are unchanged.
167
- const bodyBytes = (request.method === 'GET' || request.method === 'HEAD')
168
- ? null : Buffer.from(new Uint8Array(await request.arrayBuffer()));
169
- // The admin facade is JSON-only (the gate above enforces the content type),
170
- // and takes its body already decoded.
171
- const bodyText = bodyBytes ? new TextDecoder().decode(bodyBytes) : '';
172
- const reqHeaders = {}; for (const [k, v] of request.headers) reqHeaders[k.toLowerCase()] = v;
173
- // `host` is a forbidden header name, so a fetch Request never carries one and
174
- // the loop above cannot produce it — but it is a header every real request
175
- // arrives with, and the agent reads it to say where it lives. Without it the
176
- // notifications `Link` header named `https://undefined/`, so a client that
177
- // paged by following it (which is how a client is meant to page) walked off
178
- // the origin and saw nothing past the first screen.
179
- reqHeaders.host = url.host;
180
- const listeners = {};
181
- // The body's events fire on the next microtask. A route that reads the body
182
- // may only register for them after an await or two — the facade dispatches
183
- // through its area modules first — so a listener that arrives after the
184
- // events have fired is given them at once, in the order it asks.
185
- let fired = false;
186
- const req = { method: request.method, url: url.pathname + url.search, headers: reqHeaders,
187
- // notAllowed() above let this through, so it came from this origin. Said
188
- // out loud rather than left implicit: MastoApi asks (through the
189
- // authorities object in agent.mjs) whether a request is the owner's own,
190
- // and in a browser that question means exactly this.
168
+ const { req, res, bodyText, response } = await bridge(request, url, {
169
+ // notAllowed() above let this through, so it came from this origin.
191
170
  sameOrigin: true,
192
- socket: { encrypted: url.protocol === 'https:' },
193
- on(ev, cb) {
194
- (listeners[ev] ||= []).push(cb);
195
- if (fired) { if (ev === 'data' && bodyBytes?.length) cb(bodyBytes); else if (ev === 'end') cb(); }
196
- return req;
197
- },
198
- destroy() {} };
199
- queueMicrotask(() => {
200
- fired = true;
201
- if (bodyBytes?.length) (listeners.data || []).forEach((cb) => cb(bodyBytes));
202
- (listeners.end || []).forEach((cb) => cb());
203
171
  });
204
- let status = 200; const outHeaders = {}; const chunks = [];
205
- const res = {
206
- writeHead(s, h) { status = s; if (h) Object.assign(outHeaders, h); return res; },
207
- setHeader(k, v) { outHeaders[k] = v; }, getHeader(k) { return outHeaders[k]; },
208
- write(c) { chunks.push(c); }, end(c) { if (c) chunks.push(c); },
209
- };
210
172
  try {
211
173
  const handled = isAdmin(url.pathname)
212
174
  ? await agent.admin.handle(req, res, url.pathname, url, bodyText)
213
175
  : await agent.masto.handle(req, res, url.pathname, url);
214
176
  if (!handled) return fetch(request);
215
- return new Response(chunks.join(''), { status, headers: { 'content-type': 'application/json', ...outHeaders } });
177
+ return response();
216
178
  } catch (err) {
217
179
  return json(500, { error: err.message });
218
180
  }
package/web/app/update.js CHANGED
@@ -64,7 +64,8 @@
64
64
  setTimeout(go, 4000); // a pages-only change: no new worker will take over
65
65
  } catch { /* offline, or the site did not answer: try again later */ }
66
66
  };
67
- // Often enough that a reader watching a page being worked on sees it.
67
+ // Every ten minutes: a deploy reaches an open page soon enough, and each
68
+ // open tab asks the site 144 times a day rather than 1,440.
68
69
  setTimeout(check, 5000);
69
- setInterval(check, 60 * 1000);
70
+ setInterval(check, 10 * 60 * 1000);
70
71
  })();
@@ -0,0 +1,115 @@
1
+ // warm-start.mjs — what a browser worker's last start read, kept in this
2
+ // browser so that a restart moments later need not read the pod again.
3
+ //
4
+ // A browser stops an idle service worker after about thirty seconds, and a
5
+ // client in a background tab wakes it about once a minute. Each wake was a
6
+ // whole sign-in against the pod. Kept here: where the account lives, its state
7
+ // documents as the pod holds them (bytes and ETags), and when the last full
8
+ // start and the last inbox drain were. A document is marked pending before it
9
+ // is written to the pod and confirmed after, so a worker stopped in between
10
+ // re-reads that one document rather than trusting a copy the pod has moved
11
+ // past. Signing out deletes the database.
12
+ const DB = 'fedipod-warm';
13
+
14
+ export const WARM_GAP_MS = 5 * 60_000; // a restart this soon after the last is the same visit
15
+ export const FULL_EVERY_MS = 60 * 60_000; // a full start still happens at least hourly
16
+ export const DRAIN_EVERY_MS = 2 * 60_000; // the inbox drain's own fallback cadence
17
+
18
+ export const isWarm = (meta, now = Date.now()) =>
19
+ !!meta && now - meta.wakeAt < WARM_GAP_MS && now - meta.fullAt < FULL_EVERY_MS;
20
+
21
+ function open() {
22
+ return new Promise((res, rej) => {
23
+ const r = indexedDB.open(DB, 1);
24
+ r.onupgradeneeded = () => { r.result.createObjectStore('meta'); r.result.createObjectStore('docs'); };
25
+ r.onsuccess = () => res(r.result);
26
+ r.onerror = () => rej(r.error);
27
+ });
28
+ }
29
+
30
+ // One transaction, then the connection is closed, so a sign-out can delete
31
+ // the database without waiting on a worker that still holds it open.
32
+ async function tx(stores, mode, work) {
33
+ const db = await open();
34
+ try {
35
+ return await new Promise((res, rej) => {
36
+ const t = db.transaction(stores, mode);
37
+ let out;
38
+ Promise.resolve(work(t)).then((v) => { out = v; });
39
+ t.oncomplete = () => res(out);
40
+ t.onerror = () => rej(t.error);
41
+ t.onabort = () => rej(t.error);
42
+ });
43
+ } finally { db.close(); }
44
+ }
45
+
46
+ const docKey = (webId, name) => `${webId}\n${name}`;
47
+ const range = (webId) => IDBKeyRange.bound(`${webId}\n`, `${webId}\n￿`);
48
+ const ask = (rq) => new Promise((res, rej) => { rq.onsuccess = () => res(rq.result); rq.onerror = () => rej(rq.error); });
49
+
50
+ export const warmMeta = (webId) => tx('meta', 'readonly', (t) => ask(t.objectStore('meta').get(webId)).then((m) => m || null));
51
+
52
+ export const saveMeta = (webId, patch) => tx('meta', 'readwrite', async (t) => {
53
+ const s = t.objectStore('meta');
54
+ const had = (await ask(s.get(webId))) || {};
55
+ s.put({ ...had, ...patch }, webId);
56
+ });
57
+
58
+ export const saveDoc = (webId, name, doc) => tx('docs', 'readwrite', (t) => {
59
+ t.objectStore('docs').put({ name, ...doc }, docKey(webId, name));
60
+ });
61
+
62
+ export const dropDoc = (webId, name) => tx('docs', 'readwrite', (t) => {
63
+ t.objectStore('docs').delete(docKey(webId, name));
64
+ });
65
+
66
+ export const loadDocs = (webId) => tx('docs', 'readonly', (t) => ask(t.objectStore('docs').getAll(range(webId))));
67
+
68
+ // Everything the pod held at a load, in place of whatever was kept before.
69
+ export const replaceDocs = (webId, docs, listingEtag) => tx(['docs', 'meta'], 'readwrite', async (t) => {
70
+ const d = t.objectStore('docs');
71
+ d.delete(range(webId));
72
+ for (const doc of docs) d.put({ ...doc, pending: false }, docKey(webId, doc.name));
73
+ const m = t.objectStore('meta');
74
+ const had = (await ask(m.get(webId))) || {};
75
+ m.put({ ...had, listingEtag: listingEtag || null }, webId);
76
+ });
77
+
78
+ export function deleteWarm() {
79
+ return new Promise((res) => {
80
+ let rq; try { rq = indexedDB.deleteDatabase(DB); } catch { res(); return; }
81
+ rq.onsuccess = rq.onerror = rq.onblocked = () => res();
82
+ });
83
+ }
84
+
85
+ /**
86
+ * Keep `store`'s documents in step with the pod, for `webId`. Before a write
87
+ * or removal the document is marked pending; after it lands, the bytes the
88
+ * pod now holds are kept. A load replaces the whole copy.
89
+ */
90
+ export function keepInStep(store, webId, log = () => {}) {
91
+ const fail = (e) => log(`warm copy: ${e?.message || e}`);
92
+ store.onWriting = (name) => saveDoc(webId, name, { text: null, etag: null, pending: true }).catch(fail);
93
+ store.onSettled = (name) => {
94
+ const text = store.lastText.get(name);
95
+ (text == null ? dropDoc(webId, name) : saveDoc(webId, name, { text, etag: store.etags.get(name) || null, pending: false }))
96
+ .catch(fail);
97
+ };
98
+ store.onLoaded = () => {
99
+ const docs = [...store.lastText].map(([name, text]) => ({ name, text, etag: store.etags.get(name) || null }));
100
+ replaceDocs(webId, docs, store.etags.get('')).catch(fail);
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Put a kept copy into `store`, and answer the names of documents it caught
106
+ * mid-write: those, and only those, need reading from the pod again. While the
107
+ * lease stayed with this browser nothing else wrote the state, so the rest is
108
+ * what the pod holds. With any pending, the container's ETag is left out too,
109
+ * so a later full load lists it afresh.
110
+ */
111
+ export function takeUp(store, docs, listingEtag) {
112
+ const pending = docs.filter((d) => d.pending).map((d) => d.name);
113
+ store.restore({ docs: docs.filter((d) => !d.pending && d.text != null), listingEtag: pending.length ? null : listingEtag });
114
+ return pending;
115
+ }
@@ -0,0 +1,78 @@
1
+ // app-signin.mjs — a Mastodon app asked to use an account at this gateway
2
+ // (lib/gateway/masto-gateway.mjs). The person gives their address here, signs
3
+ // in at their own pod with the fediverse-session library, and the page proves
4
+ // that sign-in to the gateway with one signed request; the gateway hands the
5
+ // app its code. No password of ours anywhere.
6
+ import { fediLogin } from './fedi-login.mjs';
7
+
8
+ const $ = (id) => document.getElementById(id);
9
+ const say = (text, error = false) => { const s = $('signin-status'); s.textContent = text; s.setAttribute('role', error ? 'alert' : 'status'); };
10
+ const here = location.origin + location.pathname;
11
+ const params = new URLSearchParams(location.search);
12
+ // The address last used here, so the trip to the pod and back does not ask again.
13
+ const KEY = 'fedipod-app-address';
14
+ const remembered = () => { try { return sessionStorage.getItem(KEY) || localStorage.getItem(KEY) || ''; } catch { return ''; } };
15
+ const remember = (a) => { try { sessionStorage.setItem(KEY, a); localStorage.setItem(KEY, a); } catch { /* keeps no site data */ } };
16
+ const login = fediLogin({ dbName: 'fedipod-app-signin', clientName: 'Sign in to an app', redirectUri: here });
17
+ // The person's own yes to this app, given by pressing the button in this tab.
18
+ // It rides the trip to the pod and back, and nothing else stands for it: a
19
+ // page that sends someone here, signed in before, gets nothing without it.
20
+ const CONSENT = 'fedipod-app-consent';
21
+ const request = `${params.get('client_id')}\n${params.get('redirect_uri') || ''}`;
22
+ const consented = () => { try { return sessionStorage.getItem(CONSENT) === request; } catch { return false; } };
23
+ const consent = (on) => { try { if (on) sessionStorage.setItem(CONSENT, request); else sessionStorage.removeItem(CONSENT); } catch { /* keeps no site data */ } };
24
+
25
+ // Back from the pod: this finishes the sign-in and returns to the app's request.
26
+ try { await login.resume(); } catch (e) { say(e.message, true); }
27
+
28
+ // Proves the pod sign-in to the gateway. True when the app has its code.
29
+ async function prove(address) {
30
+ const s = await login.getSession();
31
+ if (!s || !consented()) return false;
32
+ consent(false);
33
+ say('Signing you in…');
34
+ const res = await s.fetch(`${location.origin}/api/authorize`, {
35
+ method: 'POST', headers: { 'content-type': 'application/json' },
36
+ body: JSON.stringify({ ...Object.fromEntries(params), address }),
37
+ });
38
+ const d = await res.json().catch(() => ({}));
39
+ if (res.ok && d.redirect) { say('Signed in. Going back to the app…'); location.href = d.redirect; return true; }
40
+ if (res.ok && d.code) { say(`Signed in. The app asks you to paste this code into it: ${d.code}`); return true; }
41
+ say(d.error || `The sign-in was refused (HTTP ${res.status}).`, true);
42
+ return false;
43
+ }
44
+
45
+ async function start() {
46
+ if (!params.get('client_id')) { say('This page is for an app that asked to use your account. Start from the app.', true); return; }
47
+ const r = await fetch(`/api/authorize?${new URLSearchParams({ client_id: params.get('client_id'), redirect_uri: params.get('redirect_uri') || '' })}`);
48
+ const app = await r.json().catch(() => ({}));
49
+ if (!r.ok) { say(app.error || `This app cannot sign in here (HTTP ${r.status}).`, true); return; }
50
+ const asking = $('asking');
51
+ const name = document.createElement('strong');
52
+ name.textContent = app.name;
53
+ asking.append(name, ' is asking to use your account');
54
+ if (app.sendsTo) { const where = document.createElement('code'); where.textContent = app.sendsTo; asking.append(', and will be sent back to ', where); }
55
+ asking.append('.');
56
+ asking.hidden = false;
57
+ const address = remembered();
58
+ $('address').value = address;
59
+ // Back from the pod after pressing the button: finish. Otherwise ask.
60
+ if (!(address && consented() && await prove(address))) $('signin-form').hidden = false;
61
+ }
62
+
63
+ $('signin-form').addEventListener('submit', async (ev) => {
64
+ ev.preventDefault();
65
+ const address = $('address').value.trim();
66
+ if (!address) { say('Write your address here first.', true); return; }
67
+ const r = await fetch(`/api/authorize?address=${encodeURIComponent(address)}`);
68
+ const who = await r.json().catch(() => ({}));
69
+ if (!r.ok) { say(who.error || `That address was not found (HTTP ${r.status}).`, true); return; }
70
+ remember(address);
71
+ consent(true);
72
+ const s = await login.getSession();
73
+ if (s && s.webId === who.webId) { await prove(address); return; }
74
+ say('Taking you to your pod…');
75
+ try { await login.login(who.webId, { returnTo: location.href, redirectUri: here }); } catch (e) { say(e.message, true); }
76
+ });
77
+
78
+ await start();
@@ -0,0 +1,41 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Let an app use your account</title>
7
+ <link rel="stylesheet" href="/app-signin/tokens.css">
8
+ <style>
9
+ body { font-family: var(--font); background: var(--surface); color: var(--fg); margin: 0; line-height: 1.5; }
10
+ main { max-width: 30rem; margin: 8vh auto; padding: 1.5rem 1.75rem; background: var(--raised);
11
+ border: 1px solid var(--line); border-radius: 10px; }
12
+ h1 { color: var(--heading); font-size: 1.5rem; margin: 0 0 1rem; }
13
+ a { color: var(--link); text-decoration: underline; }
14
+ code { word-break: break-all; background: var(--field-bg); padding: .05em .35em; border-radius: 4px; }
15
+ label { display: block; margin: 1rem 0 .4rem; font-weight: bold; }
16
+ input { font: inherit; width: 100%; box-sizing: border-box; padding: .5rem .7rem; background: var(--field-bg);
17
+ color: var(--field-text); border: 1px solid var(--field-edge); border-radius: 6px; }
18
+ .hint { color: var(--sub); }
19
+ button { font: inherit; padding: .6rem 1.1rem; background: var(--btn); color: var(--btn-text); border: none;
20
+ border-radius: 9px; cursor: pointer; }
21
+ button:hover { background: var(--btn-hover); }
22
+ :focus-visible { outline: 3px solid var(--ring); outline-offset: 2px; }
23
+ #signin-status { margin-top: 1rem; padding: .6rem .8rem; border: 1px solid var(--line); border-radius: 8px; }
24
+ #signin-status:empty { display: none; }
25
+ </style>
26
+ </head>
27
+ <body>
28
+ <main>
29
+ <h1>Let an app use your account</h1>
30
+ <p id="asking" hidden></p>
31
+ <form id="signin-form" hidden>
32
+ <label for="address">Your address here</label>
33
+ <input type="text" id="address" name="address" autocomplete="username">
34
+ <p class="hint">You sign in at your own pod. No password is typed on this page.</p>
35
+ <p><button type="submit" id="signin">Sign in with your pod</button></p>
36
+ </form>
37
+ <p id="signin-status" role="status" aria-live="polite"></p>
38
+ </main>
39
+ <script type="module" src="/app-signin/app-signin.mjs"></script>
40
+ </body>
41
+ </html>
@@ -46,12 +46,18 @@ Fediverse services straight from your pod.</p>
46
46
  <label for="run-issuer">Your identity provider</label>
47
47
  <input type="url" id="run-issuer" autocomplete="off" placeholder="https://this.server">
48
48
  </p>
49
+ <p class="field">
50
+ <label for="run-container">Store your data in a container named fedipod, inside this container</label>
51
+ <input type="text" id="run-container" autocomplete="off" spellcheck="false" placeholder="/"
52
+ aria-describedby="run-container-hint">
53
+ </p>
54
+ <p class="hint" id="run-container-hint">Leave it empty for your pod's root. This is permanent.</p>
49
55
  <p class="actions">
50
56
  <button type="button" id="run-continue" class="primary" disabled>Sign in with your pod &amp; opt in</button>
51
57
  <button type="button" id="run-leave">Stop running it here</button>
52
58
  </p>
53
59
  </form>
54
- <p class="hint" id="run-note" hidden></p>
60
+ <div class="hint" id="run-note" hidden></div>
55
61
 
56
62
  <script type="module" src="/run.js"></script>
57
63
  </body>