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 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], ['passwd', setup.passwd], ['keys', setup.keys],
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|passwd'
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 now needs one.** A client running on its own
87
- site (Elk, Phanpy on someone's own domain) asks the agent to send the sign-in
88
- result back to that site. Without a password there is nothing to approve that
89
- with — and the agent used to do it anyway, which meant any page you happened
90
- to have open could ask for the same thing and be given a key to your account.
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
- // password gate, and the three /oauth endpoints plus app registration.
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
  ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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
- // With no UI password configured this is "theater": whoever can reach the
455
- // loopback surface IS the trusted user, and authorize redirects at once.
456
- // With a password set (required before any non-loopback exposure), the
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 stands in for the password
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 without a password whenever the request "is this machine". A page
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. With NO password set there is nothing to authorize a third party
534
- // WITH. The password-less path is honest only for a client on an
535
- // address this agent itself answers on — where "whoever reaches the
536
- // port is the owner" is a statement about the machine rather than
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.store.getConfig()?.uiPassword && !api.redirectAllowed(redirect)) {
547
- // With a pod to sign in at, the owner approves the client there.
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: no UI password, and "${redirect}" is not an address of this agent`);
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 agent, and no password is '
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 (req.method === 'POST' && !proved) {
577
- if (api.rateLimited()) {
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
- // No password set, and this request did not come from this machine.
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: no UI password, and "${req.headers.host}" is not this machine`);
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 needs a password. On the machine that runs your account, run: fedipod passwd',
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 after the
700
- // password gate. Minting one here for an unrecognised code handed a
701
- // bearer to anyone who could reach the port — and a non-browser client
702
- // sends no Origin, so the firewall never saw it. That defeated `passwd`
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' });
@@ -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 — the
214
- // UI password and anything else set later must survive it.
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. Exposure without a gate
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, passwd, keys.
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
@@ -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, uiPassword,
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
@@ -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 password-less authorize path were trusting.
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 — a uiPassword guards the OAuth login and nothing else. Returns a
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: the UI password and anything else set later
176
- // must survive an edit that never mentions it.
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
  }
@@ -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, and `passwd` has never had a group carve-out,
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.49.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",
@@ -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, avatar and UI password it
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>
@@ -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
@@ -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;