fedipod 1.7.0 → 1.10.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 (53) hide show
  1. package/README.md +14 -3
  2. package/gateway.md +16 -0
  3. package/groups.md +1 -0
  4. package/lib/client/c2s.mjs +112 -64
  5. package/lib/client/masto/index.mjs +9 -1
  6. package/lib/client/masto/render.mjs +5 -3
  7. package/lib/client/masto/statuses.mjs +7 -4
  8. package/lib/client/oidc-auth.mjs +10 -3
  9. package/lib/core/as2.mjs +1 -1
  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/deliver.mjs +24 -1
  14. package/lib/core/intake/activities.mjs +54 -0
  15. package/lib/core/intake/index.mjs +34 -5
  16. package/lib/core/intake/notes.mjs +7 -0
  17. package/lib/core/publisher/collections.mjs +7 -4
  18. package/lib/core/publisher/index.mjs +49 -27
  19. package/lib/core/publisher/notes.mjs +154 -7
  20. package/lib/core/publisher/questions.mjs +11 -7
  21. package/lib/core/publisher/restore.mjs +27 -2
  22. package/lib/core/social.mjs +1 -0
  23. package/lib/core/store.mjs +13 -0
  24. package/lib/core/wire.mjs +71 -20
  25. package/lib/device/admin/static.mjs +2 -2
  26. package/lib/device/admin/surface.mjs +11 -2
  27. package/lib/gateway/front-core.mjs +76 -6
  28. package/lib/gateway/gateway-core.mjs +40 -0
  29. package/lib/gateway/httpsig.mjs +7 -3
  30. package/lib/pod/actor.mjs +2 -2
  31. package/lib/pod/transport.mjs +9 -3
  32. package/package.json +1 -1
  33. package/run-agent.mjs +11 -0
  34. package/scripts/refresh-contexts.mjs +5 -0
  35. package/scripts/stage-site.mjs +1 -0
  36. package/web/admin/index.html +2 -0
  37. package/web/admin/upkeep.js +9 -1
  38. package/web/app/README.md +1 -1
  39. package/web/app/agent.mjs +12 -0
  40. package/web/app/boot.mjs +49 -10
  41. package/web/app/dist/boot.js +47 -7
  42. package/web/app/dist/boot.js.map +2 -2
  43. package/web/app/dist/sw.js +1604 -722
  44. package/web/app/dist/sw.js.map +4 -4
  45. package/web/app/index.html +16 -6
  46. package/web/app/site/_redirects +1 -0
  47. package/web/app/site/admin/index.html +2 -0
  48. package/web/app/site/admin/upkeep.js +9 -1
  49. package/web/app/site/boot.js +89 -37
  50. package/web/app/site/index.html +16 -6
  51. package/web/app/site/sw.js +1605 -723
  52. package/web/front/#new-account.html# +0 -43
  53. package/web/front/new-account.html~ +0 -50
@@ -16,7 +16,7 @@
16
16
  // HTTPS box is another. UN-DEPLOYED — nothing in FediPod runs it.
17
17
 
18
18
  import crypto from 'node:crypto';
19
- import { handleDelivery } from './gateway-core.mjs';
19
+ import { handleDelivery, handleOwnerPost } from './gateway-core.mjs';
20
20
  import { readCapped, safeFetch, isLoopbackHost } from '../shared/safefetch.mjs';
21
21
  import * as podRoot from '../pod/root.mjs';
22
22
  import * as podPolicy from '../pod/policy.mjs';
@@ -26,11 +26,17 @@ import { podBaseOfWebId } from '../pod/urls.mjs';
26
26
  // wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
27
27
  // friends), which a serverless front must never carry — it crashed the
28
28
  // deployed function before it answered its first request.
