fedipod 1.6.0 → 1.8.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 (52) hide show
  1. package/README.md +9 -3
  2. package/device-agent.md +99 -0
  3. package/gateway.md +16 -0
  4. package/groups.md +1 -1
  5. package/gui.md +1 -1
  6. package/lib/client/c2s.mjs +95 -61
  7. package/lib/client/masto/index.mjs +7 -1
  8. package/lib/client/masto/timelines.mjs +1 -1
  9. package/lib/client/oidc-auth.mjs +5 -3
  10. package/lib/core/contexts/anno.json +126 -0
  11. package/lib/core/contexts/index.mjs +4 -0
  12. package/lib/core/contexts/map.json +2 -1
  13. package/lib/core/intake/index.mjs +32 -5
  14. package/lib/core/publisher/index.mjs +15 -1
  15. package/lib/core/publisher/notes.mjs +84 -2
  16. package/lib/core/publisher/questions.mjs +1 -0
  17. package/lib/core/publisher/restore.mjs +27 -2
  18. package/lib/core/social.mjs +1 -0
  19. package/lib/core/store.mjs +4 -0
  20. package/lib/core/wire.mjs +20 -13
  21. package/lib/device/admin/routes/gateway.mjs +1 -1
  22. package/lib/device/admin/surface.mjs +25 -17
  23. package/lib/device/cli/commands/setup.mjs +9 -1
  24. package/lib/device/setup.mjs +6 -0
  25. package/lib/gateway/front-core.mjs +66 -12
  26. package/lib/gateway/gateway-core.mjs +40 -0
  27. package/lib/pod/actor.mjs +2 -2
  28. package/lib/pod/root.mjs +11 -0
  29. package/lib/pod/transport.mjs +9 -3
  30. package/lib/server/embed.mjs +23 -6
  31. package/package.json +2 -2
  32. package/run-agent.mjs +10 -1
  33. package/scripts/refresh-contexts.mjs +5 -0
  34. package/vendor/gate.cjs +5 -2
  35. package/web/admin/index.html +2 -0
  36. package/web/admin/upkeep.js +9 -1
  37. package/web/app/README.md +1 -1
  38. package/web/app/agent.mjs +11 -1
  39. package/web/app/boot.mjs +49 -10
  40. package/web/app/dist/boot.js +89 -37
  41. package/web/app/dist/boot.js.map +3 -3
  42. package/web/app/dist/sw.js +1326 -662
  43. package/web/app/dist/sw.js.map +4 -4
  44. package/web/app/index.html +16 -6
  45. package/web/app/signup.mjs +3 -1
  46. package/web/app/site/admin/index.html +2 -0
  47. package/web/app/site/admin/upkeep.js +9 -1
  48. package/web/app/site/boot.js +89 -37
  49. package/web/app/site/index.html +16 -6
  50. package/web/app/site/sw.js +1326 -662
  51. package/web/front/#new-account.html# +0 -43
  52. package/web/front/new-account.html~ +0 -50
package/web/app/agent.mjs CHANGED
@@ -12,6 +12,7 @@ import { PodStore } from '../../lib/core/store.mjs';
12
12
  import { HttpStorage } from '../../lib/core/storage.mjs';
13
13
  import { Publisher } from '../../lib/core/publisher/index.mjs';
14
14
  import { Intake } from '../../lib/core/intake/index.mjs';
15
+ import { C2S } from '../../lib/client/c2s.mjs';
15
16
  import { Lease } from '../../lib/core/lease.mjs';
16
17
  import { MastoApi } from '../../lib/client/masto/index.mjs';
17
18
  import { TagFeed } from '../../lib/connections/tagfeed.mjs';
