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
@@ -32,6 +32,10 @@ const aliasPlugin = {
32
32
  build.onResolve({ filter: /^node:fs$/ }, () => ({ path: shim('node-fs.mjs') }));
33
33
  build.onResolve({ filter: /^web-push$/ }, () => ({ path: shim('web-push.mjs') }));
34
34
  build.onResolve({ filter: /\/safefetch\.mjs$/ }, () => ({ path: shim('safefetch.mjs') }));
35
+ // The packed sanitize-html is for Node (scripts/vendor-sanitize.mjs); the
36
+ // browser takes the package itself, whose browser build leaves out what a
37
+ // page has no use for.
38
+ build.onResolve({ filter: /vendor\/sanitize-html\.cjs$/ }, (args) => build.resolve('sanitize-html', { kind: args.kind, resolveDir: args.resolveDir }));
35
39
  // The shapes are one .ttl file for both agents; Node reads it, the browser
36
40
  // gets it through the text loader below.
37
41
  build.onResolve({ filter: /shapes-text\.mjs$/ }, () => ({ path: shim('shapes-text.mjs') }));
@@ -0,0 +1,84 @@
1
+ // check-netlify-bundles.mjs — before a deploy: the Netlify functions as Netlify
2
+ // ships them. Builds them with Netlify's own bundler (netlify functions:build),
3
+ // checks the account routes are the account function's and not the front's,
4
+ // and loads every function from its own unpacked bundle, outside this
5
+ // repository, where no node_modules beside the source can hide a missing
6
+ // module or file, under the rule Netlify's Node keeps: no require() of an
7
+ // ES-module-only package. The 1.40.0 and 1.40.2 failures (2026-09-26) are
8
+ // what this catches.
9
+ //
10
+ // node scripts/check-netlify-bundles.mjs (the deploy command runs it first)
11
+ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path';
12
+ import { execFileSync, execFile } from 'node:child_process';
13
+ import { promisify } from 'node:util';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fn-bundles-'));
18
+ let fails = 0;
19
+ const check = (ok, msg) => { console.log(`${ok ? 'PASS' : 'FAIL'} ${msg}`); if (!ok) fails++; };
20
+ try {
21
+ execFileSync('netlify', ['functions:build', '--src', path.join(root, 'netlify/functions'), '--functions', path.join(tmp, 'zips')],
22
+ { cwd: root, stdio: 'ignore' });
23
+ const manifest = JSON.parse(fs.readFileSync(path.join(tmp, 'zips/manifest.json'), 'utf8'));
24
+ const fn = Object.fromEntries(manifest.functions.map((f) => [f.name, f]));
25
+ const routes = (f) => (f?.routes || []).map((r) => r.pattern).sort();
26
+ const excluded = (f) => (f?.excludedRoutes || []).map((r) => r.pattern || r).sort();
27
+ check(routes(fn.account).length > 0 && JSON.stringify(routes(fn.account)) === JSON.stringify(excluded(fn.front)),
28
+ `the account function answers exactly the paths the front leaves out (${routes(fn.account).join(' ')})`);
29
+ check(fn['flush-mail']?.schedule === '*/15 * * * *', 'the round is scheduled every fifteen minutes');
30
+ // Netlify's storage, stood in for by the library's own local server, so a
31
+ // request can run through each function and load what it loads on first use.
32
+ const { BlobsServer } = await import(path.join(root, 'node_modules/@netlify/blobs/dist/server.js'));
33
+ const blobs = new BlobsServer({ directory: path.join(tmp, 'blobs'), token: 'check', logger: () => {} });
34
+ const { port } = await blobs.start();
35
+ const env = {
36
+ ...process.env,
37
+ NETLIFY_BLOBS_CONTEXT: Buffer.from(JSON.stringify({ edgeURL: `http://127.0.0.1:${port}`, uncachedEdgeURL: `http://127.0.0.1:${port}`,
38
+ siteID: 'check', token: 'check' })).toString('base64'),
39
+ FEDIPOD_FRONT_HOST: 'fedipod.example', FEDIPOD_FRONT_ORIGIN: 'https://fedipod.example',
40
+ FEDIPOD_KEEPER_WEBID: 'https://keeper.example/profile/card#me', FEDIPOD_KEEPER_CLIENT_SECRET: 'check-secret',
41
+ };
42
+ // What each function is asked, and the answers that mean it ran its code.
43
+ const asks = {
44
+ front: [['GET', '/', [200]], ['GET', '/.well-known/webfinger?resource=acct:nobody@fedipod.example', [404]],
45
+ ['POST', '/u/nobody/ap/inbox/', [404, 410]]],
46
+ account: [['GET', '/api/v2/instance', [200]], ['POST', '/api/v1/apps', [200], 'client_name=check&redirect_uris=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob'],
47
+ ['GET', '/api/authorize?address=nobody', [404]], ['GET', '/api/v1/timelines/home', [401]], ['POST', '/api/state/open', [401]]],
48
+ 'keeper-background': [['POST', '/', [403], '{}']],
49
+ 'push-background': [['POST', '/', [403], '{}']],
50
+ 'flush-mail': [['POST', '/', [204]]],
51
+ };
52
+ for (const f of manifest.functions) {
53
+ const dir = path.join(tmp, 'run', f.name);
54
+ fs.mkdirSync(dir, { recursive: true });
55
+ execFileSync('unzip', ['-q', path.join(tmp, 'zips', `${f.name}.zip`), '-d', dir]);
56
+ const entry = path.join(dir, 'netlify/functions', `${f.name}.mjs`);
57
+ // Netlify's Node will not require() a package that ships only as an ES
58
+ // module; this machine's will. The flag makes this one refuse it too, which
59
+ // is how 1.40.0 (jose) and 1.40.2 (htmlparser2 under sanitize-html) got
60
+ // past a check that loaded fine here. Each run exits when it has answered
61
+ // (a function may leave a timer behind), and not with execFileSync: the
62
+ // storage stand-in runs in this process, and a blocked event loop would
63
+ // never answer the function asking it.
64
+ const run = (script) => promisify(execFile)(process.execPath, ['--no-experimental-require-module', '-e', script],
65
+ { cwd: dir, env, encoding: 'utf8', timeout: 60_000 })
66
+ .then((r) => r.stdout.trim().split('\n').pop())
67
+ .catch((e) => `THREW ${String(e.stderr || e.message).trim().split('\n').pop()}`);
68
+ const out = await run(`import(${JSON.stringify(entry)}).then((m) => console.log(typeof m.default))`
69
+ + `.catch((e) => console.log('FAILS ' + e.message.split('\\n')[0])).finally(() => process.exit(0))`);
70
+ check(out === 'function', `${f.name} loads from its own bundle${out === 'function' ? '' : ` (${out})`}`);
71
+ for (const [method, p, ok, body] of asks[f.name] || []) {
72
+ const got = await run(`import(${JSON.stringify(entry)}).then(async (m) => {
73
+ const r = await m.default(new Request('https://fedipod.example' + ${JSON.stringify(p)}, { method: ${JSON.stringify(method)},
74
+ ${body ? `body: ${JSON.stringify(body)}, headers: { 'content-type': ${JSON.stringify(body.startsWith('{') ? 'application/json' : 'application/x-www-form-urlencoded')} },` : ''} }), { waitUntil() {} });
75
+ console.log(r.status);
76
+ }).catch((e) => console.log('THREW ' + e.message.split('\\n')[0])).finally(() => process.exit(0))`);
77
+ check(ok.includes(Number(got)), `${f.name} answers ${method} ${p} (${got})`);
78
+ }
79
+ }
80
+ await blobs.stop();
81
+ } catch (e) { console.log('ERROR', e.message); fails++; }
82
+ fs.rmSync(tmp, { recursive: true, force: true });
83
+ console.log(fails ? `\n${fails} FAILED` : '\nall green');
84
+ process.exit(fails ? 1 : 0);
@@ -291,5 +291,47 @@ if (!failures) pass('every in-scope caller reaches the pod through a named opera
291
291
  if (!bad) pass('lib/session/ imports nothing above itself, names no application, tells a Mastodon address from a pod one and acts through each, and the app binds its own database name');
292
292
  }
293
293
 
294
+ // ---- 9. core is a library: nothing in it reaches the DeviceAgent, and every entry loads alone ----
295
+ {
296
+ // The folders a consumer of the `fedipod` package may import from. None of
297
+ // them may import the DeviceAgent's files (lib/device/, run-agent.mjs):
298
+ // the Server and the forum import core and nothing else.
299
+ const CORE = ['lib/core', 'lib/pod', 'lib/client', 'lib/connections', 'lib/gateway', 'lib/shared', 'lib/surface', 'lib/server'];
300
+ const walk = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
301
+ const p = path.join(dir, e.name);
302
+ return e.isDirectory() ? walk(p) : (e.name.endsWith('.mjs') ? [p] : []);
303
+ });
304
+ let bad = 0;
305
+ for (const folder of CORE) {
306
+ for (const file of walk(path.join(root, folder))) {
307
+ const src = code(read(file));
308
+ const specs = [...src.matchAll(/from\s+['"]([^'"]+)['"]/g), ...src.matchAll(/import\(\s*['"]([^'"]+)['"]/g)].map((m) => m[1]);
309
+ for (const spec of specs) {
310
+ if (/(^|\/)device\//.test(spec) || /run-agent\.mjs$/.test(spec)) {
311
+ fail(`${path.relative(root, file)} imports ${spec} — core may not reach the DeviceAgent`); bad++;
312
+ }
313
+ }
314
+ }
315
+ }
316
+ // The named entries of the exports map, imported by package name from
317
+ // inside the package (Node resolves a package's own name through its map),
318
+ // so a consumer's `import 'fedipod/embed'` is what is proved here.
319
+ const entries = { embed: 'startEmbeddedAgent', front: 'routeFront', 'front-pages': 'frontPages', surface: 'buildAdminSurface', agent: 'Agent', remote: 'RemotePod', place: 'chosenRoot' };
320
+ for (const [entry, name] of Object.entries(entries)) {
321
+ try {
322
+ const m = await import(`fedipod/${entry}`);
323
+ if (typeof m[name] !== 'function') { fail(`fedipod/${entry} exports no ${name}`); bad++; }
324
+ } catch (e) { fail(`fedipod/${entry} does not load by name: ${e.message}`); bad++; }
325
+ }
326
+ try {
327
+ const { frontPages } = await import('fedipod/front-pages');
328
+ const pages = frontPages();
329
+ const missing = ['signupPage', 'runPage', 'adminPage', 'noticesPage', 'authBundle', 'installScript'].filter((k) => !pages[k])
330
+ .concat(Object.entries(pages.pageScripts).filter(([, v]) => !v).map(([k]) => k));
331
+ if (missing.length) { fail(`frontPages() is missing ${missing.join(', ')}`); bad++; }
332
+ } catch (e) { fail(`frontPages(): ${e.message}`); bad++; }
333
+ if (!bad) pass('core imports nothing from the DeviceAgent, every named entry of the fedipod package loads by name, and the front pages are all there');
334
+ }
335
+
294
336
  console.log(failures ? `\n${failures} failure(s)` : '\npod layer intact');
