fedipod 1.50.2 → 1.55.2

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 (40) hide show
  1. package/README.md +2 -1
  2. package/gateway.md +25 -0
  3. package/gui.md +11 -3
  4. package/lib/client/masto/timelines.mjs +5 -0
  5. package/lib/connections/acctfeed.mjs +4 -1
  6. package/lib/connections/tagfeed.mjs +4 -1
  7. package/lib/core/intake/index.mjs +35 -2
  8. package/lib/core/intake/verify.mjs +3 -1
  9. package/lib/core/pod-only.mjs +4 -2
  10. package/lib/core/publisher/index.mjs +19 -5
  11. package/lib/gateway/account-agent.mjs +11 -5
  12. package/lib/gateway/check-letter.mjs +123 -0
  13. package/lib/gateway/connected-feeds.mjs +116 -0
  14. package/lib/gateway/copy.mjs +19 -0
  15. package/lib/gateway/front-core.mjs +5 -3
  16. package/lib/gateway/held-mail.mjs +25 -1
  17. package/lib/gateway/keeper.mjs +2 -1
  18. package/lib/gateway/masto-gateway.mjs +47 -7
  19. package/lib/surface/routes/owner.mjs +7 -4
  20. package/package.json +3 -3
  21. package/scripts/stage-site.mjs +2 -3
  22. package/vendor/sanitize-html.cjs +358 -171
  23. package/web/admin/bar.js +45 -0
  24. package/web/admin/group.js +17 -0
  25. package/web/admin/index.html +12 -8
  26. package/web/admin/record.js +19 -41
  27. package/web/admin/upkeep.js +3 -0
  28. package/web/app/README.md +3 -3
  29. package/web/app/admin-facade.mjs +19 -16
  30. package/web/app/agent.mjs +65 -2
  31. package/web/app/atproto-browser.mjs +28 -22
  32. package/web/app/deliver-relay.mjs +2 -1
  33. package/web/app/dist/boot.js +1 -0
  34. package/web/app/dist/boot.js.map +2 -2
  35. package/web/app/dist/sw.js +567 -265
  36. package/web/app/dist/sw.js.map +4 -4
  37. package/web/app/fediacct-browser.mjs +32 -30
  38. package/web/app/idb-kv.mjs +4 -4
  39. package/web/app/shims/prelude.js +1 -0
  40. package/web/app/sw-src.mjs +44 -3
@@ -211,7 +211,31 @@ export async function routeHereApi(request, pathname, ctx, deps) {
211
211
  const flushed = await flushHeld(ctx, handle, rec)
212
212
  .catch((e) => { console.log(`here @${handle}: held mail not delivered: ${e?.message || e}`); return 0; });
213
213
  // `hold`: with it off, a browser working from a copy goes back to the pod.
214
- return j(200, { ok: true, handle, flushed, hold: holdOn(ctx) });
214
+ // `missingLetter`: the gateway's test letter has not arrived (check-letter.mjs).
215
+ const missing = await deps.missingLetter?.(handle).catch(() => null);
216
+ return j(200, { ok: true, handle, flushed, hold: holdOn(ctx), ...(missing ? { missingLetter: missing } : {}) });
217
+ }
218
+
219
+ // FediPod could not read some of its owner's mail: they are told on the phones
220
+ // and browsers they signed up for notifications, in FediPod's own words.
221
+ // Proved like /api/here. Returns a response, or null for other paths.
222
+ export async function routeAlertApi(request, pathname, ctx, deps) {
223
+ if (pathname !== '/api/alert') return null;
224
+ const { j } = deps;
225
+ if (request.method === 'OPTIONS') return deps.apiPreflight();
226
+ if (request.method !== 'POST') return null;
227
+ let body;
228
+ try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
229
+ const handle = String(body.handle || '').toLowerCase();
230
+ const text = typeof body.text === 'string' ? body.text.trim().slice(0, 200) : '';
231
+ if (!text) return j(400, { error: 'text required' });
232
+ const rec = await ctx.lookup(handle);
233
+ if (!rec) return j(404, { error: 'no such account' });
234
+ const who = await deps.provedOwner(request, pathname, ctx, rec, deps);
235
+ if (who.error) return who.error;
236
+ if (!ctx.startPush || !await ctx.pushWanted?.(handle).catch(() => false)) return j(200, { ok: true, handle, pushing: 0 });
237
+ await ctx.startPush(handle, { alert: text }).catch((e) => console.log(`alert @${handle}: not started: ${e?.message || e}`));
238
+ return j(200, { ok: true, handle, pushing: 1 });
215
239
  }