29
- const jrd = ({ handle, host, actor, aliases = [] }) => ({
29
+ const jrd = ({ handle, host, actor, aliases = [], page = null }) => ({
30
30
  subject: `acct:${handle}@${host}`,
31
31
  ...(aliases.length ? { aliases } : {}),
32
- links: [{ rel: 'self', type: 'application/activity+json', href: actor }],
32
+ links: [
33
+ { rel: 'self', type: 'application/activity+json', href: actor },
34
+ ...(page ? [{ rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: page }] : []),
35
+ ],
33
36
  });
37
+ // The public profile page of any account the front answers for lives on
38
+ // its pod, whether the address is the front's or the pod's own.
39
+ const profilePageOf = (rec) => (rec?.podHome ? rec.podHome + 'ap/profile.html' : null);
34
40
 
35
41
  const AP_CT = 'application/activity+json';
36
42
 
@@ -41,10 +47,13 @@ async function verifyPodToken(request, pathname, verifier) {
41
47
  const authz = request.headers.get('authorization');
42
48
  if (!authz) return null;
43
49
  try {
44
- const v = verifier || (await import('@solid/access-token-verifier')).createSolidTokenVerifier();
45
50
  const dpop = request.headers.get('dpop');
51
+ // Solid-OIDC binds the token to a key the client proves on every request;
52
+ // a token shown without the proof is one anyone who saw it could show.
53
+ if (!dpop) { console.log(`front: pod token without a DPoP proof refused on ${pathname}`); return null; }
54
+ const v = verifier || (await import('@solid/access-token-verifier')).createSolidTokenVerifier();
46
55
  const url = request.url;
47
- const { webid } = await v(authz, dpop ? { header: dpop, method: request.method, url } : undefined);
56
+ const { webid } = await v(authz, { header: dpop, method: request.method, url });
48
57
  return webid || null;
49
58
  } catch (e) {
50
59
  // Said aloud: a token the front will not take is otherwise a bare 401 to
@@ -597,8 +606,27 @@ async function route(request, ctx) {
597
606
  // is the alias, so a client signing in by the fronted address can find
598
607
  // the pod (and its login) without a lookup only the host could answer.
599
608
  const podActor = rec.inboxOnly ? [] : [rec.podHome + 'ap/actor'];
600
- return j(200, jrd({ handle: m[1], host: ctx.host, actor: rec.actorUrl, aliases: podActor }),
609
+ const out = j(200, jrd({ handle: m[1], host: ctx.host, actor: rec.actorUrl, aliases: podActor, page: profilePageOf(rec) }),
601
610
  'application/jrd+json');
611
+ // RFC 7033: a JRD is read from any origin.
612
+ out.headers['access-control-allow-origin'] = '*';
613
+ return out;
614
+ }
615
+
616
+ // The short address a person types: https://<host>/@handle opens the
617
+ // profile page, the way most Fediverse servers spell it. /@handle@pod-host
618
+ // is the same for an account whose address is its pod's, which the front
619
+ // holds under that full address — the form other servers use for a
620
+ // profile that lives elsewhere.
621
+ const at = /^\/@([^/@]+)(?:@([^/@]+))?$/u.exec(pathname);
622
+ if (at) {
623
+ const handle = decodeURIComponent(at[1]).toLowerCase();
624
+ const host = at[2] ? decodeURIComponent(at[2]).toLowerCase() : null;
625
+ const key = host && host !== String(ctx.host).toLowerCase() ? `${handle}@${host}` : handle;
626
+ const rec = await ctx.lookup(key);
627
+ const page = rec && profilePageOf(rec);
628
+ if (!page) return notFound();
629
+ return { status: 302, headers: { location: page, 'cache-control': 'no-store' }, body: '' };
602
630
  }
603
631
 
604
632
  const up = parseUserPath(pathname);
@@ -619,6 +647,48 @@ async function route(request, ctx) {
619
647
  return { status, headers: {}, body: '' };
620
648
  }
621
649
 
650
+ // Outbox: the owner's own post, from any client that speaks ActivityPub
651
+ // client-to-server (dokieli, say). Cross-origin by nature — such a client is
652
+ // a page on another site — so this route answers a preflight and carries
653
+ // CORS headers; the owner's pod token is the credential, so any origin may
654
+ // ask. GET stays a read: the pod's document for a fronted account, sent on
655
+ // to the pod for a door account whose documents live at their own address.
656
+ if (up.rest === 'ap/outbox') {
657
+ const cors = {
658
+ 'access-control-allow-origin': '*',
659
+ 'access-control-allow-methods': 'GET, POST, OPTIONS',
660
+ 'access-control-allow-headers': 'Authorization, DPoP, Content-Type, Slug, Link',
661
+ 'access-control-expose-headers': 'Location, Accept-Post',
662
+ 'access-control-max-age': '86400',
663
+ };
664
+ if (request.method === 'OPTIONS') {
665
+ // Accept-Post names JSON only: a client that reads it (dokieli) sends
666
+ // JSON-LD when HTML is not offered.
667
+ // body null, not '': a Response refuses any body on a 204, and the
668
+ // function adapter hands `body` straight to one.
669
+ return { status: 204, headers: { ...cors, allow: 'GET, POST, OPTIONS',
670
+ 'accept-post': 'application/ld+json, application/activity+json' }, body: null };
671
+ }
672
+ if (request.method === 'POST') {
673
+ const json = (status, obj, extra = {}) => ({ status,
674
+ headers: { ...cors, 'content-type': 'application/json', 'cache-control': 'no-store', ...extra },
675
+ body: JSON.stringify(obj) });
676
+ const webid = await verifyPodToken(request, pathname, ctx.verifier);
677
+ if (!webid) return json(401, { error: 'a Solid-OIDC token proving this account\'s owner is required' });
678
+ const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
679
+ if (!owner) return json(403, { error: 'this outbox belongs to its owner alone' });
680
+ const { status, reason, location } = await handleOwnerPost(request, identFor(rec),
681
+ { podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), ownerWebId: webid });
682
+ console.log(`door @${up.handle}: owner post → ${status} (${reason})`);
683
+ if (status !== 202) return json(status, { error: reason });
684
+ return json(202, { accepted: true, ...(location ? { object: location } : {}),
685
+ note: 'it goes out when your FediPod agent next runs' }, location ? { location } : {});
686
+ }
687
+ if (rec.inboxOnly && (request.method === 'GET' || request.method === 'HEAD')) {
688
+ return { status: 303, headers: { ...cors, location: rec.podHome + 'ap/outbox', 'cache-control': 'no-store' }, body: '' };
689
+ }
690
+ }
691
+
622
692
  // Everything else is a public GET, served by reading the user's pod and
623
693
  // rewriting pod ids to the front. The actor also gets its handle and inbox
624
694
  // fixed to the front so a consumer cross-checks it consistently.
@@ -110,4 +110,44 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
110
110
  return { status: 202, reason: v.verified ? 'verified' : 'buffered-unverified' };
111
111
  }
112
112
 
113
+ // The outbox door: the owner's own post, taken on their behalf.
114
+ //
115
+ // A client such as dokieli POSTs an activity — or a bare object — to the
116
+ // outbox address the actor advertises. The door holds no key and mints nothing:
117
+ // it checks that the token proves the account's owner (the caller has already
118
+ // done that and hands in `owner`), writes the bytes into the pod inbox exactly
119
+ // as verified mail is written, and stamps them with a receipt whose method is
120
+ // `c2s` and whose actor is this account. The drain hands such an item to the
121
+ // client-to-server dispatcher, which publishes and delivers it — so the post
122
+ // goes out when the agent next runs, the same way inbound mail is read.
123
+ //
124
+ // `slug` is the name the client asked for its new document. It rides in the
125
+ // receipt so the dispatcher can use it, and it is what lets the door answer a
126
+ // Location before anything exists: the object will live at notesPrefix+slug
127
+ // unless that name is taken, in which case the agent mints another.
128
+ export const SLUG_OK = /^[A-Za-z0-9._-]{1,64}$/u;
129
+ export const safeSlug = (s) => (typeof s === 'string' && SLUG_OK.test(s) && !/^\.+$/u.test(s) ? s : null);
130
+
131
+ export async function handleOwnerPost(request, ident, { podPut, ownerWebId, maxBytes = DEFAULT_MAX_BYTES } = {}) {
132
+ if (!ident.hmacSecret) return { status: 409, reason: 'this account has no door secret — attach it again' };
133
+ let raw;
134
+ try { raw = await request.text(); } catch { return { status: 400, reason: 'unreadable body' }; }
135
+ if (Buffer.byteLength(raw) > maxBytes) return { status: 413, reason: 'too large' };
136
+ let doc;
137
+ try { doc = JSON.parse(raw); } catch { return { status: 400, reason: 'unparsable JSON' }; }
138
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc) || !doc.type) {
139
+ return { status: 400, reason: 'a typed ActivityStreams object is required' };
140
+ }
141
+ const slug = safeSlug(request.headers.get('slug'));
142
+ const receipt = signReceipt({
143
+ v: 1, verified: true, method: 'c2s', keyId: ownerWebId || null, actor: ident.actorUrl,
144
+ checks: ['owner-token'], reason: 'owner', gateway: ident.gatewayWebId, ...(slug ? { slug } : {}),
145
+ }, ident.hmacSecret);
146
+ const hash = sha256hex(raw);
147
+ const okA = await inbox.appendVerifiedDelivery(podPut, ident.inboxUrl, hash, raw);
148
+ if (!okA) return { status: 502, reason: 'pod inbox write failed' };
149
+ await inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt);
150
+ return { status: 202, reason: 'accepted', location: slug && ident.notesPrefix ? ident.notesPrefix + slug : null };
151
+ }
152
+
113
153
  export const _internal = { isBlocked, concernsUsAtEdge, httpUrl, sha256hex };