295
337
  process.exit(failures ? 1 : 0);
@@ -0,0 +1,27 @@
1
+ // vendor-sanitize.mjs — sanitize-html and the HTML parser it uses, packed into
2
+ // one CommonJS file, vendor/sanitize-html.cjs, which lib/core/wire.mjs imports.
3
+ //
4
+ // sanitize-html is CommonJS and require()s htmlparser2, which since 12 ships
5
+ // only as an ES module. Node 24 lets require() load one; the Node that runs
6
+ // the Netlify functions does not, so every function that cleans HTML failed to
7
+ // load there (2026-09-26). Packed, the parser is part of this one file and
8
+ // nothing is require()d that cannot be. Run again after sanitize-html changes:
9
+ //
10
+ // node scripts/vendor-sanitize.mjs
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { createRequire } from 'node:module';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { build } from 'esbuild';
16
+
17
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
18
+ const require = createRequire(path.join(root, 'package.json'));
19
+ const entry = require.resolve('sanitize-html');
20
+ const version = JSON.parse(fs.readFileSync(path.join(path.dirname(entry), 'package.json'), 'utf8')).version;
21
+ const out = path.join(root, 'vendor/sanitize-html.cjs');
22
+ await build({
23
+ entryPoints: [entry], bundle: true, platform: 'node', format: 'cjs', target: 'node18',
24
+ outfile: out, legalComments: 'inline', logLevel: 'warning',
25
+ banner: { js: `// sanitize-html ${version} with its dependencies, packed by scripts/vendor-sanitize.mjs. Do not edit.` },
26
+ });
27
+ console.log(`packed sanitize-html ${version} → vendor/sanitize-html.cjs (${Math.round(fs.statSync(out).size / 1024)} KB)`);
@@ -22,7 +22,12 @@
22
22
 
