fedipod 1.49.2 → 1.50.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 = {}, mount = '') {
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="${escapeHtml(mount)}/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)) }, api.mount || '');
584
- }
585
- if (!pw || !checkPassword(pw, body.password || '')) {
586
- return sendLoginForm(res, params, 'wrong password — try again', client, null, {}, api.mount || '');
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, null, {}, api.mount || '');
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' });
@@ -10,6 +10,13 @@ import { sanitizeHtml, followsNeedApproval, publicHandle } from '../../core/wire
10
10
  import { profileUrl, postUrl } from '../../connections/bskyfeed.mjs';
11
11
  import { htmlToText } from './body.mjs';
12
12
 
13
+ // Posts stored from 2026-09-13 to 2026-10-10 hold a Mastodon post's text twice,
14
+ // joined by a comma; shown once.
15
+ export const once = (html) => {
16
+ const m = (html.length - 1) / 2;
17
+ return Number.isInteger(m) && html[m] === ',' && html.slice(0, m) === html.slice(m + 1) ? html.slice(0, m) : html;
18
+ };
19
+
13
20
  // 1x1 transparent PNG — placeholder header, and the instance thumbnail. A
14
21
  // banner nobody set should take up no room and show nothing; an avatar is a
15
22
  // different matter (below).
@@ -273,7 +280,7 @@ export function status(api, s, { all, depth = 0 } = {}) {
273
280
  created_at: s.published || new Date().toISOString(),
274
281
  in_reply_to_id: s.inReplyTo ? api.store.idFor(s.inReplyTo) : null,
275
282
  in_reply_to_account_id: null,
276
- sensitive: !!(s.spoiler || s.sensitive), spoiler_text: s.spoiler || '',
283
+ sensitive: !!(s.spoiler || s.sensitive), spoiler_text: once(s.spoiler || ''),
277
284
  visibility: s.visibility || 'public', language: null,
278
285
  edited_at: s.editedAt || null,
279
286
  uri: s.noteId, url: s.link || s.noteId,
@@ -284,7 +291,7 @@ export function status(api, s, { all, depth = 0 } = {}) {
284
291
  favourited: !!s.favourited || (s.sourceAccts || []).some(v => v.favourited),
285
292
  reblogged: !!s.reblogged || (s.sourceAccts || []).some(v => v.reblogged),
286
293
  muted: false, bookmarked: !!s.bookmarked, pinned: !!s.pinned,
287
- content: s.content || '',
294
+ content: once(s.content || ''),
288
295
  reblog: null, application: null,
289
296
  account: api.account(s.actor),
290
297
  media_attachments: (s.attachments || []).map(a => api.mediaJson(a)),
@@ -323,7 +330,7 @@ export function status(api, s, { all, depth = 0 } = {}) {
323
330
  // information it uses to decide anyway.
324
331
  export function filtersFor(api, s) {
325
332
  const now = Date.now();
326
- const hay = `${s.content || ''} ${s.spoiler || ''}`
333
+ const hay = `${once(s.content || '')} ${once(s.spoiler || '')}`
327
334
  .replace(/<[^>]*>/gu, ' ') // the text, not the markup around it
328
335
  .toLowerCase();
329
336
  if (!hay.trim()) return [];
@@ -403,7 +410,7 @@ export function pushNotify(api, n) {
403
410
  return api.push.notify(n, {
404
411
  notification_id: n.id, notification_type: n.type, preferred_locale: 'en',
405
412
  title: `${acct.display_name || acct.acct} ${verbs[n.type] || n.type}`,
406
- body: s ? htmlToText(s.content || '').slice(0, 140) : '',
413
+ body: s ? htmlToText(once(s.content || '')).slice(0, 140) : '',
407
414
  icon: acct.avatar || '',
408
415
  });
409
416
  }
@@ -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
@@ -227,6 +227,8 @@ function fromLiteral(term) {
227
227
  return term.value;
228
228
  }
229
229
 
230
+ const isTagged = (term) => term.termType === 'Literal' && !!term.language;
231
+
230
232
  function toValue(term, ctx, depth, path) {
231
233
  if (term.termType === 'Literal') return fromLiteral(term);
232
234
  if (isListHead(term, ctx)) return readList(term.value, ctx, depth, path);
@@ -248,8 +250,14 @@ function readProperty(subject, name, ctx, depth = 0, path = new Set()) {
248
250
 
249
251
  const term = TERMS.get(name);
250
252
  if (!term) return undefined;
251
- const values = preds.get(term.iri);
253
+ let values = preds.get(term.iri);
252
254
  if (!values || !values.length) return undefined;
255
+ // `content` and `contentMap` are one predicate, and Mastodon sends a post's
256
+ // text both ways. The plain term is the untagged copy, else the first tagged.
257
+ if (values.some(isTagged)) {
258
+ const plain = values.filter(v => !isTagged(v));
259
+ values = plain.length ? plain : [values.find(isTagged)];
260
+ }
253
261
 
254
262
  // `items` and `orderedItems` are the same predicate; the list is what tells
255
263
  // them apart, so asking for the ordered one only answers when it is ordered.
@@ -210,6 +210,7 @@ export async function rebuildStatuses(publisher, { fromNotes = false } = {}) {
210
210
  // merge-only rebuild when something is.
211
211
  export async function healStatuses(publisher) {
212
212
  const { urls, store } = publisher;
213
+ if (store.unread?.has('statuses.json')) return { missing: 0, recovered: 0 }; // nothing to compare with
213
214
  const own = store.read('outbox.json', [])
214
215
  .map(i => (typeof i === 'string' ? i : null))
215
216
  .filter(id => id && id.startsWith(urls.notes));
@@ -90,6 +90,10 @@ export class PodStore {
90
90
  this._held = 0;
91
91
  this.chain = Promise.resolve(); // serialized writes
92
92
  this.verdicts = new Map(); // name → did its last write land? (read by commit)
93
+ // Documents the pod holds that could not be read, and that nothing here
94
+ // has a copy of. Never written to until they are read: what this store
95
+ // would send is not the document, and would replace it on the pod.
96
+ this.unread = new Set();
93
97
  // Moves on each time queued writes are dropped (discardPending): work that
94
98
  // began in an earlier generation has lost results, whatever a commit says.
95
99
  this.generation = 0;
@@ -106,7 +110,7 @@ export class PodStore {
106
110
  attach(storage) {
107
111
  // A different tree is different state: carrying the old cache and its
108
112
  // ETags across would serve one container's documents as another's.
109
- if (this.storage && this.storage.base !== storage.base) { this.cache.clear(); this.etags.clear(); this.lastText.clear(); }
113
+ if (this.storage && this.storage.base !== storage.base) { this.cache.clear(); this.etags.clear(); this.lastText.clear(); this.unread.clear(); }
110
114
  this.storage = storage;
111
115
  }
112
116
 
@@ -137,8 +141,8 @@ export class PodStore {
137
141
  if (listing.missing && this.storage.kind === 'pod') {
138
142
  throw new Error(`state container missing (HTTP 404) at ${this.base} — pod unreachable or state gone`);
139
143
  }
140
- this.etags.set('', listing.etag);
141
144
  const names = listing.names.filter(n => n.endsWith('.json'));
145
+ for (const name of this.unread) if (!names.includes(name)) this.unread.delete(name); // gone: nothing to write over
142
146
  let fetched = 0;
143
147
  const skipped = [];
144
148
  for (const name of names) {
@@ -147,17 +151,20 @@ export class PodStore {
147
151
  if (r.notModified) continue; // ours is current
148
152
  if (!r.ok) {
149
153
  // One unreadable document must not restart the whole sweep — skip it,
150
- // keep whatever we already hold, and try again next load (no etag is
151
- // recorded, so the retry is unconditional). config.json is the
154
+ // keep whatever we already hold, and try again next load (neither its
155
+ // etag nor the container's is recorded, so the next load lists and
156
+ // reads it again). config.json is the
152
157
  // exception: without it the caller cannot tell "never set up" from
153
158
  // "could not read", and would tell the user to run setup.
154
159
  if (name === 'config.json' && !this.cache.has(name)) {
155
160
  throw new Error(`state doc ${name} unreadable (HTTP ${r.status})`);
156
161
  }
162
+ if (!this.lastText.has(name)) this.unread.add(name);
157
163
  skipped.push(`${name} (HTTP ${r.status})`);
158
164
  continue;
159
165
  }
160
166
  fetched++;
167
+ this.unread.delete(name);
161
168
  this.etags.set(name, r.etag);
162
169
  try { this.cache.set(name, JSON.parse(r.body)); this.lastText.set(name, r.body); }
163
170
  catch (e) { this.log(`state load ${name}: unparsable (${e.message})`); }
@@ -165,7 +172,12 @@ export class PodStore {
165
172
  // Kept for /status: a document skipped here is a timeline or a contact
166
173
  // list quietly missing, and the owner should be able to see that.
167
174
  this.lastSkipped = skipped;
168
- if (skipped.length) this.log(`state load skipped ${skipped.length}: ${skipped.join(', ')}`);
175
+ // The container's ETag vouches for every document in it, so it is kept
176
+ // only when every one was read; otherwise the next load would stop at
177
+ // "not modified" and never retry what was skipped.
178
+ if (skipped.length) this.etags.delete(''); else this.etags.set('', listing.etag);
179
+ if (skipped.length) this.log(`state load skipped ${skipped.length}: ${skipped.join(', ')}`
180
+ + (this.unread.size ? ` — not written until read: ${[...this.unread].join(', ')}` : ''));
169
181
  this.log(`state loaded: ${this.cache.size} doc(s) from ${this.base} (${fetched} re-fetched)`);
170
182
  this.onLoaded?.();
171
183
  }
@@ -177,6 +189,7 @@ export class PodStore {
177
189
  for (const { name, text, etag } of docs) {
178
190
  try { this.cache.set(name, JSON.parse(text)); } catch { continue; }
179
191
  this.lastText.set(name, text);
192
+ this.unread.delete(name);
180
193
  if (etag) this.etags.set(name, etag);
181
194
  }
182
195
  if (listingEtag) this.etags.set('', listingEtag);
@@ -193,11 +206,12 @@ export class PodStore {
193
206
  const r = await this.storage.read(name, etag ? { etag } : undefined);
194
207
  if (r.notModified) continue;
195
208
  if (r.status === 404) {
196
- this.cache.delete(name); this.lastText.delete(name); this.etags.delete(name);
209
+ this.cache.delete(name); this.lastText.delete(name); this.etags.delete(name); this.unread.delete(name);
197
210
  } else if (!r.ok) {
198
211
  throw new Error(`state doc ${name} unreadable (HTTP ${r.status})`);
199
212
  } else {
200
213
  this.cache.set(name, JSON.parse(r.body)); this.lastText.set(name, r.body); this.etags.set(name, r.etag);
214
+ this.unread.delete(name);
201
215
  }
202
216
  this.onSettled?.(name);
203
217
  }
@@ -216,6 +230,8 @@ export class PodStore {
216
230
  write(name, obj) {
217
231
  this.cache.set(name, structuredClone(obj));
218
232
  if (!this.storage) return;
233
+ // Not sent: the next commit says it did not land, and reads it first.
234
+ if (this.unread.has(name)) { this.verdicts.set(name, false); return; }
219
235
  // The same bytes the pod already holds are not sent again. A like, a
220
236
  // bookmark, a sweep that found nothing new each used to upload the whole
221
237
  // timeline index and the whole people cache, unchanged — the largest
@@ -263,6 +279,7 @@ export class PodStore {
263
279
  // the returned promise so one failure cannot poison the queue.
264
280
  _put(name) {
265
281
  const done = this.chain.then(async () => {
282
+ if (this.unread.has(name)) return false;
266
283
  const body = serialise(this.cache.get(name));
267
284
  await Promise.resolve(this.onWriting?.(name, body)).catch(() => {});
268
285
  for (let attempt = 1; attempt <= PUT_RETRIES; attempt++) {
@@ -313,6 +330,10 @@ export class PodStore {
313
330
  // `since`: the generation the caller's work began in. Anything dropped since
314
331
  // makes the answer false.
315
332
  async commit({ since } = {}) {
333
+ // A document that could not be read is read now, replacing whatever was
334
+ // written to it here; those writes already count as not landed, so the
335
+ // drain keeps its items and takes them again against the real document.
336
+ if (this.unread.size && this.storage) await this.refresh([...this.unread]).catch(() => {});
316
337
  const pending = [];
317
338
  // Held names too, or a sweep that suspended the debounce would delete the
318
339
  // pod's copy of an item whose result had not been written down — the one
@@ -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);