@@ -58,10 +58,14 @@ export async function verifyHttpSignature(request, { documentLoader, keyCache, t
58
58
  } catch {
59
59
  result = null; // the library itself failed — no more informative than a bad signature
60
60
  }
61
+ // Which signature the sender used: RFC 9421 carries a Signature-Input
62
+ // header beside Signature; the cavage draft carries Signature alone. The
63
+ // library picks the verifier by the same header.
64
+ const method = request.headers.has('signature-input') ? 'rfc9421' : 'draft-cavage';
61
65
  if (result?.verified) {
62
66
  const key = result.key;
63
67
  return {
64
- verified: true, method: 'draft-cavage',
68
+ verified: true, method,
65
69
  keyId: key.id?.href ?? null, actor: key.ownerId?.href ?? null, reason: null,
66
70
  checks: { signature: true, digest: true, dateSkew: true, keyFetched: true },
67
71
  };
@@ -75,12 +79,12 @@ export async function verifyHttpSignature(request, { documentLoader, keyCache, t
75
79
  }
76
80
  if (kind === 'keyFetchError') {
77
81
  return {
78
- verified: false, method: 'draft-cavage', keyId: null, actor: null,
82
+ verified: false, method, keyId: null, actor: null,
79
83
  reason: 'key-unfetchable', checks: { signature: false, keyFetched: false },
80
84
  };
81
85
  }
82
86
  return {
83
- verified: false, method: 'draft-cavage', keyId: null, actor: null,
87
+ verified: false, method, keyId: null, actor: null,
84
88
  reason: 'bad-signature', checks: { signature: false },
85
89
  };
86
90
  }
package/lib/pod/actor.mjs CHANGED
@@ -69,8 +69,8 @@ export async function writeProfilePage(pod, urls, html) {
69
69
  * parsed graph does not mention the WebID, and patches exactly the statements
70
70
  * involved rather than rewriting a document full of things that are not ours.
71
71
  */
72
- export function linkInWebIdProfile(pod, { actorUrl, accountName, kind = 'person' }) {
73
- return pod.linkAccountInProfile({ actorUrl, accountName, kind });
72
+ export function linkInWebIdProfile(pod, { actorUrl, accountName, kind = 'person', outbox = null }) {
73
+ return pod.linkAccountInProfile({ actorUrl, accountName, kind, outbox });
74
74
  }
75
75
 
76
76
  // ---- anyone at all ----
@@ -407,7 +407,7 @@ export class PodTransport {
407
407
  * says all of it. The parsed graph must mention the WebID before anything is
408
408
  * written back — an empty or foreign body must never become the new profile.
409
409
  */
410
- async linkAccountInProfile({ actorUrl, accountName, kind = 'person' }) {
410
+ async linkAccountInProfile({ actorUrl, accountName, kind = 'person', outbox = null }) {
411
411
  const docUrl = this.webId.split('#')[0];
412
412
  const res = await this.fetch(docUrl, { headers: { accept: 'text/turtle' } });
413
413
  if (res.status >= 400) throw new Error(`[${this.label}] GET ${docUrl} → ${res.status}`);
@@ -424,11 +424,17 @@ export class PodTransport {
424
424
  [actor, RDF('type'), FOAF('OnlineAccount')],
425
425
  [actor, RDF('type'), kind === 'group' ? AS('Group') : AS('Person')],
426
426
  [actor, FOAF('accountName'), $rdf.literal(accountName)],
427
+ // Where a Solid client posts on this person's behalf (`as:outbox` on the
428
+ // WebID is what dokieli reads); only where a door exists to take it.
429
+ ...(outbox ? [[me, AS('outbox'), $rdf.sym(outbox)]] : []),
427
430
  ];
428
431
  const missing = wanted.filter(([s, p, o]) => !g.holds(s, p, o, doc));
429
432
  // A handle change leaves the old accountName behind; ours is replaced.
430
- const stale = g.statementsMatching(actor, FOAF('accountName'), null, doc)
431
- .filter(st => st.object.value !== accountName);
433
+ // Likewise an outbox that moved.
434
+ const stale = [
435
+ ...g.statementsMatching(actor, FOAF('accountName'), null, doc).filter(st => st.object.value !== accountName),
436
+ ...(outbox ? g.statementsMatching(me, AS('outbox'), null, doc).filter(st => st.object.value !== outbox) : []),
437
+ ];
432
438
  if (!missing.length && !stale.length) return false;
433
439
  // A patch touches these statements and nothing else. Rewriting the whole
434
440
  // profile re-serialises statements that are not ours — the OIDC issuer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod",
3
- "version": "1.7.0",
3
+ "version": "1.10.0",
4
4
  "description": "Standalone single-actor ActivityPub agent whose wire face, RDF truth and state all live on a Solid pod (CSS). Bundles a Phanpy UI and a Mastodon client-API facade.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/run-agent.mjs CHANGED
@@ -37,6 +37,7 @@ import { RemotePod } from './lib/device/remote.mjs';
37
37
  import { Deliverer } from './lib/core/deliver.mjs';
38
38
  import { Publisher } from './lib/core/publisher/index.mjs';
39
39
  import { Intake } from './lib/core/intake/index.mjs';
40
+ import { C2S } from './lib/client/c2s.mjs';
40
41
  import { TagFeed } from './lib/connections/tagfeed.mjs';
41
42
  import { ImportWorker } from './lib/connections/import.mjs';
42
43
  import { Atproto } from './lib/connections/atproto.mjs';
@@ -122,6 +123,7 @@ export class Agent {
122
123
  update: this.updateInfo || null,
123
124
  inboxCooldownFor: this.intake?.drainCooldownUntil
124
125
  ? Math.max(0, Math.round((this.intake.drainCooldownUntil - Date.now()) / 1000)) : 0,
126
+ stateSkipped: this.store.lastSkipped || [],
125
127
  };
126
128
  }
127
129
 
@@ -323,12 +325,14 @@ export class Agent {
323
325
  actorId: this.urls.actor, edPrivate: keys.edPrivate,
324
326
  proofKeyId: assertionKeyId(this.urls),
325
327
  log: this.log, passive: this.viewer,
328
+ onGone: () => this.publisher.publishCollections({ followers: true }),
326
329
  });
327
330
  this.publisher = new Publisher({
328
331
  config, remote: this.remote, store: this.store,
329
332
  deliverer: this.deliverer, publicKeyPem: keys.rsaPublicPem,
330
333
  assertionKey: keys.edPublicMultibase, log: this.log,
331
334
  resolveMention: (h) => resolveHandle(this, h),
335
+ resolveActor: (u) => this.intake.fetchAP(u),
332
336
  // Inside a pod server the client surface answers on the pod's own
333
337
  // origin, so it can be advertised. Standalone it is on loopback, and
334
338
  // naming it in a world-readable actor would send clients nowhere.
@@ -336,11 +340,15 @@ export class Agent {
336
340
  });
337
341
  // Intake is constructed even for viewers — its signed fetchAP powers
338
342
  // search/deref; start() (draining) is active-only.
343
+ // The dispatcher the admin surface also builds; this one is for what the
344
+ // Gateway's outbox door took on the owner's behalf and the drain finds.
345
+ this.c2s = new C2S({ agent: this, log: this.log });
339
346
  this.intake = new Intake({
340
347
  config, urls: this.urls, remote: this.remote, store: this.store,
341
348
  deliverer: this.deliverer, publisher: this.publisher, log: this.log, lease: this.lease,
342
349
  archive: this.privateStorage(cred, 'archive'),
343
350
  push: !this.embedded, pollSeconds: this.pollSeconds || null,
351
+ ownerPost: (a, o) => this.c2s.dispatch(a, o),
344
352
  });
345
353
  // The CSV-import worker: paced, resumable, armed only while active.
346
354
  this.importer?.stop();
@@ -514,6 +522,9 @@ export class Agent {
514
522
  this.viewer = false;
515
523
  clearInterval(this.refreshTimer);
516
524
  if (promoted) await this.refreshBeforeActing();
525
+ // Own posts the outbox names and the timeline index lacks come back here,
526
+ // before anything acts on the index.
527
+ await this.publisher.healStatuses().catch(e => this.log(`healing the timeline index: ${e.message}`));
517
528
  this.lease.onLost = () => this.demote();
518
529
  this.lease.startRenewal();
519
530
  this.deliverer.startQueue();
@@ -24,13 +24,18 @@ for (const m of module_.matchAll(/"([^"]+)":\s*(\w+),/gu)) {
24
24
  if (idents[m[2]]) map[m[1]] = idents[m[2]];
25
25
  }
26
26
 
27
+ // Held beside fedify's set, not from it: fetched once by hand and kept.
28
+ const EXTRA = { 'http://www.w3.org/ns/anno.jsonld': 'anno.json' };
29
+
27
30
  for (const file of Object.values(map)) fs.copyFileSync(path.join(src, file), path.join(dest, file));
31
+ Object.assign(map, EXTRA);
28
32
  fs.writeFileSync(path.join(dest, 'map.json'), `${JSON.stringify(map, null, 2)}\n`);
29
33
 
30
34
  const names = Object.fromEntries(Object.keys(map).map((u, i) => [u, `ctx${i}`]));
31
35
  const header = fs.readFileSync(path.join(dest, 'index.mjs'), 'utf8').split('\nimport ')[0];
32
36
  let out = `${header}\n`;
33
37
  for (const [url, file] of Object.entries(map)) out += `import ${names[url]} from './${file}' with { type: 'json' };\n`;
38
+ // (EXTRA entries ride along in `map` above, so index.mjs and map.json keep them.)
34
39
  out += '\n/** URL → the context document itself. Nothing outside this map is ever resolved. */\nexport const CONTEXTS = {\n';
35
40
  for (const url of Object.keys(map)) out += ` '${url}': ${names[url]},\n`;
36
41
  out += '};\n';
@@ -94,6 +94,7 @@ fs.writeFileSync(path.join(site, '_redirects'), [
94
94
  '/api/agent /.netlify/functions/front 200',
95
95
  '/u/* /.netlify/functions/front 200',
96
96
  '/.well-known/* /.netlify/functions/front 200',
97
+ '/@* /.netlify/functions/front 200',
97
98
  '/install /.netlify/functions/front 200',
98
99
  '/run /.netlify/functions/front 200',
99
100
  '/roster /.netlify/functions/front 200',
@@ -275,6 +275,8 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
275
275
 
276
276
  <section id="pane-identity" hidden>
277
277
  <dl id="facts"></dl>
278
+ <!-- State documents the agent could not read on its last load, if any. -->
279
+ <p class="err" id="state-skipped" role="alert" hidden></p>
278
280
  <!-- Declared here, and render() puts these on the kind row — the row they
279
281
  belong to is generated, so this is the only place they can be written
280
282
  down. Each reads as the setting it would change, so what it shows IS the
@@ -145,8 +145,16 @@ const INBOX_PROMPT_AT = 500;
145
145
  let dismissed = false;
146
146
 
147
147
  async function renderInbox() {
148
- if (dismissed) return;
149
148
  const { json: st } = await api('/status');
149
+ // A state document the last load could not read is a timeline or a contact
150
+ // list quietly missing; the page says which, under the facts.
151
+ const skipped = st?.stateSkipped || [];
152
+ const line = $('state-skipped');
153
+ line.hidden = !skipped.length;
154
+ line.textContent = skipped.length
155
+ ? `${skipped.length} state document${skipped.length === 1 ? '' : 's'} could not be read on the last load: ${skipped.join(', ')}.`
156
+ : '';
157
+ if (dismissed) return;
150
158
  const box = st?.inbox;
151
159
  const panel = $('pane-inbox');
152
160
  if (!box || box.count < INBOX_PROMPT_AT) { panel.hidden = true; return; }
package/web/app/README.md CHANGED
@@ -7,7 +7,7 @@ See `claude/plans/browser-agent.md` for the whole design and status.
7
7
  |---|---|
8
8
  | `pod-auth.mjs` | The pod side of sign-in, browser-native: create a CSS account + pod, mint a client credential, and a DPoP-bound `fetch` that writes to the pod. The twin of `lib/device/account.mjs` + `vendor/idp-grant.cjs`. |
9
9
  | `keystore.mjs` | WebCrypto RSA/Ed25519 key generation, and wrapping the keys under the account password (PBKDF2-SHA256 + AES-GCM-256). The pod holds only the wrapped form, so the pod's host cannot sign as you. |
10
- | `keys-browser.mjs` | Importing a keys record for signing, and finding one: this browser's opened copy in IndexedDB first, else the pod's. A wrapped one the browser has not opened yet raises `KeyPasswordNeeded`, which `boot.mjs` answers with the unlock pane — once per browser. |
10
+ | `keys-browser.mjs` | Importing a keys record for signing, and finding one: this browser's opened copy in IndexedDB first, else the pod's. A wrapped one the browser has not opened yet raises `KeyPasswordNeeded`, which `boot.mjs` answers with the unlock pane — once per browser. The same pane offers a new key wrapped under the password used now, for someone who no longer has the sign-up password. |
11
11
  | `signup.mjs` | The `fedipod setup` flow, in the browser, up to publish: account, pod, credential, keys locked on the pod (owner-only ACL written *before* the key). Produces the credential/keys/config shapes the agent already reads. |
12
12
  | `shims/fedify-sig.mjs` | Browser stand-in for `@fedify/fedify/sig` (which will not bundle for a browser). `sign()` returns signed headers as data for the relay; `signRequest()` wraps it Fedify-shaped. Proven byte-identical to Fedify. |
13
13
  | `shims/node-crypto.mjs` | Browser stand-in for `node:crypto` — the small synchronous slice the agent uses, via crypto-browserify, plus native WebCrypto. |
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?.();
@@ -219,6 +223,7 @@ export class BrowserAgent {
219
223
  // The relay finds the account by the front's own key for it, which for a
220
224
  // mail-door account is the full address, not the bare handle.
221
225
  handle: doorKeyOf(config.gateway?.url) || config.handle, sessionFetch: session.fetch,
226
+ onGone: () => this.publisher.publishCollections({ followers: true }),
222
227
  });
223
228
 
224
229
  this.publisher = new Publisher({
@@ -228,6 +233,7 @@ export class BrowserAgent {
228
233
  // gives its publisher. Without it no mention from the browser ever
229
234
  // resolved: a direct message went to nobody, a mention notified no one.
230
235
  resolveMention: (h) => resolveHandle(this, h),
236
+ resolveActor: (u) => this.intake.fetchAP(u),
231
237
  });
232
238
 
233
239
  // The Bluesky connection, stamped to this actor. The same client the Node
@@ -257,9 +263,14 @@ export class BrowserAgent {
257
263
  // read-only until the owner acts on it and it takes over. Written with fresh
258
264
  // fetches, never the cached store. Passed into Intake so the drain checks it.
259
265
  this.lease = new Lease({ url: this.urls.state + 'lease.json', fetchImpl: podFetch, log: this.log });
266
+ // The client-to-server dispatcher, here only for what the Gateway's
267
+ // outbox door takes on the owner's behalf: the browser answers no
268
+ // /ap/outbox of its own.
269
+ this.c2s = new C2S({ agent: this, log: this.log });
260
270
  this.intake = new Intake({
261
271
  config: this.store.getConfig(), urls: this.urls, remote: this.remote,
262
272
  store: this.store, deliverer: this.deliverer, publisher: this.publisher, log: this.log, push: true, lease: this.lease,
273
+ ownerPost: (a, o) => this.c2s.dispatch(a, o),
263
274
  });
264
275
  // The Mastodon facade the service worker serves.
265
276
  //
@@ -356,6 +367,7 @@ export class BrowserAgent {
356
367
  podRequests: this.remote?.stats?.() || null,
357
368
  update: null,
358
369
  inboxCooldownFor: 0,
370
+ stateSkipped: this.store?.lastSkipped || [],
359
371
  };
360
372
  }
361
373
 
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,6 +33208,7 @@ 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, []);
@@ -33737,8 +33744,7 @@ async function bootWorker({ reset = false } = {}) {
33737
33744
  worker.postMessage({ type: "boot", frontOrigin: location.origin });
33738
33745
  await booted;
33739
33746
  }
33740
- window.fedipodUnlock = async (password) => {
33741
- if (!password) throw new Error("Enter your account password.");
33747
+ async function readAccountState() {
33742
33748
  const session = await getSession();
33743
33749
  if (!session) throw new Error("Sign in first.");
33744
33750
  const podFromWebId = podBaseOfWebId(session.webId);
@@ -33750,13 +33756,28 @@ window.fedipodUnlock = async (password) => {
33750
33756
  readConfig(remote, urls),
33751
33757
  readWrappedKeys(remote, urls)
33752
33758
  ]);
33753
- 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");
33754
33767
  if (!isKeyEnvelope(doc)) throw new Error("this account's key is not locked \u2014 nothing to unlock");
33755
33768
  const rec = await unwrapKeys(doc, password);
33756
- const actorUrl = `${cfg.remotePod}${cfg.root || AP_ROOT}ap/actor`;
33757
33769
  await cacheOpenedKeys(actorUrl, rec);
33758
33770
  await bootWorker();
33759
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
+ };
33760
33781
  window.fedipodSignup = async ({ onStep, ...answers }) => {
33761
33782
  await signUp(answers, { onStep, frontOrigin: location.origin });
33762
33783
  const { authorizationUrl } = await beginLogin({ issuer: answers.issuer, redirectUri: REDIRECT });
@@ -33861,6 +33882,25 @@ if (typeof document !== "undefined") (async () => {
33861
33882
  $("unlock-password")?.addEventListener("keydown", (e) => {
33862
33883
  if (e.key === "Enter") doUnlock();
33863
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);
33864
33904
  if (params.has("signout") || params.has("add")) {
33865
33905
  if (params.has("signout")) {
33866
33906
  try {