fedipod 1.48.0 → 1.49.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.
@@ -241,7 +241,7 @@ export async function ensureInboxDoor(agent, urls, log = () => {}) {
241
241
  * The policy is read from the identity's live state rather than its
242
242
  * published policy document, because both are in this process.
243
243
  */
244
- export async function deliverToInbox(agent, request, { podPut, gatewayWebId = null, fetchImpl = fetch } = {}) {
244
+ export async function deliverToInbox(agent, request, { podPut, gatewayWebId = null, fetchImpl = fetch, admit = null } = {}) {
245
245
  const cfg = agent.store.getConfig() || {};
246
246
  const contacts = agent.store.getContacts();
247
247
  const bl = agent.store.getBlocklist();
@@ -258,7 +258,7 @@ export async function deliverToInbox(agent, request, { podPut, gatewayWebId = nu
258
258
  gatewayWebId,
259
259
  hmacSecret: cfg.gateway?.hmacSecret || null,
260
260
  };
261
- return handleDelivery(request, ident, { podPut, fetchImpl });
261
+ return handleDelivery(request, ident, { podPut, fetchImpl, admit });
262
262
  }
263
263
 
264
264
  /**
@@ -122,6 +122,17 @@ export function insecureUrlReason(url, what = 'address') {
122
122
  + 'Use https:// — or a loopback address if the pod is on this machine.';
123
123
  }
124
124
 
125
+ // Whether a URL's host is, or resolves to, a private address. A host that does
126
+ // not resolve names no address at all, and a request to it fails on its own.
127
+ export async function namesPrivateAddress(url) {
128
+ if (process.env.AP_ALLOW_PRIVATE_TARGETS === '1') return false;
129
+ let u;
130
+ try { u = new URL(url); } catch { return false; }
131
+ const host = u.hostname.replace(/^\[|\]$/g, '');
132
+ if (net.isIP(host)) return isPrivateAddress(host);
133
+ try { return (await dns.lookup(host, { all: true })).some((a) => isPrivateAddress(a.address)); } catch { return false; }
134
+ }
135
+
125
136
  // Throws when the URL must not be requested; otherwise returns the
126
137
  // validated address to pin the connection to ({address, family}), or null
127
138
  // when the URL already names a literal IP.
@@ -163,7 +163,8 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
163
163
  // answer a foreign Origin, per above.
164
164
  const bad = checkRequest(req, allowed, { ignoreOrigin: apiPath });
165
165
  if (bad) {
166
- log(`refused: ${bad} (${req.method} ${req.url})`);
166
+ // The path only: an address can carry a key, and a log is kept.
167
+ log(`refused: ${bad} (${req.method} ${url.pathname})`);
167
168
  res.writeHead(403, { 'content-type': 'text/plain', ...securityHeaders(allowed, false) });
168
169
  res.end('forbidden\n');
169
170
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod",
3
- "version": "1.48.0",
3
+ "version": "1.49.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",
@@ -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 && 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 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 && 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",
@@ -527,7 +527,8 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
527
527
  <p><strong>This cannot be undone.</strong> A Delete goes to every follower's inbox telling
528
528
  those servers to drop the account and the posts they cached, the actor document is
529
529
  replaced with a Tombstone, and this agent will refuse to start again for this pod. Your
530
- posts and RDF stay on the pod; the identity does not come back.</p>
530
+ WebID profile and public type index stop naming the account. Your posts and RDF stay on
531
+ the pod; the identity does not come back.</p>
531
532
  <p>Two things that are not this, and can be done from here:
532
533
  <button type="button" id="go-park" title=" Park instead: keeps the handle, just stops the mail" class="inline">Park it instead</button>
533
534
  keeps the handle resolving and just stops the mail — setting status back to
@@ -8,15 +8,26 @@ const $ = (id) => document.getElementById(id);
8
8
  const say = (text, error = false) => { const s = $('signin-status'); s.textContent = text; s.setAttribute('role', error ? 'alert' : 'status'); };
9
9
  const webId = $('webid').href;
10
10
  const here = location.origin + location.pathname;
11
+ const params = new URLSearchParams(location.search);
11
12
  const login = fediLogin({ dbName: 'fedipod-signin', clientName: 'FediPod sign-in', redirectUri: here });
13
+ // The owner's own yes to this client, given by pressing Allow in this tab. It
14
+ // rides the trip to the pod and back, and nothing else stands for it: a link
15
+ // that lands here with a pod sign-in already stored gets nothing without it.
16
+ // The same rule as the Gateway's app sign-in (web/app-signin/app-signin.mjs).
17
+ const CONSENT = 'fedipod-signin-consent';
18
+ const request = `${params.get('client_id')}\n${params.get('redirect_uri') || ''}`;
19
+ const consented = () => { try { return sessionStorage.getItem(CONSENT) === request; } catch { return false; } };
20
+ const consent = (on) => { try { if (on) sessionStorage.setItem(CONSENT, request); else sessionStorage.removeItem(CONSENT); } catch { /* keeps no site data */ } };
12
21
 
13
22
  // Back from the pod: this finishes the sign-in and returns to the client's
14
23
  // request, which carries the client's own parameters.
15
24
  try { await login.resume(); } catch (e) { say(e.message, true); }
16
25
 
26
+ // Proves the pod sign-in to the account. True when nothing more is asked here.
17
27
  async function prove() {
18
28
  const s = await login.getSession();
19
- if (!s) return false;
29
+ if (!s || !consented()) return false;
30
+ consent(false);
20
31
  if (s.webId !== webId) { say(`You signed in as ${s.webId}, but this account belongs to ${webId}. Sign out there and sign in as the owner.`, true); return true; }
21
32
  say('Signing you in…');
22
33
  const res = await s.fetch(here, {
@@ -30,8 +41,20 @@ async function prove() {
30
41
  return true;
31
42
  }
32
43
 
33
- if (location.search.includes('client_id=') && !(await prove())) $('signin').hidden = false;
44
+ const choices = (shown) => { $('signin').hidden = !shown; $('cancel').hidden = !shown; };
45
+
34
46
  $('signin').addEventListener('click', async () => {
47
+ consent(true);
48
+ choices(false);
49
+ if (await prove()) return;
35
50
  say('Taking you to your pod…');
36
- try { await login.login(webId, { returnTo: location.href, redirectUri: here }); } catch (e) { say(e.message, true); }
51
+ try { await login.login(webId, { returnTo: location.href, redirectUri: here }); } catch (e) { say(e.message, true); choices(true); }
52
+ });
53
+ $('cancel').addEventListener('click', () => {
54
+ consent(false);
55
+ choices(false);
56
+ say('Nothing was allowed. You can close this page.');
37
57
  });
58
+
59
+ // Back from the pod after pressing Allow: finish. Otherwise ask.
60
+ if (params.get('client_id') && !(consented() && await prove())) choices(true);
package/web/app/agent.mjs CHANGED
@@ -152,8 +152,9 @@ export class BrowserAgent {
152
152
 
153
153
  // One call to the gateway's owner API, proved with the pod session. What
154
154
  // comes back on 200 or 410 is the account's standing there, kept for the
155
- // manage page; anything else is logged and forgotten.
156
- async tellGateway(what, body) {
155
+ // manage page; anything else is logged and forgotten. `standing: false` for
156
+ // a call whose answer says nothing about the account's standing.
157
+ async tellGateway(what, body, { standing = true } = {}) {
157
158
  if (!this.gatewayApi) return null;
158
159
  let res;
159
160
  try {
@@ -163,15 +164,38 @@ export class BrowserAgent {
163
164
  } catch (e) { this.log(`gateway ${what}: ${e.message}`); return null; }
164
165
  const json = await res.json().catch(() => ({}));
165
166
  const out = { status: res.status, ...json };
166
- if (res.status === 200 || res.status === 410) this.gatewayStanding = out;
167
+ if (res.status === 200 || res.status === 410) { if (standing) this.gatewayStanding = out; }
167
168
  else this.log(`gateway ${what}: ${res.status} ${json.error || ''}`);
168
169
  return out;
169
170
  }
170
- openAtGateway() { return this.tellGateway('open', { handle: this.doorKey }); }
171
+ // The account's picture goes with it, for the address's WebFinger answer;
172
+ // said only once the account has been read, so "none" is never a guess.
173
+ openAtGateway() {
174
+ const config = this.store?.getConfig?.();
175
+ return this.tellGateway('open', { handle: this.doorKey, ...(config ? { icon: config.icon || null } : {}) });
176
+ }
171
177
  pauseAtGateway(paused) { return this.tellGateway('pause', { handle: this.doorKey, paused: !!paused }); }
172
178
  closeAtGateway() { return this.tellGateway('close', { handle: this.doorKey, confirm: true }); }
173
179
  static OPEN_EVERY_MS = 60 * 60_000;
174
180
 
181
+ // A notification made here, from mail read while FediPod is open, goes to
182
+ // the phones the owner signed up, as one made while it is closed does
183
+ // (lib/gateway/held-mail.mjs: /api/push). Named a moment later, once written,
184
+ // so a burst is one call. One from another network is not pushed.
185
+ static PUSH_AFTER_MS = 2_000;
186
+ notePush(n) {
187
+ if (!this.gatewayApi || n?.bsky || n?.via) return;
188
+ if (!Object.keys(this.store.read('webpush.json', { subs: {} }).subs || {}).length) return;
189
+ (this._toPush ||= new Set()).add(n.id);
190
+ this._pushTimer ||= setTimeout(async () => {
191
+ const ids = [...this._toPush];
192
+ this._toPush.clear();
193
+ this._pushTimer = null;
194
+ await this.store.flush?.().catch(() => {});
195
+ await this.tellGateway('push', { handle: this.doorKey, ids }, { standing: false });
196
+ }, BrowserAgent.PUSH_AFTER_MS);
197
+ }
198
+
175
199
  // The gateway acting for this account while the app is closed, or not. On:
176
200
  // the rules on the account's folders name the gateway's pod identity, then
177
201
  // the gateway is told. Off: the gateway is told first, so it stops, then the
@@ -304,6 +328,7 @@ export class BrowserAgent {
304
328
  if (this.viewer) return; // another agent holds the copy: reading only
305
329
  }
306
330
  }
331
+ this.store.onEvent = (type, n) => { if (type === 'notification') this.notePush(n); };
307
332
  const now = Date.now();
308
333
  // Said before the drain starts, so held mail is in the inbox to be read.
309
334
  const hereDue = !warm || now - (warm.hereAt || 0) >= BrowserAgent.HERE_EVERY_MS;
@@ -32999,6 +32999,33 @@ var PodTransport = class {
32999
32999
  await this.writeAboutWebId(podBase, { inserts: missing, deletes: stale });
33000
33000
  return true;
33001
33001
  }
33002
+ /**
33003
+ * The account taken out of the WebID profile again, as linkAccountInProfile
33004
+ * wrote it: the foaf:account link, what the profile says of the actor, and
33005
+ * `outbox` when it is the one named. From every document of the profile that
33006
+ * holds any of it, each write checked. Returns how many documents changed.
33007
+ */
33008
+ async unlinkAccountInProfile({ actorUrl, outbox = null }) {
33009
+ const me = namedNode2(this.webId);
33010
+ const actor = namedNode2(actorUrl);
33011
+ let changed = 0;
33012
+ for (const d of await this.profileDocs(podBaseOfWebId(this.webId))) {
33013
+ if (!d.g) continue;
33014
+ const gone = [
33015
+ ...d.g.statementsMatching(me, FOAF("account"), actor),
33016
+ ...d.g.statementsMatching(actor, RDF3("type"), FOAF("OnlineAccount")),
33017
+ ...d.g.statementsMatching(actor, RDF3("type"), AS("Person")),
33018
+ ...d.g.statementsMatching(actor, RDF3("type"), AS("Group")),
33019
+ ...d.g.statementsMatching(actor, FOAF("accountName"), null),
33020
+ ...outbox ? d.g.statementsMatching(me, AS("outbox"), namedNode2(outbox)) : []
33021
+ ].map((st2) => [st2.subject, st2.predicate, st2.object]);
33022
+ if (!gone.length) continue;
33023
+ const status = await this.writeRdfChecked(d.url, d.g, { deletes: gone, mustDescribe: d.profile ? this.webId : null });
33024
+ if (status >= 300) throw new Error(`not written: ${d.url} \u2192 ${status}`);
33025
+ changed++;
33026
+ }
33027
+ return changed;
33028
+ }
33002
33029
  /**
33003
33030
  * An N3 Patch of exactly these statements, or false when the pod will not
33004
33031
  * take one and the caller should write the document instead.