216
240
 
217
241
  // FediPod open in a browser reads its own mail, so nothing is held for the
@@ -19,6 +19,7 @@ import { flushHeld, heldEntries } from './held-mail.mjs';
19
19
  import { publishDue, nextDue } from '../core/scheduled.mjs';
20
20
  import { lockCopy } from './copy.mjs';
21
21
  import { reachAccount, settle, stateAndLease, actingAgent } from './account-agent.mjs';
22
+ import { checkLettersFor } from './check-letter.mjs';
22
23
 
23
24
  export { keeperSession };
24
25
 
@@ -53,7 +54,7 @@ export async function keepOnce(ctx, handle, rec, { log = console.log, session =
53
54
  if (!await lease.acquire()) return { skipped: 'another device is acting on this account', flushed, retry: 'soon' };
54
55
  try {
55
56
  await store.load();
56
- const agent = await actingAgent(at, store, lease, { log });
57
+ const agent = await actingAgent(at, store, lease, { log, checkLetters: checkLettersFor(ctx, handle) });
57
58
  if (agent.skipped) return { skipped: agent.skipped, flushed };
58
59
  let taken = 0;
59
60
  if (at.inCopy && ctx.listHeld) {
@@ -48,6 +48,9 @@ import { CopyStorage, lockCopy, keptNow, keptBefore, holdOn, waitOutClosing, GAT
48
48
  import { HttpStorage } from '../core/storage.mjs';
49
49
  import * as podInbox from '../pod/inbox.mjs';
50
50
  import { heldEntries } from './held-mail.mjs';
51
+ import { checkLettersFor } from './check-letter.mjs';
52
+ import { readConnectedForApp } from './connected-feeds.mjs';
53
+ import { TagFeed } from '../connections/tagfeed.mjs';
51
54
  import { noteOpened } from './quiet.mjs';
52
55
  import { Push } from '../client/webpush.mjs';
53
56
  import webpush from 'web-push';
@@ -282,6 +285,8 @@ function readingAgent(at, store, acting, log) {
282
285
  const publisher = new Publisher({ config, remote: at.remote, store, deliverer: null, publicKeyPem: null, log });
283
286
  return {
284
287
  store, publisher, remote: at.remote, config, viewer: false, copy: null,
288
+ // The hashtags followed, for the app to show; FediPod in the browser reads them.
289
+ tagfeed: new TagFeed({ store, intake: null, log }),
285
290
  configured: () => !!store.getConfig(), requestTakeover: async () => true, onScheduled: () => {},
286
291
  intake: { fetchAP: async (u) => (await acting()).intake.fetchAP(u) },
287
292
  };
@@ -334,7 +339,7 @@ async function readHeld(ctx, handle, rec, { log = console.log, takeover = false,
334
339
  const { store, lease } = stateAndLease(ctx, handle, at, { log });
335
340
  if (!await lease.acquire() && !(takeover && await lease.takeover())) return 0;
336
341
  await store.load();
337
- const agent = await actingAgent(at, store, lease, { log });
342
+ const agent = await actingAgent(at, store, lease, { log, checkLetters: checkLettersFor(ctx, handle) });
338
343
  if (agent.skipped) return 0;
339
344
  const pushes = [];
340
345
  if (await pushWanted(ctx, handle)) {
@@ -433,17 +438,50 @@ export async function pushMade(ctx, handle, rec, ids, { log = console.log, now =
433
438
  return fresh.length;
434
439
  }
435
440
 
441
+ /**
442
+ * For FediPod, when it could not read some of its owner's mail: one
443
+ * notification, in its own words, to every phone and browser signed up for
444
+ * them. Read from the account as it is now, without acting.
445
+ */
446
+ export async function pushAlert(ctx, handle, rec, text, { log = console.log } = {}) {
447
+ if (!text || !await pushWanted(ctx, handle)) return 0;
448
+ await waitOutClosing(ctx.copyKv, handle);
449
+ const at = await reachAccount(ctx, handle, rec, { log });
450
+ if (at.skipped) return 0;
451
+ const store = await readingStore(ctx, handle, at, log);
452
+ const agent = readingAgent(at, store, async () => { throw new Error('only reading'); }, log);
453
+ if (!agent) return 0;
454
+ if (!at.inCopy) await store.refresh(['webpush.json'], { ifChanged: true });
455
+ const api = facade(agent, null, log, await vapidKeys(ctx.mastoKv), pushContact(ctx), { keep: false });
456
+ const id = crypto.randomBytes(8).toString('hex');
457
+ await api.push.notify({ id, type: 'mention' }, {
458
+ notification_id: id, notification_type: 'mention', preferred_locale: 'en',
459
+ title: 'FediPod', body: String(text).slice(0, 200), icon: '',
460
+ });
461
+ log(`@${handle}: alert pushed`);
462
+ return 1;
463
+ }
464
+
436
465
  // A request from an app, answered by the account's facade over its copy, or
437
466
  // with the hold off, over the pod.
438
467
  async function answerForAccount(request, url, ctx, tok, rec, log, tokenHash) {
439
468
  const handle = tok.handle;
440
469
  forgetOld();
441
470
  const repeatKey = request.method === 'GET' && REPEATABLE.test(url.pathname) ? `${handle}\n${tokenHash}\n${url.pathname}${url.search}` : null;
471
+ // An app checking in: new mail, and the owner's connected accounts when due,
472
+ // read in beside this answer for the app's next check.
473
+ const checksIn = request.method === 'GET' && /^\/api\/v[12]\/(timelines\/home|notifications)$/u.test(url.pathname);
474
+ const readConnected = () => readConnectedForApp(ctx, handle, rec, { log })
475
+ .then((n) => { if (n) forget(handle); })
476
+ .catch((e) => log(`app @${handle}: connected accounts: ${e.message}`));
442
477
  // The copy's listing as it was: the same answer, without opening the copy.
443
478
  // A copy written to the pod and deleted since lists differently.
444
479
  if (repeatKey && holdOn(ctx)) {
445
480
  const again = await repeatAnswer(ctx, handle, repeatKey, { inCopy: true });
446
- if (again) return again;
481
+ if (again) {
482
+ if (checksIn) ctx.waitUntil?.(readConnected());
483
+ return again;
484
+ }
447
485
  }
448
486
  // A copy the round is writing to the pod and deleting is waited out; it is
449
487
  // made again below.
@@ -457,9 +495,11 @@ async function answerForAccount(request, url, ctx, tok, rec, log, tokenHash) {
457
495
  if (!made.ok) return json(made.status === 409 ? 503 : made.status, { error: made.why });
458
496
  at.inCopy = true;
459
497
  }
460
- // New mail read in beside this answer, for the app's next check.
461
- if (!writes && /^\/api\/v[12]\/(timelines\/home|notifications)$/u.test(url.pathname)) {
462
- ctx.waitUntil?.(readHeldForApp(ctx, handle, rec, { log }).catch((e) => log(`app @${handle}: held mail: ${e.message}`)));
498
+ if (checksIn) {
499
+ ctx.waitUntil?.((async () => {
500
+ await readHeldForApp(ctx, handle, rec, { log }).catch((e) => log(`app @${handle}: held mail: ${e.message}`));
501
+ await readConnected();
502
+ })());
463
503
  }
464
504
  // With the hold off, the pod's listing, after the inbox has been looked at.
465
505
  if (repeatKey && !holdOn(ctx)) {
@@ -475,7 +515,7 @@ async function answerForAccount(request, url, ctx, tok, rec, log, tokenHash) {
475
515
  const acting = () => (actingP ||= (async () => {
476
516
  const s = stateAndLease(ctx, handle, at, { log });
477
517
  await s.store.load();
478
- const a = await actingAgent(at, s.store, s.lease, { log });
518
+ const a = await actingAgent(at, s.store, s.lease, { log, checkLetters: checkLettersFor(ctx, handle) });
479
519
  if (a.skipped) throw new Error(a.skipped);
480
520
  return a;
481
521
  })());
@@ -505,7 +545,7 @@ async function answerForAccount(request, url, ctx, tok, rec, log, tokenHash) {
505
545
  lease = s.lease;
506
546
  const store = s.store;
507
547
  await store.load();
508
- const agent = await actingAgent(at, store, lease, { log });
548
+ const agent = await actingAgent(at, store, lease, { log, checkLetters: checkLettersFor(ctx, handle) });
509
549
  if (agent.skipped) return json(503, { error: agent.skipped });
510
550
  const api = facade(agent, token, log, await vapidKeys(ctx.mastoKv), pushContact(ctx));
511
551
  const handled = await api.handle(b.req, b.res, url.pathname, url);
@@ -13,7 +13,7 @@ import { AGENT_VERSION } from '../static.mjs';
13
13
  const PERMANENT_CONFIG = ['handle', 'remotePod', 'issuer', 'root', 'kind'];
14
14
  // Config the actor document carries, so a change is not real until it is
15
15
  // republished.
16
- const WIRE_CONFIG = ['name', 'summary', 'icon', 'image', 'fields', 'approveJoins', 'moderators'];
16
+ const WIRE_CONFIG = ['name', 'summary', 'icon', 'image', 'fields', 'approveJoins', 'moderators', 'autoAcceptFollows'];
17
17
 
18
18
  export async function get(p, ctx, req, res, url) { // eslint-disable-line no-unused-vars
19
19
  const { agent, log, allowed, embedded, port, publicOrigin, versionOnDisk, isGroup } = ctx; // eslint-disable-line no-unused-vars
@@ -70,6 +70,7 @@ export async function get(p, ctx, req, res, url) { // eslint-disable-line no-u
70
70
  approveJoins: !!cfg.approveJoins, review: !!cfg.review,
71
71
  aliases: cfg.aliases || [],
72
72
  autoAcceptFollows: cfg.autoAcceptFollows !== false,
73
+ tagsInHome: cfg.tagsInHome !== false,
73
74
  quiescedAt: cfg.quiescedAt || null, movedTo: cfg.movedTo || null,
74
75
  // Per-machine, so it comes from the credential file, not pod config —
75
76
  // and it is a fact here, not a setting: moving it means moving data,
@@ -189,10 +190,12 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
189
190
  if ('approveJoins' in body) cfg.approveJoins = !!body.approveJoins;
190
191
  if ('review' in body) cfg.review = !!body.review;
191
192
  // A person's queue gate: on, and an inbound Follow is accepted on
192
- // arrival instead of waiting in the requests queue. Local behavior,
193
- // not on the wire — but intake reads the LIVE config, so it is
194
- // patched below whether or not anything republishes.
193
+ // arrival instead of waiting in the requests queue. The actor says
194
+ // which (manuallyApprovesFollowers), and intake reads the LIVE config,
195
+ // so it is patched below as well as republished.
195
196
  if ('autoAcceptFollows' in body) cfg.autoAcceptFollows = !!body.autoAcceptFollows;
197
+ // Whether hashtag posts appear in Home; read as each timeline is answered.
198
+ if ('tagsInHome' in body) cfg.tagsInHome = !!body.tagsInHome;
196
199
  // The moderator roster (FEP-1b12): actor IRIs whose federated
197
200
  // moderation asks are queued, published as attributedTo.
198
201
  let rosterDiff = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod",
3
- "version": "1.50.2",
3
+ "version": "1.55.2",
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",
@@ -55,7 +55,7 @@
55
55
  "install-service": "node bin/fedipod.mjs install-service",
56
56
  "uninstall-service": "node bin/fedipod.mjs uninstall-service",
57
57
  "prepublishOnly": "npm test",
58
- "test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/copy-mode-test.mjs && node claude/smoke-tests/hold-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node --test claude/smoke-tests/forum-signup-form.test.mjs claude/smoke-tests/door-verify.test.mjs claude/smoke-tests/oauth-signin.test.mjs claude/smoke-tests/app-registration.test.mjs claude/smoke-tests/push-signout.test.mjs claude/smoke-tests/token-fetches.test.mjs claude/smoke-tests/signin-hardening.test.mjs claude/smoke-tests/load-fixes.test.mjs claude/smoke-tests/unread-docs.test.mjs claude/smoke-tests/post-text-once.test.mjs && node scripts/check-sizes.mjs && node scripts/test-dependents.mjs && node claude/validation/browser-agent/all.mjs",
58
+ "test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/copy-mode-test.mjs && node claude/smoke-tests/hold-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node claude/smoke-tests/admin-facade-smoke.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node --test claude/smoke-tests/forum-signup-form.test.mjs claude/smoke-tests/door-verify.test.mjs claude/smoke-tests/oauth-signin.test.mjs claude/smoke-tests/app-registration.test.mjs claude/smoke-tests/push-signout.test.mjs claude/smoke-tests/token-fetches.test.mjs claude/smoke-tests/signin-hardening.test.mjs claude/smoke-tests/load-fixes.test.mjs claude/smoke-tests/unread-docs.test.mjs claude/smoke-tests/post-text-once.test.mjs claude/smoke-tests/connected-pod-only.test.mjs claude/smoke-tests/check-letter.test.mjs claude/smoke-tests/gone-through-relay.test.mjs claude/smoke-tests/connected-feeds.test.mjs && node scripts/check-sizes.mjs && node scripts/test-dependents.mjs && node claude/validation/browser-agent/all.mjs",
59
59
  "verify-ui": "node scripts/verify-ui.mjs",
60
60
  "build:sw": "node scripts/build-app.mjs --entry web/app/sw-src.mjs --out web/app/dist/sw.js",
61
61
  "build:boot": "node scripts/build-app.mjs --entry web/app/boot.mjs --out web/app/dist/boot.js",
@@ -98,6 +98,6 @@
98
98
  },
99
99
  "devDependencies": {
100
100
  "esbuild": "^0.28.2",
101
- "fedipod-bb": "^0.4.1"
101
+ "fedipod-bb": "^0.4.4"
102
102
  }
103
103
  }
@@ -152,8 +152,7 @@ const copyAdmin = (from, to) => { fs.mkdirSync(to, { recursive: true });
152
152
  .replace('</head>', '<script src="/admin/client-pick.js"></script>\n</head>')
153
153
  .replace('</body>', `${clientNews()}</body>`);
154
154
  // Hide controls the browser build does not carry: the manual drain (it
155
- // drains automatically over notifications), the local logfile viewer
156
- // (there is no local log in a browser), and the "create a handle at the
155
+ // drains automatically over notifications), and the "create a handle at the
157
156
  // gateway" choice — a fronted @you@front identity is not the browser
158
157
  // model, and admin-facade.mjs refuses one outright. Leaving the radio
159
158
  // visible meant the page checked the name's availability as you typed,
@@ -162,7 +161,7 @@ const copyAdmin = (from, to) => { fs.mkdirSync(to, { recursive: true });
162
161
  // And rename "Recover posts" to the plainer "Refresh Feed" for this
163
162
  // audience.
164
163
  text = text
165
- .replace('<style>#actor-pick', '<style>#gw-shape-front,#do-drain,#do-log,#actor-pick')
164
+ .replace('<style>#actor-pick', '<style>#gw-shape-front,#do-drain,#actor-pick')
166
165
  .replace('>Recover posts<', '>Refresh Feed<')
167
166
  // The move-to-another-gateway hint: only the browser build can move
168
167
  // in (signup.mjs moveIn), so only here does gateway.js get to show it.