fedipod-server 0.28.1 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +27 -28
  2. package/config/server.json +22 -0
  3. package/dist/claims.js +3 -2
  4. package/dist/directory.d.ts +2 -0
  5. package/dist/handler.d.ts +4 -1
  6. package/dist/handler.js +32 -3
  7. package/dist/handler.jsonld +4 -0
  8. package/dist/store-pod.js +37 -0
  9. package/lib/client/c2s.mjs +97 -43
  10. package/lib/client/masto/accounts.mjs +1 -0
  11. package/lib/core/deliver.mjs +17 -1
  12. package/lib/core/intake/activities.mjs +18 -2
  13. package/lib/core/intake/group.mjs +3 -1
  14. package/lib/core/intake/index.mjs +33 -2
  15. package/lib/core/intake/notes.mjs +12 -5
  16. package/lib/core/place.mjs +82 -0
  17. package/lib/core/publisher/collections.mjs +37 -2
  18. package/lib/core/publisher/index.mjs +23 -1
  19. package/lib/core/publisher/notes.mjs +73 -7
  20. package/lib/core/publisher/own.mjs +143 -0
  21. package/lib/core/publisher/questions.mjs +5 -3
  22. package/lib/core/social.mjs +62 -32
  23. package/lib/core/store.mjs +11 -1
  24. package/lib/core/wire.mjs +51 -15
  25. package/lib/device/admin/routes/lifecycle.mjs +1 -1
  26. package/lib/device/admin/routes/setup.mjs +17 -1
  27. package/lib/device/cli/commands/setup.mjs +50 -8
  28. package/lib/device/cli/context.mjs +1 -1
  29. package/lib/device/migrate.mjs +1 -1
  30. package/lib/device/setup.mjs +45 -8
  31. package/lib/gateway/front-core.mjs +54 -58
  32. package/lib/gateway/gateway-core.mjs +87 -8
  33. package/lib/gateway/relay-extras.mjs +89 -0
  34. package/lib/gateway/token-claims.mjs +16 -0
  35. package/lib/pod/location.mjs +52 -0
  36. package/lib/pod/notes.mjs +2 -4
  37. package/lib/pod/transport.mjs +171 -22
  38. package/lib/pod/type-index.mjs +101 -0
  39. package/lib/pod/urls.mjs +6 -0
  40. package/lib/server/embed.mjs +7 -6
  41. package/lib/session/fedi-account.mjs +19 -10
  42. package/lib/session/package.json +1 -1
  43. package/package.json +1 -1
  44. package/run-agent.mjs +2 -0
  45. package/web/admin/actors.js +2 -0
  46. package/web/admin/index.html +9 -0
  47. package/web/admin/record.js +4 -1
  48. package/web/admin/setup/index.html +17 -1
  49. package/web/admin/setup/setup.js +18 -5
  50. package/web/app/README.md +1 -1
  51. package/web/app/admin-facade.mjs +1 -1
  52. package/web/app/agent.mjs +11 -2
  53. package/web/app/boot.mjs +72 -32
  54. package/web/app/dist/boot.js +430 -86
  55. package/web/app/dist/boot.js.map +4 -4
  56. package/web/app/dist/sw.js +3294 -2648
  57. package/web/app/dist/sw.js.map +4 -4
  58. package/web/app/index.html +16 -0
  59. package/web/app/signup.mjs +65 -26
  60. package/web/front/run.html +7 -1
  61. package/web/front/run.js +30 -4
@@ -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 };
@@ -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>
package/web/front/run.js CHANGED
@@ -88,8 +88,19 @@ function showPods(pods) {
88
88
  stop.onclick = () => runStart('opt-out', pod.podBase);
89
89
  actions.append(stop);
90
90
  } else {
91
+ // Where on the pod the account goes: a container named fedipod, inside
92
+ // the one named here (empty: the pod's own root).
93
+ const field = document.createElement('p'); field.className = 'field';
94
+ const id = `container-${k.children.length}`;
95
+ const label = say('label', 'Store your data in a container named fedipod, inside this container');
96
+ label.htmlFor = id;
97
+ const input = document.createElement('input');
98
+ input.type = 'text'; input.id = id; input.autocomplete = 'off'; input.spellcheck = false;
99
+ try { input.placeholder = new URL(pod.podBase).pathname; } catch { input.placeholder = '/'; }
100
+ field.append(label, input);
101
+ box.append(field);
91
102
  const go = say('button', 'Create a Fediverse account'); go.type = 'button'; go.className = 'primary';
92
- go.onclick = () => runStart('opt-in', pod.podBase);
103
+ go.onclick = () => runStart('opt-in', pod.podBase, input.value.trim());
93
104
  actions.append(go);
94
105
  }
95
106
  box.append(actions);
@@ -121,13 +132,15 @@ $('run-issuer').addEventListener('input', runFormCheck);
121
132
  // a filled-in form, with nothing to say why.
122
133
  runFormCheck();
123
134
 
124
- const runStart = async (action, podBase = null) => {
135
+ const runStart = async (action, podBase = null, container = null, createIndex = false) => {
125
136
  const n = $('run-note');
126
137
  const u = new URL(podBase || $('run-pod-url').value.trim());
127
138
  if (u.pathname === '') u.pathname = '/';
128
139
  if (!u.pathname.endsWith('/')) u.pathname += '/';
129
140
  u.search = ''; u.hash = '';
130
- const p = { podBase: u.href, action };
141
+ const p = { podBase: u.href, action,
142
+ container: container ?? ($('run-container') ? $('run-container').value.trim() : ''),
143
+ ...(createIndex ? { createIndex: true } : {}) };
131
144
 
132
145
  // A pod server issued the session this page is being read with, so ask it
133
146
  // first: where it knows who you are, there is nothing to sign in to again.
@@ -156,6 +169,18 @@ async function showReply(res, p) {
156
169
  const n = $('run-note');
157
170
  n.hidden = false;
158
171
  const d = res ? await res.json().catch(() => ({})) : {};
172
+ // No public type index: nothing has been written. Ask.
173
+ if (res && res.status === 409 && d.code === 'needs-index') {
174
+ n.replaceChildren(say('p', `${d.error} Create it? It will be readable by anyone, like your profile.`));
175
+ const yes = say('button', 'Create it and continue'); yes.type = 'button'; yes.className = 'primary';
176
+ const no = say('button', 'No, stop'); no.type = 'button';
177
+ yes.onclick = () => runStart(p.action, p.podBase, p.container || '', true);
178
+ no.onclick = () => { n.textContent = 'Stopped. Nothing was written to your pod.'; };
179
+ const row = document.createElement('p'); row.className = 'actions'; row.append(no, yes);
180
+ n.append(row);
181
+ yes.focus();
182
+ return;
183
+ }
159
184
  if (res && res.status === 201 && d.doorSecret) {
160
185
  // Built as nodes, not as a string of HTML. Three of the pieces below come
161
186
  // back from the server and one comes from sessionStorage, and pasting any
@@ -189,7 +214,8 @@ async function showReply(res, p) {
189
214
  // The signed fetch builds a DPoP proof from the URL, so it must be absolute.
190
215
  const res = await session.authFetch(location.origin + '/api/agent', {
191
216
  method: 'POST', headers: { 'content-type': 'application/json' },
192
- body: JSON.stringify({ action: p.action, podBase: p.podBase }),
217
+ body: JSON.stringify({ action: p.action, podBase: p.podBase, container: p.container || '',
218
+ ...(p.createIndex ? { createIndex: true } : {}) }),
193
219
  }).catch(() => null);
194
220
  await showReply(res, p);
195
221
  })();