fedipod 1.40.0 → 1.42.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.
Files changed (65) hide show
  1. package/README.md +5 -0
  2. package/architecture.md +1 -3
  3. package/architecture.svg +93 -85
  4. package/gateway.md +7 -2
  5. package/lib/client/c2s.mjs +16 -5
  6. package/lib/client/masto/oauth.mjs +1 -1
  7. package/lib/connections/vault.mjs +1 -1
  8. package/lib/core/agent.mjs +720 -0
  9. package/lib/core/gateway-move.mjs +58 -0
  10. package/lib/core/keys.mjs +1 -1
  11. package/lib/core/lease.mjs +4 -3
  12. package/lib/core/publisher/index.mjs +4 -6
  13. package/lib/core/publisher/notes.mjs +73 -4
  14. package/lib/core/remote.mjs +54 -0
  15. package/lib/core/shapes/shapes-text.mjs +11 -3
  16. package/lib/core/wire.mjs +4 -1
  17. package/lib/device/admin/index.mjs +4 -11
  18. package/lib/device/admin/routes/setup.mjs +60 -5
  19. package/lib/device/admin/server.mjs +18 -5
  20. package/lib/device/cli/commands/account.mjs +1 -1
  21. package/lib/device/cli/commands/run.mjs +2 -2
  22. package/lib/device/cli/commands/service.mjs +1 -1
  23. package/lib/device/cli/commands/setup.mjs +1 -1
  24. package/lib/device/cli/commands/state.mjs +1 -1
  25. package/lib/device/cli/context.mjs +1 -1
  26. package/lib/{gateway → device}/directory.mjs +1 -1
  27. package/lib/device/gateway-move.mjs +3 -58
  28. package/lib/device/home.mjs +3 -50
  29. package/lib/{client → device}/localapi.mjs +2 -2
  30. package/lib/device/remote.mjs +3 -54
  31. package/lib/gateway/copy.mjs +7 -1
  32. package/lib/gateway/front-core.mjs +6 -16
  33. package/lib/gateway/front-pages.mjs +31 -0
  34. package/lib/gateway/keeper-session.mjs +10 -1
  35. package/lib/gateway/masto-gateway.mjs +61 -12
  36. package/lib/gateway/outbox-door.mjs +31 -0
  37. package/lib/gateway/state-api.mjs +39 -7
  38. package/lib/server/embed.mjs +4 -4
  39. package/lib/shared/files.mjs +49 -0
  40. package/lib/shared/ua.mjs +3 -0
  41. package/lib/surface/index.mjs +7 -0
  42. package/lib/{device/admin → surface}/origins.mjs +1 -1
  43. package/lib/{device/admin → surface}/routes/gateway.mjs +7 -11
  44. package/lib/{device/admin → surface}/routes/lifecycle.mjs +3 -3
  45. package/lib/{device/admin → surface}/routes/owner.mjs +4 -60
  46. package/lib/{device/admin → surface}/routes/social.mjs +1 -1
  47. package/lib/{device/admin → surface}/static.mjs +6 -1
  48. package/lib/{device/admin → surface}/surface.mjs +19 -16
  49. package/package.json +23 -5
  50. package/run-agent.mjs +18 -714
  51. package/scripts/build-app.mjs +4 -0
  52. package/scripts/check-netlify-bundles.mjs +84 -0
  53. package/scripts/check-pod-calls.mjs +42 -0
  54. package/scripts/vendor-sanitize.mjs +27 -0
  55. package/vendor/idp-grant.cjs +11 -5
  56. package/vendor/sanitize-html.cjs +11456 -0
  57. package/web/app/agent.mjs +4 -2
  58. package/web/app/copy-mode.mjs +27 -3
  59. package/web/app/dist/boot.js.map +1 -1
  60. package/web/app/dist/sw.js +110 -24
  61. package/web/app/dist/sw.js.map +4 -4
  62. package/web/app-signin/app-signin.mjs +2 -2
  63. package/web/app-signin/index.html +13 -5
  64. package/browser.svg +0 -72
  65. /package/lib/{device/admin → surface}/routes/connections.mjs +0 -0
@@ -13,7 +13,9 @@
13
13
  import fs from 'node:fs';
14
14
  import os from 'node:os';
15
15
  import path from 'node:path';
16
- import { fileURLToPath } from 'node:url';
16
+ import { writeJsonAtomic } from '../shared/files.mjs';
17
+
18
+ export { writeFileAtomic, writeJsonAtomic, tildify } from '../shared/files.mjs';
17
19
 
18
20
  export const CURRENT_ROOT = '.fedipod';
19
21
  export const LEGACY_ROOTS = ['.solid-activitypub', '.activitypod']; // most recent first
