fedipod 1.49.1 → 1.50.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/bin/fedipod.mjs +2 -5
- package/cli.md +5 -10
- package/lib/client/masto/index.mjs +0 -3
- package/lib/client/masto/oauth.mjs +23 -112
- package/lib/core/agent.mjs +3 -12
- package/lib/device/cli/commands/setup.mjs +1 -24
- package/lib/device/setup.mjs +1 -5
- package/lib/shared/guard.mjs +2 -2
- package/lib/surface/routes/owner.mjs +2 -12
- package/lib/surface/surface.mjs +1 -2
- package/package.json +1 -1
- package/web/admin/index.html +1 -1
- package/web/admin/record.js +0 -3
- package/web/app/admin-facade.mjs +0 -6
- package/web/app/dist/sw.js +186 -143
- package/web/app/dist/sw.js.map +3 -3
package/bin/fedipod.mjs
CHANGED
|
@@ -70,9 +70,6 @@
|
|
|
70
70
|
// into profiles/<its handle>/. Installs made before the
|
|
71
71
|
// 2026-07-30 rename keep ~/.activitypod until they run
|
|
72
72
|
// `--to`; new ones get ~/.fedipod.
|
|
73
|
-
// fedipod passwd set/change the UI password (REQUIRED before any
|
|
74
|
-
// non-loopback exposure — it turns the instant
|
|
75
|
-
// OAuth redirect into a real login form)
|
|
76
73
|
// fedipod tokens list client tokens; --revoke <prefix> / --revoke-all
|
|
77
74
|
// fedipod revoke-credential --email you@example.org
|
|
78
75
|
// kill this machine's pod credential server-side and
|
|
@@ -93,7 +90,7 @@ const COMMANDS = new Map([
|
|
|
93
90
|
['up', run.up], ['start', run.start], ['run', run.start], ['stop', run.stop], ['status', run.status],
|
|
94
91
|
['https', run.https],
|
|
95
92
|
['setup', setup.setup], ['rotate-key', setup.rotateKey], ['revoke-credential', setup.revokeCredential],
|
|
96
|
-
['tokens', setup.tokens], ['
|
|
93
|
+
['tokens', setup.tokens], ['keys', setup.keys],
|
|
97
94
|
['state', state.state], ['upgrade', state.upgrade], ['profiles', state.profiles], ['home', state.home],
|
|
98
95
|
['export', state.exportCollectionsCmd],
|
|
99
96
|
['update', service.update], ['install-service', service.service], ['uninstall-service', service.service],
|
|
@@ -109,7 +106,7 @@ const command = COMMANDS.get(cmd);
|
|
|
109
106
|
if (command) {
|
|
110
107
|
await command();
|
|
111
108
|
} else {
|
|
112
|
-
console.log('usage: fedipod <setup|start|stop|status|state|upgrade|rebuild|home
|
|
109
|
+
console.log('usage: fedipod <setup|start|stop|status|state|upgrade|rebuild|home'
|
|
113
110
|
+ '|tokens|revoke-credential|install-service|archive|alias|import|keys|front> [--flags]');
|
|
114
111
|
console.log(' keys: where the signing key lives; --to pod|local moves it (pod = multi-device signing)');
|
|
115
112
|
console.log(' https: the local certificate agents serve TLS with; --trust mints a local CA for strict clients');
|
package/cli.md
CHANGED
|
@@ -77,20 +77,15 @@ nothing local is overwritten. Agent must be running.
|
|
|
77
77
|
## Security
|
|
78
78
|
|
|
79
79
|
```
|
|
80
|
-
fedipod passwd # require a password when a client logs in
|
|
81
80
|
fedipod tokens # list client logins; --revoke <prefix> or --revoke-all
|
|
82
81
|
fedipod revoke-credential --email EMAIL # cut this machine off from the pod account
|
|
83
82
|
```
|
|
84
|
-
A new password takes effect when a running agent restarts.
|
|
85
83
|
|
|
86
|
-
**A client hosted somewhere else
|
|
87
|
-
site (Elk, Phanpy on someone's own domain) asks the agent to send
|
|
88
|
-
result back to that site
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
So: no password, no sending credentials off this machine. Clients that live on
|
|
92
|
-
the agent itself (the bundled one, a dist in `ui/`, anything on
|
|
93
|
-
`https://localhost:<port>`) are unaffected and need no password.
|
|
84
|
+
**A client hosted somewhere else is approved at your pod.** A client running
|
|
85
|
+
on its own site (Elk, Phanpy on someone's own domain) asks the agent to send
|
|
86
|
+
the sign-in result back to that site; you approve it by signing in at your pod
|
|
87
|
+
and pressing Allow. Clients that live on the agent itself (the bundled one, a
|
|
88
|
+
dist in `ui/`, anything on `https://localhost:<port>`) need nothing.
|
|
94
89
|
|
|
95
90
|
## Bluesky
|
|
96
91
|
|
|
@@ -26,7 +26,6 @@ import * as accounts from './accounts.mjs';
|
|
|
26
26
|
import * as timelines from './timelines.mjs';
|
|
27
27
|
import * as statuses from './statuses.mjs';
|
|
28
28
|
import * as media from './media.mjs';
|
|
29
|
-
export { hashPassword } from './oauth.mjs';
|
|
30
29
|
export { pollParams } from './body.mjs';
|
|
31
30
|
export { attachmentType, extensionFor } from './media.mjs';
|
|
32
31
|
|
|
@@ -74,7 +73,6 @@ export class MastoApi {
|
|
|
74
73
|
// loss — the client says "scheduled" and the post never appears. Refusing
|
|
75
74
|
// it is worse UX and better behaviour.
|
|
76
75
|
this.scheduling = scheduling;
|
|
77
|
-
this.authzAttempts = []; // password-attempt timestamps
|
|
78
76
|
}
|
|
79
77
|
|
|
80
78
|
get store() { return this.agent.store; }
|
|
@@ -182,7 +180,6 @@ export class MastoApi {
|
|
|
182
180
|
tokenOf(...a) { return oauth.tokenOf(this, ...a); }
|
|
183
181
|
authed(...a) { return oauth.authed(this, ...a); }
|
|
184
182
|
redirectAllowed(...a) { return oauth.redirectAllowed(this, ...a); }
|
|
185
|
-
rateLimited(...a) { return oauth.rateLimited(this, ...a); }
|
|
186
183
|
|
|
187
184
|
// render.mjs
|
|
188
185
|
selfAccount(...a) { return render.selfAccount(this, ...a); }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// oauth.mjs — how a client gets in: registered apps and client documents,
|
|
2
2
|
// authorization codes and PKCE, the bearer tokens and their scopes, the
|
|
3
|
-
//
|
|
3
|
+
// owner's approval at their pod, and the three /oauth endpoints plus app registration.
|
|
4
4
|
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
6
|
import { isCrossSiteNavigation } from '../../shared/guard.mjs';
|
|
@@ -8,8 +8,6 @@ import { safeFetch, readCapped } from '../../shared/safefetch.mjs';
|
|
|
8
8
|
import { readBody } from './body.mjs';
|
|
9
9
|
|
|
10
10
|
const TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000; // tokens age out after 90 days
|
|
11
|
-
const AUTHZ_WINDOW_MS = 60_000;
|
|
12
|
-
const AUTHZ_MAX_ATTEMPTS = 5;
|
|
13
11
|
const CODE_TTL_MS = 5 * 60_000; // an authorization code is short-lived
|
|
14
12
|
const MAX_APPS = 200; // registered third-party clients, capped
|
|
15
13
|
const MAX_REDIRECTS = 10; // return addresses one client may register
|
|
@@ -22,20 +20,6 @@ const CLIENT_DOC_WINDOW_MS = 60_000;
|
|
|
22
20
|
const CLIENT_DOC_MAX_FETCHES = 20; // outbound lookups per window
|
|
23
21
|
const CLIENT_DOC_MAX = 64 * 1024; // it names a client; it is not a payload
|
|
24
22
|
|
|
25
|
-
// scrypt check for the optional UI password ({ saltHex, hashHex } record —
|
|
26
|
-
// see hashPassword, used by the passwd CLI).
|
|
27
|
-
export function hashPassword(password) {
|
|
28
|
-
const salt = crypto.randomBytes(16);
|
|
29
|
-
const hash = crypto.scryptSync(String(password), salt, 32);
|
|
30
|
-
return { saltHex: salt.toString('hex'), hashHex: hash.toString('hex') };
|
|
31
|
-
}
|
|
32
|
-
export function checkPassword(rec, password) {
|
|
33
|
-
try {
|
|
34
|
-
const hash = crypto.scryptSync(String(password), Buffer.from(rec.saltHex, 'hex'), 32);
|
|
35
|
-
return crypto.timingSafeEqual(hash, Buffer.from(rec.hashHex, 'hex'));
|
|
36
|
-
} catch { return false; }
|
|
37
|
-
}
|
|
38
|
-
|
|
39
23
|
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c =>
|
|
40
24
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
41
25
|
|
|
@@ -85,48 +69,6 @@ button[hidden]{display:none}
|
|
|
85
69
|
return true;
|
|
86
70
|
}
|
|
87
71
|
|
|
88
|
-
function sendLoginForm(res, params, error = '', client = null, status = null, headers = {}) {
|
|
89
|
-
const hidden = [...params.entries()].filter(([k]) => k !== 'password')
|
|
90
|
-
.map(([k, v]) => `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}">`).join('\n');
|
|
91
|
-
// Name what is asking, so the owner approves a client they can see rather
|
|
92
|
-
// than an opaque one. The destination the code will be sent to is the fact
|
|
93
|
-
// that matters for a cross-site client.
|
|
94
|
-
let asking = '<p>Enter the agent password to authorize this client.</p>';
|
|
95
|
-
if (client && (client.name || client.redirect)) {
|
|
96
|
-
let where = '';
|
|
97
|
-
try { where = client.redirect ? new URL(client.redirect).host : ''; } catch { /* oob or blank */ }
|
|
98
|
-
const who = client.name ? escapeHtml(client.name) : (where ? escapeHtml(where) : 'A client');
|
|
99
|
-
asking = `<p><strong>${who}</strong> is asking to access your account`
|
|
100
|
-
+ `${where ? `, sending the authorization to <code>${escapeHtml(where)}</code>` : ''}.</p>`
|
|
101
|
-
+ `<p>Scope: <code>${escapeHtml(client.scope || 'read')}</code>. Enter the agent password to allow it.</p>`;
|
|
102
|
-
}
|
|
103
|
-
res.writeHead(status || (error ? 401 : 200),
|
|
104
|
-
{ 'content-type': 'text/html; charset=utf-8', ...headers });
|
|
105
|
-
res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
106
|
-
<title>FediPod — authorize</title>
|
|
107
|
-
<style>:root{color-scheme:light dark;font-size:125%;--heading:#1a4f8a}
|
|
108
|
-
body{font:1rem system-ui,sans-serif;max-width:22rem;margin:15vh auto;padding:0 1rem}
|
|
109
|
-
h1{color:var(--heading)}
|
|
110
|
-
code{word-break:break-all}
|
|
111
|
-
@media (prefers-color-scheme:dark){:root{--heading:#7fb3e8}}
|
|
112
|
-
input,button{font:inherit;width:100%;padding:.5rem;margin:.3rem 0;box-sizing:border-box}
|
|
113
|
-
.err{color:#b00020}
|
|
114
|
-
@media (prefers-color-scheme:dark){.err{color:#ff8a8a}}</style></head><body>
|
|
115
|
-
<main>
|
|
116
|
-
<h1>FediPod</h1>
|
|
117
|
-
${asking}
|
|
118
|
-
${error ? `<p class="err" id="login-err" role="alert">${escapeHtml(error)}</p>` : ''}
|
|
119
|
-
<form method="POST" action="/oauth/authorize">
|
|
120
|
-
${hidden}
|
|
121
|
-
<label for="password">Agent password</label>
|
|
122
|
-
<input type="password" id="password" name="password" autofocus autocomplete="current-password"
|
|
123
|
-
${error ? 'aria-invalid="true" aria-describedby="login-err"' : ''}>
|
|
124
|
-
<button type="submit">Authorize</button>
|
|
125
|
-
</form>
|
|
126
|
-
</main></body></html>`);
|
|
127
|
-
return true;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
72
|
// What one request needs. Mastodon's four coarse scopes; a client that was
|
|
131
73
|
// granted a granular `write:statuses` satisfies `write` here, which is the
|
|
132
74
|
// direction that cannot let anything through that `write` would not.
|
|
@@ -439,22 +381,13 @@ export function redirectAllowed(api, redirect) {
|
|
|
439
381
|
} catch { return false; }
|
|
440
382
|
}
|
|
441
383
|
|
|
442
|
-
export function rateLimited(api) {
|
|
443
|
-
const now = Date.now();
|
|
444
|
-
api.authzAttempts = api.authzAttempts.filter(t => now - t < AUTHZ_WINDOW_MS);
|
|
445
|
-
if (api.authzAttempts.length >= AUTHZ_MAX_ATTEMPTS) return true;
|
|
446
|
-
api.authzAttempts.push(now);
|
|
447
|
-
return false;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
384
|
export async function handle(api, ctx) {
|
|
451
385
|
const { req, res, pathname, url, send } = ctx; // eslint-disable-line no-unused-vars
|
|
452
386
|
|
|
453
387
|
// --- oauth ---
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
// authorize step becomes a real login form.
|
|
388
|
+
// On loopback this is "theater": whoever can reach the surface IS the
|
|
389
|
+
// trusted user, and authorize answers at once. Anywhere else the owner
|
|
390
|
+
// approves the client by signing in at their pod.
|
|
458
391
|
if (pathname === '/api/v1/apps' && req.method === 'POST') {
|
|
459
392
|
const body = await readBody(req);
|
|
460
393
|
const problem = redirectsProblem(body.redirect_uris);
|
|
@@ -475,14 +408,12 @@ export async function handle(api, ctx) {
|
|
|
475
408
|
});
|
|
476
409
|
}
|
|
477
410
|
if (pathname === '/oauth/authorize' && (req.method === 'GET' || req.method === 'POST')) {
|
|
478
|
-
const pw = api.store.getConfig()?.uiPassword; // { saltHex, hashHex } scrypt record
|
|
479
411
|
let params = url.searchParams;
|
|
480
412
|
let body = null;
|
|
481
413
|
if (req.method === 'POST') { body = await readBody(req); params = new URLSearchParams(body); }
|
|
482
414
|
const redirect = params.get('redirect_uri') || '';
|
|
483
415
|
// The owner's pod sign-in, proved: a Solid-OIDC token with its DPoP proof,
|
|
484
|
-
// from the WebID this account belongs to. It
|
|
485
|
-
// everywhere below.
|
|
416
|
+
// from the WebID this account belongs to. It is how a client is approved.
|
|
486
417
|
let proved = false;
|
|
487
418
|
if (req.method === 'POST' && req.headers.authorization && req.headers.dpop && api.podAuth) {
|
|
488
419
|
const proof = await api.podAuth(req, pathname);
|
|
@@ -513,7 +444,7 @@ export async function handle(api, ctx) {
|
|
|
513
444
|
//
|
|
514
445
|
// /api/v1/apps takes any redirect_uri from anyone — as it must, that is
|
|
515
446
|
// how a fediverse client registers — and the instant-authorize path below
|
|
516
|
-
// mints
|
|
447
|
+
// mints at once whenever the request "is this machine". A page
|
|
517
448
|
// in another tab is this machine. So: register a client whose redirect is
|
|
518
449
|
// your own server, navigate the owner's browser to /oauth/authorize, and
|
|
519
450
|
// the code arrives at your address. The agent's own CA is in the browser's
|
|
@@ -530,12 +461,10 @@ export async function handle(api, ctx) {
|
|
|
530
461
|
api.log(`authorize refused: cross-site navigation to the mint from ${req.headers.referer || 'nowhere'}`);
|
|
531
462
|
return send(403, { error: 'a cross-site navigation may not authorize a client' });
|
|
532
463
|
}
|
|
533
|
-
// 2.
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
// about a web page. Anything pointing elsewhere has to be approved by
|
|
538
|
-
// somebody who knows the password.
|
|
464
|
+
// 2. The unapproved path is honest only for a client on an address this
|
|
465
|
+
// agent itself answers on — where "whoever reaches the port is the
|
|
466
|
+
// owner" is a statement about the machine rather than about a web
|
|
467
|
+
// page. Anything pointing elsewhere is approved by the owner at their pod.
|
|
539
468
|
const client = { name: app?.name || doc?.name || null, redirect, scope: params.get('scope') || 'read' };
|
|
540
469
|
// A proof method named and not known is refused rather than read as the
|
|
541
470
|
// weakest one, whose proof is the challenge itself.
|
|
@@ -543,13 +472,12 @@ export async function handle(api, ctx) {
|
|
|
543
472
|
if (params.get('code_challenge') && challengeMethod && challengeMethod !== 'S256' && challengeMethod !== 'plain') {
|
|
544
473
|
return send(400, { error: 'code_challenge_method must be S256 or plain' });
|
|
545
474
|
}
|
|
546
|
-
if (external && !proved && !api.
|
|
547
|
-
//
|
|
475
|
+
if (external && !proved && !api.redirectAllowed(redirect)) {
|
|
476
|
+
// The owner approves the client at their pod.
|
|
548
477
|
if (req.method === 'GET' && ownerWebId) return sendPodSigninPage(res, client, ownerWebId, api.mount || '', ownAddress(api));
|
|
549
|
-
api.log(`authorize refused:
|
|
478
|
+
api.log(`authorize refused: "${redirect}" is not an address of this agent, and nothing approved it`);
|
|
550
479
|
return send(403, {
|
|
551
|
-
error: 'this client asks to be sent somewhere other than this
|
|
552
|
-
+ 'set to approve that with. Run `fedipod passwd` and try again.',
|
|
480
|
+
error: 'this client asks to be sent somewhere other than this account; approve it by signing in at your pod',
|
|
553
481
|
});
|
|
554
482
|
}
|
|
555
483
|
if (app) {
|
|
@@ -573,26 +501,10 @@ export async function handle(api, ctx) {
|
|
|
573
501
|
api.log(`authorize refused: redirect_uri "${redirect}" is not this agent`);
|
|
574
502
|
return send(400, { error: 'redirect_uri must be an address of this agent' });
|
|
575
503
|
}
|
|
576
|
-
if (
|
|
577
|
-
|
|
578
|
-
api.log('authorize rate limited');
|
|
579
|
-
// 429, not 401: a client that reads this as a wrong password will
|
|
580
|
-
// ask the person to type it again, which is the one thing that
|
|
581
|
-
// cannot help. Retry-After says how long the wait actually is.
|
|
582
|
-
return sendLoginForm(res, params, 'too many attempts — wait a minute', client,
|
|
583
|
-
429, { 'retry-after': String(Math.ceil(AUTHZ_WINDOW_MS / 1000)) });
|
|
584
|
-
}
|
|
585
|
-
if (!pw || !checkPassword(pw, body.password || '')) {
|
|
586
|
-
return sendLoginForm(res, params, 'wrong password — try again', client);
|
|
587
|
-
}
|
|
588
|
-
} else if (pw && !proved) {
|
|
589
|
-
// The login/approve screen names what is asking before the owner types
|
|
590
|
-
// the password — a cross-site client cannot forge past that.
|
|
591
|
-
return sendLoginForm(res, params, '', client);
|
|
592
|
-
} else if (!proved && api.allowed && !api.allowed.isLocalRequest(req) && !api.throughDoor?.(req)) {
|
|
593
|
-
// With a pod to sign in at, the owner does that instead of a password.
|
|
504
|
+
if (!proved && api.allowed && !api.allowed.isLocalRequest(req) && !api.throughDoor?.(req)) {
|
|
505
|
+
// The owner signs in at their pod and approves the client there.
|
|
594
506
|
if (req.method === 'GET' && ownerWebId) return sendPodSigninPage(res, client, ownerWebId, api.mount || '', ownAddress(api));
|
|
595
|
-
//
|
|
507
|
+
// Not proved, and this request did not come from this machine.
|
|
596
508
|
//
|
|
597
509
|
// The instant-authorize path is honest theatre on loopback: whoever can
|
|
598
510
|
// reach it IS the trusted user. It stops being theatre the moment an
|
|
@@ -602,11 +514,11 @@ export async function handle(api, ctx) {
|
|
|
602
514
|
// — update_credentials included, which is precisely the authority the
|
|
603
515
|
// isLocal check on /setup and /config exists to withhold. /oauth is
|
|
604
516
|
// dispatched before that check ever runs, so it needs its own.
|
|
605
|
-
api.log(`authorize refused:
|
|
517
|
+
api.log(`authorize refused: not approved at the pod, and "${req.headers.host}" is not this machine`);
|
|
606
518
|
return send(403, {
|
|
607
519
|
error: api.embedded
|
|
608
520
|
? 'Open your account\'s management page first, then sign in from there.'
|
|
609
|
-
: 'Signing in from another machine
|
|
521
|
+
: 'Signing in from another machine goes through your pod: sign in there to approve this app.',
|
|
610
522
|
});
|
|
611
523
|
}
|
|
612
524
|
// External clients get a bound code; the local flow keeps code==token.
|
|
@@ -696,11 +608,10 @@ export async function handle(api, ctx) {
|
|
|
696
608
|
// would mean a second round trip before it knows who it is.
|
|
697
609
|
...(api.urls?.actor ? { activitypub_actor_id: api.urls.actor } : {}) });
|
|
698
610
|
}
|
|
699
|
-
// Local flow: the code IS the token, minted by /oauth/authorize
|
|
700
|
-
//
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
// on any agent deliberately exposed through AP_ALLOWED_HOSTS.
|
|
611
|
+
// Local flow: the code IS the token, minted by /oauth/authorize. Minting
|
|
612
|
+
// one here for an unrecognised code handed a bearer to anyone who could
|
|
613
|
+
// reach the port — and a non-browser client sends no Origin, so the
|
|
614
|
+
// firewall never saw it.
|
|
704
615
|
if (!body.code || !api.tokens().includes(body.code)) {
|
|
705
616
|
api.log('token refused: code is not a live authorization');
|
|
706
617
|
return send(400, { error: 'invalid_grant' });
|
package/lib/core/agent.mjs
CHANGED
|
@@ -210,8 +210,8 @@ export class Agent {
|
|
|
210
210
|
this.log(`this actor was retired on ${config.retiredAt} — run setup for a new identity`);
|
|
211
211
|
return false;
|
|
212
212
|
}
|
|
213
|
-
// A rename is a merge into the existing config, never a rewrite —
|
|
214
|
-
//
|
|
213
|
+
// A rename is a merge into the existing config, never a rewrite —
|
|
214
|
+
// anything set later must survive it.
|
|
215
215
|
if (name && name !== config.name) {
|
|
216
216
|
this.store.setConfig({ ...config, name });
|
|
217
217
|
config = this.store.getConfig();
|
|
@@ -235,11 +235,7 @@ export class Agent {
|
|
|
235
235
|
// came out bare. Pod state is the authority; this is the cache of it.
|
|
236
236
|
this.recordHandle(config.handle);
|
|
237
237
|
|
|
238
|
-
// Said once, where the config is finally known.
|
|
239
|
-
// token is refused before the socket opens (startAgent), so reaching here
|
|
240
|
-
// means the token is set and the whole surface is behind it. A UI password
|
|
241
|
-
// is the second, per-person half: without one, anyone who has the token
|
|
242
|
-
// gets a client bearer for the asking.
|
|
238
|
+
// Said once, where the config is finally known.
|
|
243
239
|
// An install made before the shape changed goes on working, which is why
|
|
244
240
|
// nothing here refuses — but it should not go on SILENTLY. The whole reason
|
|
245
241
|
// this is said out loud is that bbba587 changed a default and every install
|
|
@@ -253,11 +249,6 @@ export class Agent {
|
|
|
253
249
|
// read by nobody who lives in the GUI.
|
|
254
250
|
this.pendingUpgrade = pending.map(s => s.what);
|
|
255
251
|
|
|
256
|
-
if (process.env.AP_ALLOWED_HOSTS && !config.uiPassword) {
|
|
257
|
-
this.log('WARNING: AP_ALLOWED_HOSTS is set and no UI password is — anyone holding '
|
|
258
|
-
+ 'AP_GATE_TOKEN can mint a client token. Run `fedipod passwd`.');
|
|
259
|
-
}
|
|
260
|
-
|
|
261
252
|
if (!act) return true; // reading only — see the note above
|
|
262
253
|
|
|
263
254
|
// Exactly one agent may act on a pod (inbox drains are destructive
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// setup.mjs — making an identity and its secrets: setup, rotate-key,
|
|
2
|
-
// revoke-credential, tokens,
|
|
2
|
+
// revoke-credential, tokens, keys.
|
|
3
3
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
@@ -398,29 +398,6 @@ if (has('revoke-all')) {
|
|
|
398
398
|
}
|
|
399
399
|
}
|
|
400
400
|
|
|
401
|
-
export async function passwd() {
|
|
402
|
-
requireIdentity();
|
|
403
|
-
const { Agent } = await import(new URL('../../../../run-agent.mjs', import.meta.url));
|
|
404
|
-
const { hashPassword } = await import(new URL('../../../../lib/client/masto/index.mjs', import.meta.url));
|
|
405
|
-
const { RemotePod } = await import(new URL('../../../../lib/device/remote.mjs', import.meta.url));
|
|
406
|
-
const { apUrls } = await import(new URL('../../../../lib/core/wire.mjs', import.meta.url));
|
|
407
|
-
const agent = new Agent({ home: HOME, log: () => {} });
|
|
408
|
-
const cred = agent.readCredential();
|
|
409
|
-
if (!cred) { console.error('no credential — run setup first'); process.exit(2); }
|
|
410
|
-
const pw = await askHidden('new UI password: ');
|
|
411
|
-
if (!pw) { console.error('empty password — aborted'); process.exit(2); }
|
|
412
|
-
agent.remote = new RemotePod(cred);
|
|
413
|
-
await agent.remote.warmup();
|
|
414
|
-
agent.urls = apUrls(cred.remotePod, cred.root);
|
|
415
|
-
agent.store.attach(agent.privateStorage(cred, 'state')); // honours privateRoot
|
|
416
|
-
await agent.store.load();
|
|
417
|
-
const config = agent.store.getConfig();
|
|
418
|
-
if (!config) { console.error('pod state empty — run setup first'); process.exit(2); }
|
|
419
|
-
agent.store.setConfig({ ...config, uiPassword: hashPassword(pw) });
|
|
420
|
-
await agent.store.flush();
|
|
421
|
-
console.log('UI password set — /oauth/authorize now shows a login form (restart a running agent to pick it up)');
|
|
422
|
-
}
|
|
423
|
-
|
|
424
401
|
export async function keys() {
|
|
425
402
|
// Where the signing key lives; `--to pod` moves it into pod state — with
|
|
426
403
|
// the state store on a pod every device reaches, that is what lets them
|
package/lib/device/setup.mjs
CHANGED
|
@@ -15,7 +15,6 @@ import * as $rdf from 'rdflib';
|
|
|
15
15
|
|
|
16
16
|
import { createAccountWithPod as realCreateAccount } from './account.mjs';
|
|
17
17
|
import { mintCredential as realMint, RemotePod } from './remote.mjs';
|
|
18
|
-
import { hashPassword } from '../client/masto/index.mjs';
|
|
19
18
|
import { webfingerHost, apUrls, DEFAULT_ROOT } from '../core/wire.mjs';
|
|
20
19
|
import { rootOf, recordLastUsed, writeJsonAtomic } from './home.mjs';
|
|
21
20
|
import { insecureUrlReason } from '../shared/safefetch.mjs';
|
|
@@ -233,7 +232,7 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
233
232
|
|
|
234
233
|
const {
|
|
235
234
|
mode, issuer, email, password, handle, name, podName,
|
|
236
|
-
kind = 'person', approveJoins = false, summary, icon, keys,
|
|
235
|
+
kind = 'person', approveJoins = false, summary, icon, keys,
|
|
237
236
|
gateway = null, shape = 'pod', gatewayOrigin = 'https://fedipod.net',
|
|
238
237
|
// The container that holds `fedipod/` (blank: the pod's root), and the
|
|
239
238
|
// person's yes to a new public type index when their profile names none.
|
|
@@ -436,9 +435,6 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
436
435
|
done('connect');
|
|
437
436
|
|
|
438
437
|
begin('publish');
|
|
439
|
-
if (uiPassword) {
|
|
440
|
-
agent.store.setConfig({ ...agent.store.getConfig(), uiPassword: hashPassword(uiPassword) });
|
|
441
|
-
}
|
|
442
438
|
const published = await agent.publisher.publishProfile();
|
|
443
439
|
await agent.store.flush();
|
|
444
440
|
// Where the account lives, in the owner's public type index — made first
|
package/lib/shared/guard.mjs
CHANGED
|
@@ -72,10 +72,10 @@ export function isLoopbackSocket(req) {
|
|
|
72
72
|
// means something on this machine is relaying, and a stock
|
|
73
73
|
// `proxy_pass http://127.0.0.1:<port>;` rewrites Host to a loopback authority
|
|
74
74
|
// and connects from loopback. Every caller then looks local, which is what the
|
|
75
|
-
// local-only routes and the
|
|
75
|
+
// local-only routes and the unapproved authorize path were trusting.
|
|
76
76
|
//
|
|
77
77
|
// A secret is all that is left, and AP_GATE_TOKEN is the one every route
|
|
78
|
-
// checks
|
|
78
|
+
// checks; an app's sign-in is approved at the owner's pod. Returns a
|
|
79
79
|
// reason string when the agent must not start, or null.
|
|
80
80
|
export function exposureProblem({ allowedHosts, gateToken } = {}) {
|
|
81
81
|
const exposed = String(allowedHosts || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
@@ -5,7 +5,6 @@ import fs from 'node:fs';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { announceModeration } from '../../core/social.mjs';
|
|
7
7
|
import { addRemoveActivity, publicHandle } from '../../core/wire.mjs';
|
|
8
|
-
import { hashPassword } from '../../client/masto/index.mjs';
|
|
9
8
|
import { tildify } from '../../shared/files.mjs';
|
|
10
9
|
import { AGENT_VERSION } from '../static.mjs';
|
|
11
10
|
|
|
@@ -71,8 +70,6 @@ export async function get(p, ctx, req, res, url) { // eslint-disable-line no-u
|
|
|
71
70
|
approveJoins: !!cfg.approveJoins, review: !!cfg.review,
|
|
72
71
|
aliases: cfg.aliases || [],
|
|
73
72
|
autoAcceptFollows: cfg.autoAcceptFollows !== false,
|
|
74
|
-
// never the record itself
|
|
75
|
-
hasUiPassword: !!cfg.uiPassword,
|
|
76
73
|
quiescedAt: cfg.quiescedAt || null, movedTo: cfg.movedTo || null,
|
|
77
74
|
// Per-machine, so it comes from the credential file, not pod config —
|
|
78
75
|
// and it is a fact here, not a setting: moving it means moving data,
|
|
@@ -172,8 +169,8 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
|
|
|
172
169
|
// A user acting here outranks an idle active agent on another device:
|
|
173
170
|
// claim the lease rather than write state that one will clobber.
|
|
174
171
|
await agent.requestTakeover?.();
|
|
175
|
-
// Merge, never replace:
|
|
176
|
-
//
|
|
172
|
+
// Merge, never replace: anything set later must survive an edit that
|
|
173
|
+
// never mentions it.
|
|
177
174
|
const cfg = { ...agent.store.getConfig() };
|
|
178
175
|
if ('name' in body) {
|
|
179
176
|
if (!body.name) return json(res, 400, { error: 'a display name is required' });
|
|
@@ -210,12 +207,6 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
|
|
|
210
207
|
};
|
|
211
208
|
cfg.moderators = next;
|
|
212
209
|
}
|
|
213
|
-
if ('password' in body) {
|
|
214
|
-
// '' clears it. The UI password is what turns the instant OAuth
|
|
215
|
-
// redirect into a login form; switching that off is a real choice.
|
|
216
|
-
if (body.password) cfg.uiPassword = hashPassword(body.password);
|
|
217
|
-
else delete cfg.uiPassword;
|
|
218
|
-
}
|
|
219
210
|
const republish = WIRE_CONFIG.some(k => k in body);
|
|
220
211
|
agent.store.setConfig(cfg);
|
|
221
212
|
if ('autoAcceptFollows' in body && agent.publisher) {
|
|
@@ -257,7 +248,6 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
|
|
|
257
248
|
approveJoins: !!cfg.approveJoins, review: !!cfg.review,
|
|
258
249
|
autoAcceptFollows: cfg.autoAcceptFollows !== false,
|
|
259
250
|
...(isGroup() ? { moderators: cfg.moderators || [] } : {}),
|
|
260
|
-
hasUiPassword: !!cfg.uiPassword,
|
|
261
251
|
},
|
|
262
252
|
});
|
|
263
253
|
}
|
package/lib/surface/surface.mjs
CHANGED
|
@@ -239,8 +239,7 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
239
239
|
// twice over: a group has statuses (what it carried) and notifications
|
|
240
240
|
// (who joined), and its operator has a bio to edit and a profile they
|
|
241
241
|
// want to see the way everyone else does. The surface this opens is a
|
|
242
|
-
// login and client tokens,
|
|
243
|
-
// so a group can be gated exactly like a person before it is exposed.
|
|
242
|
+
// login and client tokens, approved at the pod like a person's.
|
|
244
243
|
if (p.startsWith('/api/') || p.startsWith('/oauth/')) {
|
|
245
244
|
if (await masto.handle(req, res, p, url)) return;
|
|
246
245
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.50.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/web/admin/index.html
CHANGED
|
@@ -220,7 +220,7 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
|
|
|
220
220
|
no page there to open. Plain links — a top-level navigation sends no
|
|
221
221
|
Origin, so each agent's own firewall lets it through. -->
|
|
222
222
|
<section id="pane-others" aria-label="Actors on this machine" hidden>
|
|
223
|
-
<!-- Everything setup cannot work without. The bio
|
|
223
|
+
<!-- Everything setup cannot work without. The bio and avatar it
|
|
224
224
|
also offers are all changeable afterwards, so they are not asked here —
|
|
225
225
|
the fuller form is what the new actor opens on. -->
|
|
226
226
|
<form id="new-actor-form" hidden>
|
package/web/admin/record.js
CHANGED
|
@@ -7,9 +7,6 @@
|
|
|
7
7
|
// Two rules the routes enforce and this page respects: a write is a MERGE, so a
|
|
8
8
|
// form that never mentions a field cannot delete it; and anything on the wire is
|
|
9
9
|
// not real until the actor is republished, which POST /config does for itself.
|
|
10
|
-
//
|
|
11
|
-
// The UI password is deliberately not here — `fedipod passwd` sets it. It
|
|
12
|
-
// only gates /oauth/authorize, so it does nothing for a loopback-only agent.
|
|
13
10
|
|
|
14
11
|
let config = null;
|
|
15
12
|
// Held, because render() moves it into a generated row and empties that row's
|
package/web/app/admin-facade.mjs
CHANGED
|
@@ -18,7 +18,6 @@ import { publicHandle, webfingerHost } from '../../lib/core/wire.mjs';
|
|
|
18
18
|
import * as podInbox from '../../lib/pod/inbox.mjs';
|
|
19
19
|
import { normalizeImport, IMPORT_KINDS } from '../../lib/connections/import.mjs';
|
|
20
20
|
import { followActor, followHandle } from '../../lib/core/social.mjs';
|
|
21
|
-
import { hashPassword } from '../../lib/client/masto/index.mjs';
|
|
22
21
|
|
|
23
22
|
// The identity itself — changing any means a different actor, i.e. a new setup.
|
|
24
23
|
const PERMANENT_CONFIG = ['handle', 'remotePod', 'issuer', 'root', 'kind'];
|
|
@@ -121,7 +120,6 @@ export class AdminFacade {
|
|
|
121
120
|
image: cfg.image || null, fields: cfg.fields || [],
|
|
122
121
|
aliases: cfg.aliases || [],
|
|
123
122
|
autoAcceptFollows: cfg.autoAcceptFollows !== false,
|
|
124
|
-
hasUiPassword: !!cfg.uiPassword,
|
|
125
123
|
quiescedAt: cfg.quiescedAt || null, movedTo: cfg.movedTo || null,
|
|
126
124
|
mode: a.status().mode,
|
|
127
125
|
// Live from the credential's actual backend on THIS browser (a
|
|
@@ -218,10 +216,6 @@ export class AdminFacade {
|
|
|
218
216
|
.map(f => ({ name: String(f.name).trim(), value: String(f.value ?? '').trim() }));
|
|
219
217
|
}
|
|
220
218
|
if ('autoAcceptFollows' in body) cfg.autoAcceptFollows = !!body.autoAcceptFollows;
|
|
221
|
-
if ('password' in body) {
|
|
222
|
-
if (body.password) cfg.uiPassword = hashPassword(body.password);
|
|
223
|
-
else delete cfg.uiPassword;
|
|
224
|
-
}
|
|
225
219
|
const republish = WIRE_CONFIG.some(k => k in body);
|
|
226
220
|
a.store.setConfig(cfg);
|
|
227
221
|
if ('autoAcceptFollows' in body && a.publisher) a.publisher.config.autoAcceptFollows = cfg.autoAcceptFollows;
|