23
23
  const crypto = require('node:crypto');
24
24
  const fs = require('node:fs');
25
- const { generateKeyPair, exportJWK, SignJWT } = require('jose');
25
+ // jose ships only as an ES module, so it is loaded with import(), which every
26
+ // Node version takes and a bundler follows. A require() of it was left out of
27
+ // the Netlify function bundle and took the whole gateway down on load
28
+ // (2026-09-26): nothing here may require it.
29
+ let joseLoaded = null;
30
+ const jose = () => (joseLoaded ||= import('jose'));
26
31
  const { jfetch } = require('./jfetch.cjs');
27
32
 
28
33
  // The HTTP URI for a DPoP proof's `htu` is the request URL without query/fragment.
@@ -31,6 +36,7 @@ function htuOf(url) { const u = new URL(url); return u.origin + u.pathname; }
31
36
  // Build a DPoP proof JWT bound to (htm, htu), optionally carrying a server nonce
32
37
  // and the access-token hash (`ath`, required on resource requests).
33
38
  async function dpopProof({ keyPair, htm, htu, nonce, accessToken }) {
39
+ const { exportJWK, SignJWT } = await jose();
34
40
  const jwk = await exportJWK(keyPair.publicKey);
35
41
  const payload = { htu, htm, jti: crypto.randomUUID() };
36
42
  if (nonce) payload.nonce = nonce;
@@ -178,7 +184,7 @@ function readSavedToken(file) {
178
184
  }
179
185
 
180
186
  async function importSavedKey(saved) {
181
- const { importJWK } = require('jose');
187
+ const { importJWK } = await jose();
182
188
  const privateKey = await importJWK(saved.privateJwk, 'ES256');
183
189
  const publicKey = await importJWK(saved.publicJwk, 'ES256');
184
190
  return { privateKey, publicKey };
@@ -187,7 +193,7 @@ async function importSavedKey(saved) {
187
193
  async function saveToken(file, keyPair, accessToken, expiresAt) {
188
194
  if (!file) return;
189
195
  try {
190
- const { exportJWK } = require('jose');
196
+ const { exportJWK } = await jose();
191
197
  fs.writeFileSync(file, JSON.stringify({
192
198
  accessToken, expiresAt,
193
199
  privateJwk: await exportJWK(keyPair.privateKey),
@@ -223,8 +229,8 @@ function createGrantSession(rec, { gateToken, gatedOrigin, backoffFile = null, t
223
229
  // restart resumes the same session instead of minting a fresh grant. jose 6
224
230
  // makes generated keys non-extractable by default, which turns both of
225
231
  // those into "non-extractable CryptoKey cannot be exported as a JWK".
226
- ? importSavedKey(saved).catch(() => generateKeyPair('ES256', { extractable: true }))
227
- : generateKeyPair('ES256', { extractable: true });
232
+ ? importSavedKey(saved).catch(() => jose().then((j) => j.generateKeyPair('ES256', { extractable: true })))
233
+ : jose().then((j) => j.generateKeyPair('ES256', { extractable: true }));
228
234
  let accessToken = saved?.accessToken || null;
229
235
  let expiresAt = saved?.expiresAt || 0;
230
236
  let rsNonce = null;