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.
- package/README.md +12 -71
- package/gateway.md +3 -3
- package/gui.md +2 -1
- package/lib/client/masto/accounts.mjs +18 -4
- package/lib/client/masto/index.mjs +1 -0
- package/lib/client/masto/oauth.mjs +62 -4
- package/lib/client/oidc-auth.mjs +20 -2
- package/lib/client/webpush.mjs +14 -4
- package/lib/core/place.mjs +10 -0
- package/lib/core/publisher/index.mjs +14 -2
- package/lib/core/store.mjs +6 -2
- package/lib/core/wire.mjs +3 -1
- package/lib/gateway/front-core.mjs +42 -45
- package/lib/gateway/gateway-core.mjs +6 -1
- package/lib/gateway/held-mail.mjs +23 -0
- package/lib/gateway/masto-gateway.mjs +54 -7
- package/lib/gateway/notices.mjs +1 -1
- package/lib/gateway/outbox-door.mjs +3 -3
- package/lib/gateway/private-read.mjs +7 -1
- package/lib/gateway/quiet.mjs +13 -3
- package/lib/gateway/state-api.mjs +7 -3
- package/lib/gateway/token-claims.mjs +66 -4
- package/lib/pod/actor.mjs +5 -0
- package/lib/pod/transport.mjs +28 -0
- package/lib/pod/type-index.mjs +23 -0
- package/lib/server/embed.mjs +2 -2
- package/lib/shared/safefetch.mjs +11 -0
- package/lib/surface/surface.mjs +2 -1
- package/package.json +2 -2
- package/web/admin/index.html +2 -1
- package/web/admin/oauth-signin.mjs +26 -3
- package/web/app/agent.mjs +29 -4
- package/web/app/dist/boot.js +27 -0
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +106 -10
- package/web/app/dist/sw.js.map +3 -3
- package/web/front/run.js +1 -1
package/lib/server/embed.mjs
CHANGED
|
@@ -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
|
/**
|
package/lib/shared/safefetch.mjs
CHANGED
|
@@ -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.
|
package/lib/surface/surface.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|
package/web/admin/index.html
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/web/app/dist/boot.js
CHANGED
|
@@ -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.
|