@@ -84,6 +85,9 @@ export class BrowserAgent {
84
85
  // whole document back over newer state. Read what is actually there
85
86
  // before acting on it.
86
87
  await this.store.load({ force: true }).catch((e) => this.log(`re-reading state: ${e.message}`));
88
+ // Own posts the outbox names and the timeline index lacks come back
89
+ // here, before anything acts on the index.
90
+ await this.publisher.healStatuses().catch((e) => this.log(`healing the timeline index: ${e.message}`));
87
91
  // And start delivering again, since demote() stopped it. startQueue() is
88
92
  // idempotent, so a goActive() that was already active costs nothing.
89
93
  this.deliverer?.startQueue?.();
@@ -189,7 +193,7 @@ export class BrowserAgent {
189
193
  // builds its own urls from `config.root` (publisher.mjs), and a config
190
194
  // without one falls to the Node default — so an account set up elsewhere
191
195
  // and signed into here would keep its state under `fedipod/` while every
192
- // document it published landed under `activitypods-js/`. One root, decided
196
+ // document it published landed under a different root. One root, decided
193
197
  // once, carried by the config everything downstream reads.
194
198
  this.store.setConfig({ ...(this.store.getConfig() || {}), ...cfg, root });
195
199
  config = this.store.getConfig();
@@ -257,9 +261,14 @@ export class BrowserAgent {
257
261
  // read-only until the owner acts on it and it takes over. Written with fresh
258
262
  // fetches, never the cached store. Passed into Intake so the drain checks it.
259
263
  this.lease = new Lease({ url: this.urls.state + 'lease.json', fetchImpl: podFetch, log: this.log });
264
+ // The client-to-server dispatcher, here only for what the Gateway's
265
+ // outbox door takes on the owner's behalf: the browser answers no
266
+ // /ap/outbox of its own.
267
+ this.c2s = new C2S({ agent: this, log: this.log });
260
268
  this.intake = new Intake({
261
269
  config: this.store.getConfig(), urls: this.urls, remote: this.remote,
262
270
  store: this.store, deliverer: this.deliverer, publisher: this.publisher, log: this.log, push: true, lease: this.lease,
271
+ ownerPost: (a, o) => this.c2s.dispatch(a, o),
263
272
  });
264
273
  // The Mastodon facade the service worker serves.
265
274
  //
@@ -356,6 +365,7 @@ export class BrowserAgent {
356
365
  podRequests: this.remote?.stats?.() || null,
357
366
  update: null,
358
367
  inboxCooldownFor: 0,
368
+ stateSkipped: this.store?.lastSkipped || [],
359
369
  };
360
370
  }
361
371
 
package/web/app/boot.mjs CHANGED
@@ -16,7 +16,7 @@ import { podBaseOfWebId } from '../../lib/pod/urls.mjs';
16
16
  import { podLayout } from '../../lib/pod/root.mjs';
17
17
  import { BrowserRemotePod } from './pod-remote.mjs';
18
18
  import { beginLogin, completeLogin, getSession, signOut } from './oidc-session.mjs';
19
- import { unwrapKeys, isKeyEnvelope } from './keystore.mjs';
19
+ import { generateKeys, wrapKeys, unwrapKeys, isKeyEnvelope } from './keystore.mjs';
20
20
  import { cacheOpenedKeys } from './keys-browser.mjs';
21
21
 
22
22
  const REDIRECT = `${location.origin}/`; // the app root doubles as the OIDC callback
@@ -64,30 +64,50 @@ async function bootWorker({ reset = false } = {}) {
64
64
  //
65
65
  // The unwrap happens HERE, in the page, and not in the worker: the worker boots
66
66
  // itself whenever the browser restarts it, with nobody present to type anything.
67
- window.fedipodUnlock = async (password) => {
68
- if (!password) throw new Error('Enter your account password.');
67
+ //
68
+ // Both paths below read the account's config and key the same way: with the
69
+ // session, as the owner, through the transport rather than the bare session —
70
+ // a pod read like any other, with the retry ladder that exists because the pod
71
+ // host throttles bursts.
72
+ async function readAccountState() {
69
73
  const session = await getSession();
70
74
  if (!session) throw new Error('Sign in first.');
71
- // The config on the pod says where this account's state lives; the key sits
72
- // beside it. Both are read with the session, as the owner.
73
75
  const podFromWebId = podBaseOfWebId(session.webId); // a suffix-based host, or its own host
74
76
  const state = `${podFromWebId}${AP_ROOT}ap-state/`;
75
- // Through the transport rather than the bare session: this is a pod read
76
- // like any other, and going round it skipped the retry ladder that exists
77
- // because the pod host throttles bursts.
78
77
  const remote = new BrowserRemotePod(session, { webId: session.webId, role: 'signup', log: () => {} });
79
78
  const urls = { state };
80
79
  const [cfg, doc] = await Promise.all([
81
80
  podState.readConfig(remote, urls), podState.readWrappedKeys(remote, urls),
82
81
  ]);
83
- if (!cfg || !doc) throw new Error(`could not read this account's config and key under ${state}`);
82
+ if (!cfg) throw new Error(`could not read this account's config under ${state}`);
83
+ const actorUrl = `${cfg.remotePod}${cfg.root || AP_ROOT}ap/actor`;
84
+ return { remote, urls, cfg, doc, actorUrl };
85
+ }
86
+
87
+ window.fedipodUnlock = async (password) => {
88
+ if (!password) throw new Error('Enter your password.');
89
+ const { doc, actorUrl } = await readAccountState();
90
+ if (!doc) throw new Error('could not read this account\'s key on the pod');
84
91
  if (!isKeyEnvelope(doc)) throw new Error('this account\'s key is not locked — nothing to unlock');
85
92
  const rec = await unwrapKeys(doc, password); // throws 'wrong password'
86
- const actorUrl = `${cfg.remotePod}${cfg.root || AP_ROOT}ap/actor`;
87
93
  await cacheOpenedKeys(actorUrl, rec);
88
94
  await bootWorker();
89
95
  };
90
96
 
97
+ // The same pane, for someone who no longer has the sign-up password: a new key,
98
+ // wrapped under the password they use now, written over the pod's copy. Nothing
99
+ // is unwrapped, so the old password is never needed. The boot that follows
100
+ // publishes the new public key (agent.goActive → publishProfile).
101
+ window.fedipodNewKey = async (password) => {
102
+ if (!password) throw new Error('Enter the password you use for your pod now.');
103
+ const { remote, urls, cfg, actorUrl } = await readAccountState();
104
+ const keys = await generateKeys();
105
+ keys.mintedFor = cfg.gateway?.frontActor || actorUrl; // one key, one actor (signup.mjs)
106
+ await podState.writeWrappedKeys(remote, urls, await wrapKeys(keys, password));
107
+ await cacheOpenedKeys(actorUrl, keys);
108
+ await bootWorker();
109
+ };
110
+
91
111
  // New account: create the account, pod, key, config and gateway attach (this
92
112
  // needs the password once), then redirect to the pod's login to establish the
93
113
  // durable session. The agent boots on return, reading config + key from the pod.
@@ -213,6 +233,25 @@ if (typeof document !== 'undefined') (async () => {
213
233
  };
214
234
  $('unlock-go')?.addEventListener('click', doUnlock);
215
235
  $('unlock-password')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') doUnlock(); });
236
+ // The new-key path: one click reveals the confirmation, the second acts.
237
+ $('unlock-newkey')?.addEventListener('click', () => {
238
+ $('unlock-newkey-confirm').hidden = false;
239
+ $('unlock-password').focus();
240
+ });
241
+ const doNewKey = async () => {
242
+ $('unlock-error').textContent = '';
243
+ const btn = $('unlock-newkey-go'); btn.disabled = true;
244
+ try {
245
+ await window.fedipodNewKey($('unlock-password').value);
246
+ $('unlock-password').value = '';
247
+ location.href = '/admin/client/';
248
+ } catch (err) {
249
+ $('unlock-error').textContent = err.message || String(err);
250
+ btn.disabled = false;
251
+ $('unlock-password').select();
252
+ }
253
+ };
254
+ $('unlock-newkey-go')?.addEventListener('click', doNewKey);
216
255
 
217
256
 
218
257
  // The client shell's bar returns here for two things, and neither continues
@@ -33075,7 +33075,7 @@ var PodTransport = class {
33075
33075
  * says all of it. The parsed graph must mention the WebID before anything is
33076
33076
  * written back — an empty or foreign body must never become the new profile.
33077
33077
  */
33078
- async linkAccountInProfile({ actorUrl, accountName, kind = "person" }) {
33078
+ async linkAccountInProfile({ actorUrl, accountName, kind = "person", outbox = null }) {
33079
33079
  const docUrl = this.webId.split("#")[0];
33080
33080
  const res = await this.fetch(docUrl, { headers: { accept: "text/turtle" } });
33081
33081
  if (res.status >= 400) throw new Error(`[${this.label}] GET ${docUrl} \u2192 ${res.status}`);
@@ -33091,10 +33091,16 @@ var PodTransport = class {
33091
33091
  [me, FOAF("account"), actor],
33092
33092
  [actor, RDF3("type"), FOAF("OnlineAccount")],
33093
33093
  [actor, RDF3("type"), kind === "group" ? AS("Group") : AS("Person")],
33094
- [actor, FOAF("accountName"), literal2(accountName)]
33094
+ [actor, FOAF("accountName"), literal2(accountName)],
33095
+ // Where a Solid client posts on this person's behalf (`as:outbox` on the
33096
+ // WebID is what dokieli reads); only where a door exists to take it.
33097
+ ...outbox ? [[me, AS("outbox"), namedNode2(outbox)]] : []
33095
33098
  ];
33096
33099
  const missing = wanted.filter(([s, p, o]) => !g.holds(s, p, o, doc));
33097
- const stale = g.statementsMatching(actor, FOAF("accountName"), null, doc).filter((st2) => st2.object.value !== accountName);
33100
+ const stale = [
33101
+ ...g.statementsMatching(actor, FOAF("accountName"), null, doc).filter((st2) => st2.object.value !== accountName),
33102
+ ...outbox ? g.statementsMatching(me, AS("outbox"), null, doc).filter((st2) => st2.object.value !== outbox) : []
33103
+ ];
33098
33104
  if (!missing.length && !stale.length) return false;
33099
33105
  const deletes = stale.map((st2) => [st2.subject, st2.predicate, st2.object]);
33100
33106
  if (await this.patchDocument(docUrl, missing, deletes)) return true;
@@ -33202,12 +33208,54 @@ var BrowserRemotePod = class extends PodTransport {
33202
33208
  // lib/pod/state.mjs
33203
33209
  var readWrappedKeys = (pod, urls) => pod.getJson(urls.state + "keys.json");
33204
33210
  var readConfig = (pod, urls) => pod.getJson(urls.state + "config.json");
33211
+ var writeWrappedKeys = (pod, urls, envelope) => pod.putJson(urls.state + "keys.json", envelope, "application/json");
33205
33212
  var writeConfig = (pod, urls, config) => pod.putJson(urls.state + "config.json", config, "application/json");
33206
33213
  async function provisionKey(pod, { stateUrl, keysUrl, envelope }) {
33207
33214
  await pod.setAcl(stateUrl, []);
33208
33215
  await pod.putJson(keysUrl, envelope, "application/json");
33209
33216
  }
33210
33217
 
33218
+ // lib/pod/root.mjs
33219
+ var OWNER_LOOKUP_MS = 5e3;
33220
+ var PUBLIC_DOC_MAX_BYTES = 1024 * 1024;
33221
+ async function podLayout(fetchImpl, providerOrigin, { timeoutMs = OWNER_LOOKUP_MS } = {}) {
33222
+ let origin;
33223
+ try {
33224
+ origin = new URL(providerOrigin).origin;
33225
+ } catch {
33226
+ return null;
33227
+ }
33228
+ let res;
33229
+ try {
33230
+ res = await fetchImpl(
33231
+ `${origin}/.well-known/solid`,
33232
+ { headers: { accept: "text/turtle" }, signal: AbortSignal.timeout(timeoutMs) }
33233
+ );
33234
+ } catch {
33235
+ return null;
33236
+ }
33237
+ if (res.status === 501) return "host";
33238
+ if (res.status !== 200) return null;
33239
+ let body = "";
33240
+ try {
33241
+ body = await readCapped(res, 64 * 1024);
33242
+ } catch {
33243
+ return null;
33244
+ }
33245
+ return /ns\/pim\/space#Storage|pim:Storage/u.test(body) ? "path" : null;
33246
+ }
33247
+ async function resourceExists(fetchImpl, url, { timeoutMs = OWNER_LOOKUP_MS } = {}) {
33248
+ try {
33249
+ const res = await fetchImpl(
33250
+ url,
33251
+ { headers: { accept: "application/activity+json" }, signal: AbortSignal.timeout(timeoutMs) }
33252
+ );
33253
+ return !!res && res.status === 200;
33254
+ } catch {
33255
+ return false;
33256
+ }
33257
+ }
33258
+
33211
33259
  // web/app/idb-kv.mjs
33212
33260
  var DB = "fedipod-accounts";
33213
33261
  function open() {
@@ -33310,6 +33358,7 @@ async function signUp(answers, { onStep = () => {
33310
33358
  acct.running("checking your pod");
33311
33359
  const head = await fetch(brought, { method: "HEAD" }).catch(() => null);
33312
33360
  if (!head || head.status >= 400) throw new Error(`the pod at ${brought} did not answer (HTTP ${head?.status || "no response"})`);
33361
+ if (await resourceExists(fetch, actorUrlFor(brought))) throw new Error("The pod already hosts a FediPod account. If you want a second account, put it on a different pod.");
33313
33362
  prog.pod = brought;
33314
33363
  acct.skip("using the pod you brought");
33315
33364
  }
@@ -33450,36 +33499,6 @@ function podBaseOfWebId(webId) {
33450
33499
  return `${u.origin}${dir.endsWith("/") ? dir : dir + "/"}`;
33451
33500
  }
33452
33501
 
33453
- // lib/pod/root.mjs
33454
- var OWNER_LOOKUP_MS = 5e3;
33455
- var PUBLIC_DOC_MAX_BYTES = 1024 * 1024;
33456
- async function podLayout(fetchImpl, providerOrigin, { timeoutMs = OWNER_LOOKUP_MS } = {}) {
33457
- let origin;
33458
- try {
33459
- origin = new URL(providerOrigin).origin;
33460
- } catch {
33461
- return null;
33462
- }
33463
- let res;
33464
- try {
33465
- res = await fetchImpl(
33466
- `${origin}/.well-known/solid`,
33467
- { headers: { accept: "text/turtle" }, signal: AbortSignal.timeout(timeoutMs) }
33468
- );
33469
- } catch {
33470
- return null;
33471
- }
33472
- if (res.status === 501) return "host";
33473
- if (res.status !== 200) return null;
33474
- let body = "";
33475
- try {
33476
- body = await readCapped(res, 64 * 1024);
33477
- } catch {
33478
- return null;
33479
- }
33480
- return /ns\/pim\/space#Storage|pim:Storage/u.test(body) ? "path" : null;
33481
- }
33482
-
33483
33502
  // web/app/oidc-session.mjs
33484
33503
  var DB2 = "fedipod-oidc";
33485
33504
  var STORE = "session";
@@ -33725,8 +33744,7 @@ async function bootWorker({ reset = false } = {}) {
33725
33744
  worker.postMessage({ type: "boot", frontOrigin: location.origin });
33726
33745
  await booted;
33727
33746
  }
33728
- window.fedipodUnlock = async (password) => {
33729
- if (!password) throw new Error("Enter your account password.");
33747
+ async function readAccountState() {
33730
33748
  const session = await getSession();
33731
33749
  if (!session) throw new Error("Sign in first.");
33732
33750
  const podFromWebId = podBaseOfWebId(session.webId);
@@ -33738,13 +33756,28 @@ window.fedipodUnlock = async (password) => {
33738
33756
  readConfig(remote, urls),
33739
33757
  readWrappedKeys(remote, urls)
33740
33758
  ]);
33741
- if (!cfg || !doc) throw new Error(`could not read this account's config and key under ${state}`);
33759
+ if (!cfg) throw new Error(`could not read this account's config under ${state}`);
33760
+ const actorUrl = `${cfg.remotePod}${cfg.root || AP_ROOT}ap/actor`;
33761
+ return { remote, urls, cfg, doc, actorUrl };
33762
+ }
33763
+ window.fedipodUnlock = async (password) => {
33764
+ if (!password) throw new Error("Enter your password.");
33765
+ const { doc, actorUrl } = await readAccountState();
33766
+ if (!doc) throw new Error("could not read this account's key on the pod");
33742
33767
  if (!isKeyEnvelope(doc)) throw new Error("this account's key is not locked \u2014 nothing to unlock");
33743
33768
  const rec = await unwrapKeys(doc, password);
33744
- const actorUrl = `${cfg.remotePod}${cfg.root || AP_ROOT}ap/actor`;
33745
33769
  await cacheOpenedKeys(actorUrl, rec);
33746
33770
  await bootWorker();
33747
33771
  };
33772
+ window.fedipodNewKey = async (password) => {
33773
+ if (!password) throw new Error("Enter the password you use for your pod now.");
33774
+ const { remote, urls, cfg, actorUrl } = await readAccountState();
33775
+ const keys = await generateKeys();
33776
+ keys.mintedFor = cfg.gateway?.frontActor || actorUrl;
33777
+ await writeWrappedKeys(remote, urls, await wrapKeys(keys, password));
33778
+ await cacheOpenedKeys(actorUrl, keys);
33779
+ await bootWorker();
33780
+ };
33748
33781
  window.fedipodSignup = async ({ onStep, ...answers }) => {
33749
33782
  await signUp(answers, { onStep, frontOrigin: location.origin });
33750
33783
  const { authorizationUrl } = await beginLogin({ issuer: answers.issuer, redirectUri: REDIRECT });
@@ -33849,6 +33882,25 @@ if (typeof document !== "undefined") (async () => {
33849
33882
  $("unlock-password")?.addEventListener("keydown", (e) => {
33850
33883
  if (e.key === "Enter") doUnlock();
33851
33884
  });
33885
+ $("unlock-newkey")?.addEventListener("click", () => {
33886
+ $("unlock-newkey-confirm").hidden = false;
33887
+ $("unlock-password").focus();
33888
+ });
33889
+ const doNewKey = async () => {
33890
+ $("unlock-error").textContent = "";
33891
+ const btn = $("unlock-newkey-go");
33892
+ btn.disabled = true;
33893
+ try {
33894
+ await window.fedipodNewKey($("unlock-password").value);
33895
+ $("unlock-password").value = "";
33896
+ location.href = "/admin/client/";
33897
+ } catch (err) {
33898
+ $("unlock-error").textContent = err.message || String(err);
33899
+ btn.disabled = false;
33900
+ $("unlock-password").select();
33901
+ }
33902
+ };
33903
+ $("unlock-newkey-go")?.addEventListener("click", doNewKey);
33852
33904
  if (params.has("signout") || params.has("add")) {
33853
33905
  if (params.has("signout")) {
33854
33906
  try {