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.
- package/README.md +5 -0
- package/architecture.md +1 -3
- package/architecture.svg +93 -85
- package/gateway.md +7 -2
- package/lib/client/c2s.mjs +16 -5
- package/lib/client/masto/oauth.mjs +1 -1
- package/lib/connections/vault.mjs +1 -1
- package/lib/core/agent.mjs +720 -0
- package/lib/core/gateway-move.mjs +58 -0
- package/lib/core/keys.mjs +1 -1
- package/lib/core/lease.mjs +4 -3
- package/lib/core/publisher/index.mjs +4 -6
- package/lib/core/publisher/notes.mjs +73 -4
- package/lib/core/remote.mjs +54 -0
- package/lib/core/shapes/shapes-text.mjs +11 -3
- package/lib/core/wire.mjs +4 -1
- package/lib/device/admin/index.mjs +4 -11
- package/lib/device/admin/routes/setup.mjs +60 -5
- package/lib/device/admin/server.mjs +18 -5
- package/lib/device/cli/commands/account.mjs +1 -1
- package/lib/device/cli/commands/run.mjs +2 -2
- package/lib/device/cli/commands/service.mjs +1 -1
- package/lib/device/cli/commands/setup.mjs +1 -1
- package/lib/device/cli/commands/state.mjs +1 -1
- package/lib/device/cli/context.mjs +1 -1
- package/lib/{gateway → device}/directory.mjs +1 -1
- package/lib/device/gateway-move.mjs +3 -58
- package/lib/device/home.mjs +3 -50
- package/lib/{client → device}/localapi.mjs +2 -2
- package/lib/device/remote.mjs +3 -54
- package/lib/gateway/copy.mjs +7 -1
- package/lib/gateway/front-core.mjs +6 -16
- package/lib/gateway/front-pages.mjs +31 -0
- package/lib/gateway/keeper-session.mjs +10 -1
- package/lib/gateway/masto-gateway.mjs +61 -12
- package/lib/gateway/outbox-door.mjs +31 -0
- package/lib/gateway/state-api.mjs +39 -7
- package/lib/server/embed.mjs +4 -4
- package/lib/shared/files.mjs +49 -0
- package/lib/shared/ua.mjs +3 -0
- package/lib/surface/index.mjs +7 -0
- package/lib/{device/admin → surface}/origins.mjs +1 -1
- package/lib/{device/admin → surface}/routes/gateway.mjs +7 -11
- package/lib/{device/admin → surface}/routes/lifecycle.mjs +3 -3
- package/lib/{device/admin → surface}/routes/owner.mjs +4 -60
- package/lib/{device/admin → surface}/routes/social.mjs +1 -1
- package/lib/{device/admin → surface}/static.mjs +6 -1
- package/lib/{device/admin → surface}/surface.mjs +19 -16
- package/package.json +23 -5
- package/run-agent.mjs +18 -714
- package/scripts/build-app.mjs +4 -0
- package/scripts/check-netlify-bundles.mjs +84 -0
- package/scripts/check-pod-calls.mjs +42 -0
- package/scripts/vendor-sanitize.mjs +27 -0
- package/vendor/idp-grant.cjs +11 -5
- package/vendor/sanitize-html.cjs +11456 -0
- package/web/app/agent.mjs +4 -2
- package/web/app/copy-mode.mjs +27 -3
- package/web/app/dist/boot.js.map +1 -1
- package/web/app/dist/sw.js +110 -24
- package/web/app/dist/sw.js.map +4 -4
- package/web/app-signin/app-signin.mjs +2 -2
- package/web/app-signin/index.html +13 -5
- package/browser.svg +0 -72
- /package/lib/{device/admin → surface}/routes/connections.mjs +0 -0
package/scripts/build-app.mjs
CHANGED
|
@@ -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)`);
|
package/vendor/idp-grant.cjs
CHANGED
|
@@ -22,7 +22,12 @@
|
|
|
22
22
|
|
|
23
23
|
const crypto = require('node:crypto');
|
|
24
24
|
const fs = require('node:fs');
|
|
25
|
-
|
|
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 } =
|
|
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 } =
|
|
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;
|