@@ -31,39 +33,6 @@ export function apRoot(homedir = os.homedir()) {
31
33
 
32
34
  export const isLegacyRoot = (root) => LEGACY_ROOTS.includes(path.basename(root));
33
35
 
34
- // Write one of this home's files without a window in which it is half there.
35
- //
36
- // lib/storage.mjs goes to the trouble of tmp+rename for state documents that
37
- // its own header calls rebuildable from the pod. The files here are the ones
38
- // that are NOT: keys.json is the private key remote servers have cached, and
39
- // credential.json holds a secret a Solid server mints once and will not mint
40
- // again. A plain writeFileSync truncates first, so a crash, a power loss or a
41
- // full disk between truncate and write leaves an empty or half-written file
42
- // where the identity used to be.
43
- //
44
- // rename(2) is atomic on POSIX, so a reader — or a backup running at the wrong
45
- // moment — sees either the old file or the new one. fsync before it, so the
46
- // content is on the platter and not merely in the page cache when the rename
47
- // lands. Windows has no atomic rename over an existing file; there the copy
48
- // path is still better than a truncate, and this is a best effort by design.
49
- export function writeFileAtomic(file, body, { mode = 0o600 } = {}) {
50
- const tmp = `${file}.${process.pid}.tmp`;
51
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
52
- const fd = fs.openSync(tmp, 'w', mode);
53
- try {
54
- fs.writeFileSync(fd, body);
55
- fs.fsyncSync(fd);
56
- } finally {
57
- fs.closeSync(fd);
58
- }
59
- fs.renameSync(tmp, file);
60
- }
61
-
62
- // The same, for the JSON these files all happen to be.
63
- export function writeJsonAtomic(file, obj, opts) {
64
- writeFileAtomic(file, JSON.stringify(obj, null, 2) + '\n', opts);
65
- }
66
-
67
36
  export const profilesDir = (root) => path.join(root, 'profiles');
68
37
 
69
38
  // The root an arbitrary home belongs to. `<root>/profiles/<name>` is a profile;
@@ -136,22 +105,6 @@ export const profileHome = (root, name) => path.join(profilesDir(root), name);
136
105
  export const rootHoldsIdentity = (root) => fs.existsSync(path.join(root, 'credential.json'));
137
106
 
138
107
 
139
- // For DISPLAY only: `~/.fedipod` reads at a glance where
140
- // `/home/jeff/.fedipod` has to be parsed, and the home directory is
141
- // the least interesting part of every path this prints. A `file:` URL is a path
142
- // wearing a scheme, so it is shown as the path — which is also what you would
143
- // type, since `state --to` takes either.
144
- //
145
- // Never used to build a path. `~` is the shell's, not the filesystem's, and
146
- // anything that resolves one must expand it first.
147
- export function tildify(p, homedir = os.homedir()) {
148
- if (!p) return p;
149
- let s = String(p);
150
- if (s.startsWith('file://')) { try { s = fileURLToPath(s); } catch { return s; } }
151
- if (s === homedir) return '~';
152
- return s.startsWith(homedir + path.sep) ? '~' + s.slice(homedir.length) : s;
153
- }
154
-
155
108
  // Using an identity is what makes it the default. Called when an agent starts
156
109
  // for one — not on every read, or `--profile other status` would quietly move
157
110
  // the machine's idea of "you" while only asking a question.
@@ -12,8 +12,8 @@
12
12
  import https from 'node:https';
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
- import { rootOf, apRoot } from '../device/home.mjs';
16
- import { certPaths } from '../device/certs.mjs';
15
+ import { rootOf, apRoot } from './home.mjs';
16
+ import { certPaths } from './certs.mjs';
17
17
 
18
18
  /**
19
19
  * The authorities worth offering for a loopback call: this install's, and the
@@ -1,54 +1,3 @@
1
- // remote.mjs — the Node agent's pod transport: CSS client-credentials + DPoP
2
- // (vendor/idp-grant.cjs, extracted from data-kitchen's "remember this IdP"
3
- // machinery — plain Node, no Electron).
4
- //
5
- // Everything about talking to a pod — the verbs, the ACL documents, the
6
- // deletion deny-list, the Retry-After cooldown, the container listing — lives
7
- // in lib/pod/transport.mjs, which knows nothing about Node and is shared with
8
- // the browser. What is left here is the one thing that genuinely differs: how
9
- // the session is obtained. That top-level `require` of the vendor grant is also
10
- // exactly why the split exists — it cannot be bundled for a service worker.
11
-
12
- import { createRequire } from 'node:module';
13
- import path from 'node:path';
14
- import { fileURLToPath } from 'node:url';
15
- import { PodTransport } from '../pod/transport.mjs';
16
-
17
- const require = createRequire(import.meta.url);
18
- const vendorDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../vendor');
19
- const { mintCredential, discoverTokenEndpoint, createGrantSession, revokeCredentialViaAccount } =
20
- require(path.join(vendorDir, 'idp-grant.cjs'));
21
-
22
- export { mintCredential, discoverTokenEndpoint, revokeCredentialViaAccount };
23
-
24
- // Re-exported so the callers that guard their own deletions — run-agent, the
25
- // server package's live test, the smoke suite — keep importing it from here.
26
- export { protectedFromDeletion } from '../pod/transport.mjs';
27
-
28
- export class RemotePod extends PodTransport {
29
- constructor(credential, { log = () => {}, home = null, session = null, role = 'agent' } = {}) {
30
- // An injected session replaces the credential-backed one, and everything
31
- // above it — the cooldown, the deny-list, the url map — is unchanged. That
32
- // is how the agent runs inside a pod server: same class, no token to mint.
33
- super(session || createGrantSession(credential, home ? {
34
- backoffFile: path.join(home, 'backoff.json'),
35
- tokenFile: path.join(home, 'token.json'),
36
- } : {}), {
37
- webId: credential.webId,
38
- log,
39
- role,
40
- runtime: 'node',
41
- // Fail fast for the window rather than sleeping it out: a daemon has
42
- // timers behind it, and a request that waits is a timer that does not
43
- // fire. The callers' existing retry paths take it from there.
44
- cooldownMode: 'refuse',
45
- });
46
- }
47
-
48
- async warmup() { return this.session.warmup(); }
49
-
50
- /** The grant's own counters, under the transport's. */
51
- stats() {
52
- return { ...(this.session.stats?.() || {}), ...super.stats() };
53
- }
54
- }
1
+ // The pod transport lives in lib/core/remote.mjs; this path is kept for the
2
+ // callers that import it from here.
3
+ export { RemotePod, mintCredential, discoverTokenEndpoint, revokeCredentialViaAccount, protectedFromDeletion } from '../core/remote.mjs';
@@ -263,7 +263,13 @@ export async function lockCopy(kv, h, { ms = 60_000, waitMs = 8_000, by = crypto
263
263
  export async function fillCopy(kv, h, { pod, podFetch, stateUrl, webId = null, log = () => {} }) {
264
264
  if (await copyMeta(kv, h)) return { ok: true, already: true };
265
265
  const podLease = new Lease({ url: stateUrl + 'lease.json', fetchImpl: podFetch, log, id: POD_HOLDER });
266
- if (!await podLease.acquire()) return { ok: false, why: 'the account is active on another device' };
266
+ if (!await podLease.acquire()) {
267
+ // Held is another device acting on the pod; unreadable is the gateway's
268
+ // own identity refused or unanswered there, which no device has to do with.
269
+ return podLease.denied === 'unreadable'
270
+ ? { ok: false, why: `the pod could not be read (${podLease.unreadable})` }
271
+ : { ok: false, why: 'the account is active on another device' };
272
+ }
267
273
  const undo = async (why) => {
268
274
  for (const b of await kv.list(`${h}/d/`).catch(() => [])) await kv.delete(b.key).catch(() => {});
269
275
  await podLease.release().catch(() => {});
@@ -16,7 +16,8 @@
16
16
  // HTTPS box is another. UN-DEPLOYED — nothing in FediPod runs it.
17
17
 
18
18
  import crypto from 'node:crypto';
19
- import { handleDelivery, handleOwnerPost } from './gateway-core.mjs';
19
+ import { handleDelivery } from './gateway-core.mjs';
20
+ import { ownerPostAtDoor } from './outbox-door.mjs';
20
21
  import { readCapped, safeFetch, isLoopbackHost } from '../shared/safefetch.mjs';
21
22
  import * as podRoot from '../pod/root.mjs';
22
23
  import * as podPolicy from '../pod/policy.mjs';
@@ -529,6 +530,9 @@ async function route(request, ctx) {
529
530
  handle, address, podHome, webId: webid, actorUrl,
530
531
  kind: body.kind === 'group' ? 'group' : body.kind === 'application' ? 'application' : 'person',
531
532
  gatewayWebId: ctx.gatewayWebId || null, hmacSecret,
533
+ // When the account was set up here: an app signing in a moment later is
534
+ // told it is still being got ready, not that it cannot be used.
535
+ attachedAt: new Date().toISOString(),
532
536
  ...(inboxUrl ? { inboxUrl } : {}),
533
537
  ...(fronted ? {} : { inboxOnly: true }),
534
538
  };
@@ -847,21 +851,7 @@ async function route(request, ctx) {
847
851
  return { status: 301, headers: { ...cors, location: movedBase + up.rest, 'cache-control': 'no-store' }, body: '' };
848
852
  }
849
853
  if (request.method === 'POST') {
850
- const json = (status, obj, extra = {}) => ({ status,
851
- headers: { ...cors, 'content-type': 'application/json', 'cache-control': 'no-store', ...extra },
852
- body: JSON.stringify(obj) });
853
- const webid = await verifyPodToken(request, pathname, ctx.verifier);
854
- if (!webid) return json(401, { error: 'a Solid-OIDC token proving this account\'s owner is required' });
855
- const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
856
- if (!owner) return json(403, { error: 'this outbox belongs to its owner alone' });
857
- const onPod = (u) => u.replace(rec.actorUrl.replace(/ap\/actor$/u, ''), rec.podHome);
858
- const { status, reason, location, object } = await handleOwnerPost(request, identFor(rec),
859
- { podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), ownerWebId: webid,
860
- exists: async (u) => ((await (ctx.podGet || fetch)(onPod(u), { podHome: rec.podHome })).status !== 404) });
861
- console.log(`door @${up.handle}: owner post → ${status} (${reason})`);
862
- if (status !== 201) return json(status, { error: reason });
863
- return json(201, { accepted: true, ...(location ? { id: location } : {}), ...(object ? { object } : {}),
864
- note: 'it goes out when your FediPod agent next runs' }, location ? { location } : {});
854
+ return ownerPostAtDoor(request, pathname, ctx, rec, up.handle, { cors, identFor, verifyPodToken, webidUnderPod });
865
855
  }
866
856
  // Its owner, signed in, reads every message the account produced (§5.1:
867
857
  // the outbox is filtered by who asks); that view is on the pod, under the
@@ -0,0 +1,31 @@
1
+ // front-pages.mjs — the front's pages and the files they load, read from
2
+ // beside this package: the opt-in page, the roster, the notices page, the
3
+ // new-account page, each page's own script, the sign-in library and the
4
+ // installer. A file that is not there comes back null, and the route it
5
+ // feeds answers 404.
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const FRONT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../web/front');
12
+ const SCRIPTS = ['new-account.js', 'run.js', 'admin.js', 'notices.js'];
13
+
14
+ const readOrNull = (name) => {
15
+ try { return fs.readFileSync(path.join(FRONT_DIR, name), 'utf8'); } catch { return null; }
16
+ };
17
+
18
+ /** The pages routeFront serves, in the shape its options take them. */
19
+ export function frontPages() {
20
+ const pageScripts = {};
21
+ for (const name of SCRIPTS) pageScripts[name] = readOrNull(name);
22
+ return {
23
+ signupPage: readOrNull('new-account.html'),
24
+ runPage: readOrNull('run.html'),
25
+ adminPage: readOrNull('admin.html'),
26
+ noticesPage: readOrNull('notices.html'),
27
+ authBundle: readOrNull('solid-oidc-client.js'),
28
+ pageScripts,
29
+ installScript: readOrNull('install.sh'),
30
+ };
31
+ }
@@ -1,10 +1,19 @@
1
1
  // keeper-session.mjs — the gateway's own pod identity at work: one grant per
2
2
  // running copy, shared by every account it works for, so a round is one token,
3
3
  // not one per account (keeper.mjs, state-api.mjs).
4
+ import crypto from 'node:crypto';
4
5
  import grant from '../../vendor/idp-grant.cjs';
5
6
 
7
+ // The same credential is the same session, however it arrives: the deployment
8
+ // reads its settings into a new object each time, and comparing objects made
9
+ // a new session, and so a new sign-in at the issuer, for every request that
10
+ // reached a pod. Kept as a hash, so the secret is not held here a second time.
11
+ const markOf = (cred) => crypto.createHash('sha256')
12
+ .update(`${cred.clientId}\n${cred.secret}\n${cred.tokenEndpoint}`).digest('hex');
13
+
6
14
  let shared = null;
7
15
  export function keeperSession(cred) {
8
- if (!shared || shared.cred !== cred) shared = { cred, session: grant.createGrantSession(cred) };
16
+ const mark = markOf(cred);
17
+ if (!shared || shared.mark !== mark) shared = { mark, session: grant.createGrantSession(cred) };
9
18
  return shared.session;
10
19
  }
@@ -7,7 +7,7 @@
7
7
  // (/app-signin/, web/app-signin/), which
8
8
  // signs them in at their own pod
9
9
  // GET /api/authorize?client_id=… what the page shows: the app's name
10
- // GET /api/authorize?address=… the pod an address here belongs to
10
+ // GET /api/authorize?address=… the pod an address here, or a WebID, belongs to
11
11
  // POST /api/authorize the pod sign-in, proved: a code for the app
12
12
  // POST /oauth/token, /oauth/revoke the code for a token, and back
13
13
  // GET /api/v1/instance, /api/v2/instance fedipod.net itself
@@ -97,6 +97,56 @@ function handleOf(address, host) {
97
97
  return !at || at === String(host || '').toLowerCase() ? name : a;
98
98
  }
99
99
 
100
+ /**
101
+ * The account an address names: a Fediverse address here ("you", "@you",
102
+ * "you@host", "@you@host", or a whole address on the person's own pod), or a
103
+ * WebID, which names the browser account here that belongs to it. An account
104
+ * FediPod has not finished setting up counts: the caller says it is not ready.
105
+ * Returns { handle, rec }, or { error: [status, message] }.
106
+ */
107
+ async function accountFor(ctx, address, host) {
108
+ const a = String(address || '').trim();
109
+ const here = (r) => !!r && !r.movedTo && !r.closedAt;
110
+ if (/^https?:\/\//iu.test(a)) {
111
+ const mine = Object.entries(await ctx.listDirectory?.() || {}).filter(([, r]) => r?.webId === a && here(r));
112
+ // Kept running first: that is the one an app can use; then opened ones.
113
+ const kept = mine.filter(([, r]) => r.openedAt && r.keeper);
114
+ const opened = mine.filter(([, r]) => r.openedAt);
115
+ const pick = kept.length ? kept : opened.length ? opened : mine;
116
+ if (pick.length > 1) return { error: [409, 'this WebID has more than one account here; give the Fediverse address of the one to use'] };
117
+ if (!pick.length) return { error: [404, 'there is no account here for that WebID'] };
118
+ return { handle: pick[0][0], rec: pick[0][1] };
119
+ }
120
+ const handle = handleOf(a, host);
121
+ // Read fresh: a person is signing in, perhaps a moment after signing up.
122
+ const rec = handle ? await (ctx.lookupFresh || ctx.lookup)(handle) : null;
123
+ if (!here(rec)) return { error: [404, 'there is no account with that address here'] };
124
+ return { handle, rec };
125
+ }
126
+
127
+ // An account here that an app cannot use. The forum and its categories are
128
+ // run by a server. A personal account set up in the last few minutes and not
129
+ // yet opened: FediPod has not finished its first start. An older one never
130
+ // opened in a browser: made before FediPod noted openings, or run by a server;
131
+ // the gateway cannot tell which. Opened but not kept: new, or its owner
132
+ // stopped keeping it running. The minute rests on a first start seen on
133
+ // 2026-09-26 (24 seconds from opening to kept).
134
+ const NEW_FOR_MS = 10 * 60_000;
135
+ const SERVER_RUN = 'This account is run by a server, not by FediPod in a browser, so apps can\'t sign in to it here.';
136
+ const NOT_READY_NEW = 'This account was set up moments ago, and FediPod is still getting it ready for apps. '
137
+ + 'That usually takes under a minute. Leave FediPod open in your browser, then try again.';
138
+ const NOT_IN_BROWSER = 'An app can only sign in to an account that FediPod runs in your browser. '
139
+ + 'If this is one, open it in FediPod first, then try again.';
140
+ const NOT_READY = 'This account can\'t be used from an app yet. If you set it up in the last few minutes, FediPod is '
141
+ + 'still getting it ready: that usually takes under a minute, so leave FediPod open in your browser and try again. '
142
+ + 'Otherwise, press "Keep my account running while I\'m away" on your account\'s manage page, then try again.';
143
+ function notReady(rec) {
144
+ if (rec.kind === 'group' || rec.kind === 'application') return SERVER_RUN;
145
+ if (rec.openedAt) return NOT_READY;
146
+ const setUp = Date.parse(rec.attachedAt || '');
147
+ return setUp && Date.now() - setUp < NEW_FOR_MS ? NOT_READY_NEW : NOT_IN_BROWSER;
148
+ }
149
+
100
150
  // An account an app may sign in to: a browser account here, kept running, not
101
151
  // moved or closed.
102
152
  const usable = (ctx, rec) => !!(rec && rec.openedAt && keptNow(ctx, rec) && !rec.movedTo && !rec.closedAt);
@@ -409,14 +459,11 @@ async function route(request, pathname, ctx, deps, log) {
409
459
  let where = null; try { where = redirect.startsWith('urn:') ? null : new URL(redirect).host || null; } catch { /* a custom scheme */ }
410
460
  return json(200, { name: app.name, website: app.website || null, sendsTo: where });
411
461
  }
412
- const handle = handleOf(url.searchParams.get('address'), host);
413
- const rec = handle ? await ctx.lookup(handle) : null;
414
- if (!rec?.openedAt || rec.movedTo || rec.closedAt) return json(404, { error: 'there is no account with that address here' });
462
+ const found = await accountFor(ctx, url.searchParams.get('address'), host);
463
+ if (found.error) return json(found.error[0], { error: found.error[1] });
464
+ const { handle, rec } = found;
415
465
  if (keptBefore(ctx, rec)) return json(503, { error: MOVING });
416
- if (!rec.keeper) {
417
- return json(409, { error: 'this account works only while FediPod is open. To use it from an app, turn on '
418
- + '"Keep my account running while I\'m away" on its manage page first.' });
419
- }
466
+ if (!rec.openedAt || !rec.keeper) return json(409, { error: notReady(rec) });
420
467
  return json(200, { handle, webId: rec.webId });
421
468
  }
422
469
 
@@ -425,10 +472,11 @@ async function route(request, pathname, ctx, deps, log) {
425
472
  const webid = await deps.verifyPodToken(request, pathname, ctx.verifier);
426
473
  if (!webid) return json(401, { error: 'sign in at your pod first' });
427
474
  const body = await request.clone().json().catch(() => ({}));
428
- const handle = handleOf(body.address, host);
429
- const rec = handle ? await ctx.lookup(handle) : null;
475
+ const found = await accountFor(ctx, body.address, host);
476
+ if (found.error) return json(found.error[0], { error: found.error[1] });
477
+ const { handle, rec } = found;
430
478
  if (keptBefore(ctx, rec)) return json(503, { error: MOVING });
431
- if (!usable(ctx, rec)) return json(404, { error: 'there is no account kept running with that address here' });
479
+ if (!usable(ctx, rec)) return json(409, { error: notReady(rec) });
432
480
  if (rec.webId !== webid) return json(403, { error: `you signed in as ${webid}, and this account belongs to ${rec.webId}` });
433
481
  const app = await appOf(kv, body.client_id);
434
482
  if (!app) return json(400, { error: 'this app has not registered here' });
@@ -436,7 +484,8 @@ async function route(request, pathname, ctx, deps, log) {
436
484
  if (redirect !== 'urn:ietf:wg:oauth:2.0:oob' && !app.redirectUris.includes(redirect)) {
437
485
  return json(400, { error: 'this app asked to be sent somewhere it did not register' });
438
486
  }
439
- const made = await ensureCopy(ctx, handle, rec, log);
487
+ // The owner, proved: not an app checking in, so not made to wait.
488
+ const made = await ensureCopy(ctx, handle, rec, log, { owner: true });
440
489
  if (!made.ok) return json(made.status === 409 ? 503 : made.status, { error: made.why });
441
490
  const code = rand(24);
442
491
  await putJson(kv, `code/${sha(code)}`, {
@@ -0,0 +1,31 @@
1
+ // outbox-door.mjs — the owner's own post, taken at the outbox door
2
+ // (front-core.mjs routes it here). The owner is proved by a pod sign-in. The
3
+ // post is written into the pod inbox with a receipt (gateway-core:
4
+ // handleOwnerPost); for a kept account whose FediPod is closed, the keeper is
5
+ // started at once to publish it.
6
+ import { handleOwnerPost } from './gateway-core.mjs';
7
+ import { keptNow } from './copy.mjs';
8
+ import { isPresent } from './held-mail.mjs';
9
+
10
+ export async function ownerPostAtDoor(request, pathname, ctx, rec, handle, { cors, identFor, verifyPodToken, webidUnderPod }) {
11
+ const json = (status, obj, extra = {}) => ({ status,
12
+ headers: { ...cors, 'content-type': 'application/json', 'cache-control': 'no-store', ...extra },
13
+ body: JSON.stringify(obj) });
14
+ const webid = await verifyPodToken(request, pathname, ctx.verifier);
15
+ if (!webid) return json(401, { error: 'a Solid-OIDC token proving this account\'s owner is required' });
16
+ const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
17
+ if (!owner) return json(403, { error: 'this outbox belongs to its owner alone' });
18
+ const onPod = (u) => u.replace(rec.actorUrl.replace(/ap\/actor$/u, ''), rec.podHome);
19
+ const { status, reason, location, object } = await handleOwnerPost(request, identFor(rec),
20
+ { podPut: (u, b, ct) => ctx.podPut(handle, u, b, ct), ownerWebId: webid,
21
+ exists: async (u) => ((await (ctx.podGet || fetch)(onPod(u), { podHome: rec.podHome })).status !== 404) });
22
+ console.log(`door @${handle}: owner post → ${status} (${reason})`);
23
+ if (status !== 201) return json(status, { error: reason });
24
+ // Kept and closed: the keeper publishes it now rather than at the next visit.
25
+ let now = false;
26
+ if (keptNow(ctx, rec) && ctx.startKeeper && !(await isPresent(ctx, handle).catch(() => false))) {
27
+ now = await ctx.startKeeper(handle).then(() => true, (e) => { console.log(`door @${handle}: keeper not started: ${e?.message || e}`); return false; });
28
+ }
29
+ return json(201, { accepted: true, ...(location ? { id: location } : {}), ...(object ? { object } : {}),
30
+ note: now ? 'it goes out in a moment' : 'it goes out when your FediPod agent next runs' }, location ? { location } : {});
31
+ }
@@ -49,19 +49,44 @@ export const stateUrlOf = (rec) => `${rec.podHome.replace(/\/?$/u, '/')}ap-state
49
49
  export const keepsCopies = (ctx) => !!(ctx.copyKv && ctx.keeperWebId && ctx.keeperFetch && ctx.stateSecret);
50
50
 
51
51
  const FILL_RETRY_MS = 5 * 60_000;
52
+ // The gateway's own sign-in at its pod's issuer refused: nothing an account
53
+ // does changes that, so no account's copy is tried for this long. Held against
54
+ // the credential it failed with (ctx.keeperMark); a new one is tried at once.
55
+ const LOGIN_PAUSE_MS = 15 * 60_000;
56
+ const LOGIN_REFUSED = '_keeper/login-refused';
57
+ const refusedSignIn = (why) => /token request failed \(HTTP 40[01]\)/u.test(why || '');
58
+ async function loginRefused(ctx) {
59
+ let r = null;
60
+ try { r = JSON.parse((await ctx.copyKv.get(LOGIN_REFUSED))?.text || 'null'); } catch { /* none */ }
61
+ return r && r.mark === (ctx.keeperMark || '') && Date.now() - r.at < LOGIN_PAUSE_MS ? r : null;
62
+ }
63
+
64
+ // The last failed attempt: { at, why }. Written as a bare time before 2026-09-26.
65
+ async function lastFailure(kv, handle) {
66
+ const text = (await kv.get(`${handle}/fill-failed-at`))?.text || '';
67
+ try { const f = JSON.parse(text); return typeof f === 'number' ? { at: f, why: null } : { at: Number(f?.at) || 0, why: f?.why || null }; }
68
+ catch { return { at: 0, why: null }; }
69
+ }
70
+ const noteFailure = (kv, handle, why = null) => kv.set(`${handle}/fill-failed-at`, JSON.stringify({ at: Date.now(), why })).catch(() => {});
52
71
 
53
72
  /**
54
73
  * The copy for an account, made from the pod when there is none: one maker at
55
74
  * a time, and after a failed attempt not again for five minutes, so an app
56
75
  * checking in every minute does not read the pod every minute. `owner`: the
57
- * owner's own browser asking, which is not made to wait. Returns { ok } or
58
- * { ok: false, status, why }.
76
+ * owner asking, from their own browser or by signing in at the app sign-in
77
+ * page, which is not made to wait. After the gateway's own sign-in is refused,
78
+ * no account is tried for fifteen minutes, the owner's included, unless the
79
+ * credential has changed since. Returns { ok } or { ok: false, status, why }.
59
80
  */
60
81
  export async function ensureCopy(ctx, handle, rec, log = console.log, { owner = false } = {}) {
61
82
  if (await copyMeta(ctx.copyKv, handle)) return { ok: true };
62
- const failedAt = Number((await ctx.copyKv.get(`${handle}/fill-failed-at`))?.text || 0);
63
- if (!owner && Date.now() - failedAt < FILL_RETRY_MS) {
64
- return { ok: false, status: 503, why: 'this account could not be opened a moment ago; try again in a few minutes' };
83
+ // The owner too: it is the gateway's credential that has to change.
84
+ const refused = await loginRefused(ctx);
85
+ if (refused) return { ok: false, status: 503, why: `this account could not be opened: ${refused.why}. Try again later` };
86
+ const failed = await lastFailure(ctx.copyKv, handle);
87
+ if (!owner && Date.now() - failed.at < FILL_RETRY_MS) {
88
+ return { ok: false, status: 503,
89
+ why: `this account could not be opened a moment ago${failed.why ? `: ${failed.why}` : ''}. Try again in a few minutes` };
65
90
  }
66
91
  const podFetch = await ctx.keeperFetch();
67
92
  if (!podFetch) return { ok: false, status: 501, why: 'this gateway cannot reach pods for accounts' };
@@ -71,11 +96,16 @@ export async function ensureCopy(ctx, handle, rec, log = console.log, { owner =
71
96
  if (await copyMeta(ctx.copyKv, handle)) return { ok: true };
72
97
  // Marked failed before it starts and cleared when it is done, so an attempt
73
98
  // cut off part way (a request's time running out) counts as failed too.
74
- await ctx.copyKv.set(`${handle}/fill-failed-at`, String(Date.now())).catch(() => {});
99
+ await noteFailure(ctx.copyKv, handle);
75
100
  const stateUrl = stateUrlOf(rec);
76
101
  const made = await fillCopy(ctx.copyKv, handle, { pod: new HttpStorage(stateUrl, podFetch), podFetch, stateUrl, webId: rec.webId, log })
77
102
  .catch((e) => ({ ok: false, why: e.message }));
78
103
  if (made.ok) { await ctx.copyKv.delete(`${handle}/fill-failed-at`).catch(() => {}); return { ok: true }; }
104
+ await noteFailure(ctx.copyKv, handle, made.why);
105
+ log(`copy @${handle}: not made: ${made.why}`);
106
+ if (refusedSignIn(made.why)) {
107
+ await ctx.copyKv.set(LOGIN_REFUSED, JSON.stringify({ at: Date.now(), mark: ctx.keeperMark || '', why: made.why })).catch(() => {});
108
+ }
79
109
  return { ok: false, status: /active on another device/.test(made.why) ? 409 : 502, why: made.why };
80
110
  } finally { await unlock(); }
81
111
  }
@@ -107,7 +137,9 @@ export async function routeStateApi(request, pathname, ctx, deps) {
107
137
  try { body = JSON.parse((await request.clone().text()) || '{}'); } catch { return j(400, { error: 'bad JSON' }); }
108
138
  // The account: named, or the one browser account kept here for this WebID.
109
139
  let handle = String(body.handle || '').toLowerCase();
110
- let rec = handle ? await ctx.lookup(handle) : null;
140
+ // Read fresh: FediPod asks this the moment it has turned keeping on, and a
141
+ // row read a minute earlier would still say the account is not kept.
142
+ let rec = handle ? await (ctx.lookupFresh || ctx.lookup)(handle) : null;
111
143
  if (!handle) {
112
144
  const rows = Object.entries(await ctx.listDirectory?.() || {})
113
145
  .filter(([, r]) => r?.webId === webid && r.openedAt && r.keeper && !r.movedTo && !r.closedAt);
@@ -16,12 +16,12 @@ import path from 'node:path';
16
16
 
17
17
  import { createRequire } from 'node:module';
18
18
 
19
- import { Agent } from '../../run-agent.mjs';
20
- import { RemotePod } from '../device/remote.mjs';
19
+ import { Agent } from '../core/agent.mjs';
20
+ import { RemotePod } from '../core/remote.mjs';
21
21
  import { apUrls, DEFAULT_ROOT } from '../core/wire.mjs';
22
22
  import { handleDelivery } from '../gateway/gateway-core.mjs';
23
- import { writeJsonAtomic } from '../device/home.mjs';
24
- import { buildAdminSurface } from '../device/admin/index.mjs';
23
+ import { writeJsonAtomic } from '../shared/files.mjs';
24
+ import { buildAdminSurface } from '../surface/index.mjs';
25
25
  import { FixedAuthorities } from '../shared/guard.mjs';
26
26
  import { CONNECTION_PREFIX, fileVault, moveVault, podVault } from '../connections/vault.mjs';
27
27
 
@@ -0,0 +1,49 @@
1
+ // files.mjs — writing a file without a window in which it is half there, and
2
+ // showing a path the way a person types it.
3
+ //
4
+ // The files written this way are the ones nothing can rebuild: keys.json is
5
+ // the private key remote servers have cached, and credential.json holds a
6
+ // secret a Solid server mints once and will not mint again. A plain
7
+ // writeFileSync truncates first, so a crash, a power loss or a full disk
8
+ // between truncate and write leaves an empty or half-written file where the
9
+ // identity used to be.
10
+ //
11
+ // rename(2) is atomic on POSIX, so a reader — or a backup running at the wrong
12
+ // moment — sees either the old file or the new one. fsync before it, so the
13
+ // content is on the platter and not merely in the page cache when the rename
14
+ // lands. Windows has no atomic rename over an existing file; there the copy
15
+ // path is still better than a truncate, and this is a best effort by design.
16
+
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ export function writeFileAtomic(file, body, { mode = 0o600 } = {}) {
23
+ const tmp = `${file}.${process.pid}.tmp`;
24
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
25
+ const fd = fs.openSync(tmp, 'w', mode);
26
+ try {
27
+ fs.writeFileSync(fd, body);
28
+ fs.fsyncSync(fd);
29
+ } finally {
30
+ fs.closeSync(fd);
31
+ }
32
+ fs.renameSync(tmp, file);
33
+ }
34
+
35
+ // The same, for the JSON these files all happen to be.
36
+ export function writeJsonAtomic(file, obj, opts) {
37
+ writeFileAtomic(file, JSON.stringify(obj, null, 2) + '\n', opts);
38
+ }
39
+
40
+ // A path for display: the home directory becomes `~`, a `file:` URL is shown
41
+ // as the path it is. Never used to build a path — `~` is the shell's, not the
42
+ // filesystem's, and anything that resolves one must expand it first.
43
+ export function tildify(p, homedir = os.homedir()) {
44
+ if (!p) return p;
45
+ let s = String(p);
46
+ if (s.startsWith('file://')) { try { s = fileURLToPath(s); } catch { return s; } }
47
+ if (s === homedir) return '~';
48
+ return s.startsWith(homedir + path.sep) ? '~' + s.slice(homedir.length) : s;
49
+ }
package/lib/shared/ua.mjs CHANGED
@@ -27,3 +27,6 @@ try {
27
27
  // Renamed 2026-07-31, once solidcommunity.net was back up and both actors there
28
28
  // were retired: the name in their logs no longer has to match the name in here.
29
29
  export const USER_AGENT = `fedipod/${version} (+https://github.com/jeff-zucker/FediPod)`;
30
+ // The same version, for anything else that reports it (the publisher's
31
+ // nodeinfo and status), read the same best-effort way.
32
+ export const AGENT_VERSION = version;
@@ -0,0 +1,7 @@
1
+ // The identity's HTTP surface: the Mastodon client API and OAuth, the
2
+ // ActivityPub client-to-server door, nodeinfo, the owner's door and routes,
3
+ // and the pages and client served off disk. buildAdminSurface returns one
4
+ // request handler; whatever listens hands it requests.
5
+
6
+ export { buildAdminSurface } from './surface.mjs';
7
+ export { namedOrigin, secureOrigin, wsOrigins } from './origins.mjs';
@@ -1,7 +1,7 @@
1
1
  // origins.mjs — the addresses an identity answers on, as a browser must be
2
2
  // sent to them.
3
3
 
4
- import { allowedAuthorities, hostLabel } from '../../shared/guard.mjs';
4
+ import { allowedAuthorities, hostLabel } from '../shared/guard.mjs';
5
5
 
6
6
  // The origin a browser should be sent to for an identity. A browser keys
7
7
  // storage per ORIGIN, so linking every identity at localhost:<port> files them
@@ -4,10 +4,9 @@
4
4
  import crypto from 'node:crypto';
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
- import { spawn } from 'node:child_process';
8
7
 
9
8
  export async function post(p, body, ctx, req, res) { // eslint-disable-line no-unused-vars
10
- const { agent, log, allowed, embedded, port, publicOrigin, versionOnDisk, isGroup } = ctx; // eslint-disable-line no-unused-vars
9
+ const { agent, log, allowed, embedded, port, publicOrigin, versionOnDisk, isGroup, restart } = ctx; // eslint-disable-line no-unused-vars
11
10
  const json = (res, status, obj) => { ctx.json(res, status, obj); return true; };
12
11
  switch (p) {
13
12
  // The inbox gateway lifecycle (opt-in). `configure` points at a
@@ -50,15 +49,10 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
50
49
  const root = cfg.root ? (cfg.root.endsWith('/') ? cfg.root : `${cfg.root}/`) : 'fedipod/';
51
50
  return `${base}${root}ap/actor`;
52
51
  };
53
- // The reply first, the restart a beat later — same shape as /update.
54
- const selfRestart = (why) => setTimeout(() => {
55
- log(why);
56
- if (process.env.INVOCATION_ID) { process.exit(1); return; } // systemd: Restart=on-failure respawns
57
- const child = spawn(process.execPath, process.argv.slice(1),
58
- { detached: true, stdio: 'ignore', env: process.env });
59
- child.unref();
60
- setTimeout(() => process.exit(0), 300);
61
- }, 200);
52
+ // Fronting and un-fronting rename every id, which only a restart
53
+ // applies. A host that cannot restart itself refuses them.
54
+ const selfRestart = restart;
55
+ const noRestart = { error: 'this identity runs inside its pod server, which cannot restart for it — front it, or un-front it, from a standalone agent' };
62
56
  if (body.action === 'configure') {
63
57
  if (!/^https:\/\/\S+$/.test(String(body.url || ''))) return json(res, 400, { error: 'gateway url must be https' });
64
58
  if (!/^https?:\/\/\S+$/.test(String(body.webId || ''))) return json(res, 400, { error: 'gateway webId must be a URL' });
@@ -121,6 +115,7 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
121
115
  if (agent.embedded) {
122
116
  return json(res, 400, { error: 'this identity runs inside its pod server and has no portable credential — attach from a standalone agent' });
123
117
  }
118
+ if (body.fronted === true && !selfRestart) return json(res, 400, noRestart);
124
119
  const named = !!String(body.handle || '').trim();
125
120
  let handle = String(body.handle || cfg.handle || '').toLowerCase().trim();
126
121
  if (!handle) return json(res, 400, { error: 'a name at the gateway is required' });
@@ -177,6 +172,7 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
177
172
  if (body.action === 'forget') {
178
173
  const wasLocked = g.mode === 'locked';
179
174
  const wasFronted = !!g.frontActor;
175
+ if (wasFronted && !selfRestart) return json(res, 400, noRestart);
180
176
  delete cfg.gateway; agent.store.setConfig(cfg);
181
177
  if (agent.publisher) agent.publisher.config.gateway = undefined;
182
178
  await agent.store.flush();