fedipod 1.45.0 → 1.48.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 (54) hide show
  1. package/README.md +11 -16
  2. package/architecture.md +6 -3
  3. package/gateway.md +51 -23
  4. package/gui.md +3 -2
  5. package/lib/client/c2s.mjs +2 -1
  6. package/lib/client/masto/index.mjs +5 -4
  7. package/lib/client/masto/render.mjs +4 -1
  8. package/lib/core/intake/activities.mjs +8 -9
  9. package/lib/core/intake/counts.mjs +143 -0
  10. package/lib/core/intake/index.mjs +4 -0
  11. package/lib/core/publisher/notes.mjs +4 -1
  12. package/lib/core/social.mjs +2 -0
  13. package/lib/core/storage.mjs +54 -5
  14. package/lib/core/wire.mjs +16 -5
  15. package/lib/gateway/account-agent.mjs +20 -4
  16. package/lib/gateway/copy.mjs +95 -5
  17. package/lib/gateway/front-core.mjs +4 -4
  18. package/lib/gateway/held-mail.mjs +11 -4
  19. package/lib/gateway/keeper.mjs +9 -6
  20. package/lib/gateway/masto-gateway.mjs +140 -40
  21. package/lib/gateway/private-read.mjs +10 -3
  22. package/lib/gateway/quiet.mjs +4 -3
  23. package/lib/gateway/state-api.mjs +73 -18
  24. package/lib/pod/notes.mjs +25 -0
  25. package/lib/server/embed.mjs +1 -1
  26. package/lib/session/README.md +2 -135
  27. package/lib/surface/routes/owner.mjs +2 -2
  28. package/lib/surface/surface.mjs +4 -3
  29. package/package.json +5 -3
  30. package/scripts/check-netlify-bundles.mjs +0 -4
  31. package/scripts/check-pod-calls.mjs +5 -108
  32. package/scripts/check-sizes.mjs +6 -3
  33. package/scripts/dependents.mjs +76 -0
  34. package/scripts/stage-site.mjs +10 -8
  35. package/scripts/start.mjs +2 -2
  36. package/scripts/test-dependents.mjs +37 -0
  37. package/web/admin/gateway.js +8 -24
  38. package/web/admin/index.html +0 -16
  39. package/web/admin/record.js +1 -2
  40. package/web/admin/upkeep.js +1 -5
  41. package/web/app/admin-facade.mjs +1 -1
  42. package/web/app/agent.mjs +27 -10
  43. package/web/app/copy-mode.mjs +98 -13
  44. package/web/app/dist/boot.js +68 -57
  45. package/web/app/dist/boot.js.map +4 -4
  46. package/web/app/dist/sw.js +583 -283
  47. package/web/app/dist/sw.js.map +4 -4
  48. package/web/app/forum-support.mjs +2 -2
  49. package/web/app/oidc-session.mjs +2 -2
  50. package/lib/session/demo.html +0 -94
  51. package/lib/session/fedi-account.mjs +0 -417
  52. package/lib/session/fedi-login.mjs +0 -123
  53. package/lib/session/oidc-session.mjs +0 -233
  54. package/lib/session/package.json +0 -49
@@ -1,137 +1,4 @@
1
1
  # fediverse-session
2
2
 
3
- The library lets an app take a Fediverse handle or a WebID and act as that
4
- account.
5
-
6
- - **`login`** — sends the person to wherever their account signs in, their
7
- Mastodon server or their pod, and brings them back to where they were.
8
- - **`resume`** — on every page load, finishes a sign-in they are coming back
9
- from. Otherwise does nothing.
10
- - **`current`** — the signed-in account, or nothing.
11
- - **`signOut`** — signs them out, at their server or pod as well as in the
12
- browser.
13
- - **`describe`** — says what an address is without signing in: a Mastodon
14
- account or a pod account, and its handle.
15
-
16
- Once signed in, the account gives you:
17
-
18
- - **`post`** — publishes a post as them, public, unlisted or followers-only.
19
- - **`reply`** — answers a post.
20
- - **`timeline`** — their home timeline, newest first.
21
- - **`outbox`** — their own posts, newest first. Pass `rdf: true` to also get
22
- the real thing as RDF, for an app that wants to work with it as linked
23
- data rather than as this library's own shape.
24
- - **`follow`** — follows someone by handle.
25
- - **`favourite`**, **`boost`** — as they say.
26
- - **`profile`** — who they are as their server or pod shows them: name,
27
- handle, picture, bio, counts, profile page, and the WebID for a pod
28
- account.
29
- - **`notice`** — a sentence to show them, or nothing. A browser-based
30
- FediPod account gets: "Because you have a browser-based account, your
31
- posts and interactions will only go out to the Fediverse when your browser
32
- is opened to your account."
33
- - **`handle`, `name`, `kind`** — who they are, and whether the account is on
34
- Mastodon or a pod, for an app that wants to know.
35
- - **`signOut`** — the same as above.
36
-
37
- The app never asks which kind it has. A Mastodon account acts at once. A
38
- pod account's actions are carried out by its own agent, at once for a
39
- DeviceAgent or Server account, and when fedipod.net is next open for a
40
- browser-based one.
41
-
42
- Three files, no dependencies. It runs in a page and in a service worker.
43
- The one exception: `outbox({ rdf: true })` needs the `jsonld` package, and
44
- only loads it when that flag is actually used.
45
-
46
- [The demo](https://jeff-zucker.github.io/FediPod/) shows the sign-in and
47
- the profile that comes back.
48
-
49
- ## Get it
50
-
51
- From a CDN, with nothing to install:
52
-
53
- ```js
54
- import { fediAccount } from 'https://cdn.jsdelivr.net/npm/fediverse-session@0.2.1/fedi-account.mjs';
55
- ```
56
-
57
- Or from npm:
58
-
59
- ```bash
60
- npm install fediverse-session
61
- ```
62
-
63
- ```js
64
- import { fediAccount } from 'fediverse-session';
65
- ```
66
-
67
- ## Use
68
-
69
- ```js
70
- import { fediAccount } from 'fediverse-session';
71
- const accounts = fediAccount({ dbName: 'my-app', clientName: 'My app' });
72
-
73
- await accounts.resume(); // on every page load
74
-
75
- signInButton.onclick = () => accounts.login(addressField.value);
76
-
77
- const me = await accounts.current(); // null when nobody is signed in
78
- if (me?.notice) show(me.notice);
79
- await me.post({ text: 'Hello from my app' });
80
- for (const p of await me.timeline({ limit: 20 })) render(p);
81
- for (const p of await me.outbox({ limit: 20 })) render(p);
82
- await me.follow('@aisha@her.server');
83
- await me.reply(p.url, 'Well said');
84
- await me.signOut();
85
- ```
86
-
87
- ## The details
88
-
89
- - **`fediAccount({ dbName, clientName })`** — sets the library up for your
90
- app, once. `dbName` keeps this app's sign-ins apart from another app's on
91
- the same origin. `clientName` is what the person sees on the consent
92
- screen.
93
- - **`login(address, { returnTo, redirectUri })`** — `@kwame@mastodon.social`
94
- goes to Mastodon; `@mei@fedipod.net`, or a WebID, goes to the pod. They
95
- come back to this page unless `redirectUri` says otherwise, and are
96
- returned to where they were unless `returnTo` says otherwise.
97
- - **`post({ text, inReplyTo, visibility })`** — `visibility` is `public`,
98
- `unlisted` or `followers`.
99
- - **`reply(post, text)`** — the post is named by its address or its id.
100
- - **`timeline({ limit })`** — each post as `{ id, url, author: { id, handle,
101
- name }, html, published, inReplyTo }`.
102
- - **`outbox({ limit, rdf })`** — the same shape as `timeline`, but only this
103
- account's own posts. With `rdf: true`, the returned array also carries
104
- `.rdf`: the real outbox parsed into an RDF/JS quad array — for a Mastodon
105
- account too, since a Mastodon server is itself an ActivityPub server with
106
- a real actor and outbox, same as a pod's. It comes back empty on a server
107
- that requires a signed request just to read that document, which some
108
- do. Reading it needs the `jsonld` package available to your app; without
109
- `rdf: true` nothing changes and nothing extra loads.
110
- - **`fetch`** — the raw authenticated fetch, for anything the above does not
111
- cover.
112
- - **`actor`** — the account's ActivityPub id.
113
-
114
- Every action answers `{ sent: true }` when the server did it, or `{ queued:
115
- true }` when the account's own agent will carry it out.
116
-
117
- ## What it cannot do
118
-
119
- - **An address whose server is neither a Mastodon-family server nor a Solid
120
- pod** is refused with a sentence naming the host.
121
- - **A browser-based FediPod account acts when its browser is open.** Posts,
122
- follows and replies wait at the account's outbox until fedipod.net is next
123
- open in some browser, and its timeline is the one that browser last built.
124
- The account's `notice` says so; show it.
125
- - **A Mastodon account has no pod.** An app that keeps things on a pod has
126
- nowhere to keep them for such a person.
127
-
128
- ## Where the sign-in goes, and what stays in the browser
129
-
130
- A Mastodon account signs in at its own server, which asks the person to
131
- approve your app once. The token it grants stays in their browser and is
132
- handed back to the server at sign-out.
133
-
134
- A pod account signs in at the pod's login provider. The key that proves the
135
- sign-in is made in the browser and cannot be read out of it, even by a
136
- script on the page. The person stays signed in across browser restarts
137
- until they sign out, and sign-out is reported to the provider.
3
+ fediverse-session lives in its own repository:
4
+ https://github.com/jeff-zucker/fediverse-session
@@ -70,7 +70,7 @@ export async function get(p, ctx, req, res, url) { // eslint-disable-line no-u
70
70
  image: cfg.image || null, fields: cfg.fields || [],
71
71
  approveJoins: !!cfg.approveJoins, review: !!cfg.review,
72
72
  aliases: cfg.aliases || [],
73
- autoAcceptFollows: !!cfg.autoAcceptFollows,
73
+ autoAcceptFollows: cfg.autoAcceptFollows !== false,
74
74
  // never the record itself
75
75
  hasUiPassword: !!cfg.uiPassword,
76
76
  quiescedAt: cfg.quiescedAt || null, movedTo: cfg.movedTo || null,
@@ -255,7 +255,7 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
255
255
  name: cfg.name || null, summary: cfg.summary || null, icon: cfg.icon || null,
256
256
  image: cfg.image || null, fields: cfg.fields || [],
257
257
  approveJoins: !!cfg.approveJoins, review: !!cfg.review,
258
- autoAcceptFollows: !!cfg.autoAcceptFollows,
258
+ autoAcceptFollows: cfg.autoAcceptFollows !== false,
259
259
  ...(isGroup() ? { moderators: cfg.moderators || [] } : {}),
260
260
  hasUiPassword: !!cfg.uiPassword,
261
261
  },
@@ -8,6 +8,7 @@
8
8
 
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
11
12
  import { MastoApi } from '../client/masto/index.mjs';
12
13
  import { C2S } from '../client/c2s.mjs';
13
14
  import { makeC2sAuth } from '../client/oidc-auth.mjs';
@@ -75,8 +76,8 @@ const hasCredential = (home) => fs.existsSync(path.join(home, 'credential.json')
75
76
  // guards the operator's door (basePath) instead of the whole surface.
76
77
  const SESSION_FILES = {
77
78
  '/oauth/session/signin.mjs': 'web/admin/oauth-signin.mjs',
78
- '/oauth/session/fedi-login.mjs': 'lib/session/fedi-login.mjs',
79
- '/oauth/session/oidc-session.mjs': 'lib/session/oidc-session.mjs',
79
+ '/oauth/session/fedi-login.mjs': fileURLToPath(import.meta.resolve('fediverse-session/fedi-login.mjs')),
80
+ '/oauth/session/oidc-session.mjs': fileURLToPath(import.meta.resolve('fediverse-session/oidc-session.mjs')),
80
81
  };
81
82
 
82
83
  // `extraRoutes` are the routes a host process adds after the owner's — the
@@ -194,7 +195,7 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
194
195
  const sessionFile = SESSION_FILES[p];
195
196
  if (sessionFile) {
196
197
  let body;
197
- try { body = fs.readFileSync(path.join(projectRoot, sessionFile)); } catch { return json(res, 404, { error: 'not here' }); }
198
+ try { body = fs.readFileSync(path.resolve(projectRoot, sessionFile)); } catch { return json(res, 404, { error: 'not here' }); }
198
199
  res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' });
199
200
  res.end(body); return;
200
201
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod",
3
- "version": "1.45.0",
3
+ "version": "1.48.0",
4
4
  "description": "Standalone single-actor ActivityPub agent whose wire face, RDF truth and state all live on a Solid pod (CSS). Bundles a Phanpy UI and a Mastodon client-API facade.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -55,7 +55,7 @@
55
55
  "install-service": "node bin/fedipod.mjs install-service",
56
56
  "uninstall-service": "node bin/fedipod.mjs uninstall-service",
57
57
  "prepublishOnly": "npm test",
58
- "test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/copy-mode-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node packages/fedipod-bb/scripts/link-fedipod.mjs && node --test packages/fedipod-bb/test/*.test.mjs claude/smoke-tests/forum-signup-form.test.mjs claude/smoke-tests/door-verify.test.mjs && node scripts/check-sizes.mjs && node claude/validation/browser-agent/all.mjs",
58
+ "test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/copy-mode-test.mjs && node claude/smoke-tests/hold-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node --test claude/smoke-tests/forum-signup-form.test.mjs claude/smoke-tests/door-verify.test.mjs && node scripts/check-sizes.mjs && node scripts/test-dependents.mjs && node claude/validation/browser-agent/all.mjs",
59
59
  "verify-ui": "node scripts/verify-ui.mjs",
60
60
  "build:sw": "node scripts/build-app.mjs --entry web/app/sw-src.mjs --out web/app/dist/sw.js",
61
61
  "build:boot": "node scripts/build-app.mjs --entry web/app/boot.mjs --out web/app/dist/boot.js",
@@ -76,6 +76,7 @@
76
76
  "@rdfjs/term-set": "^2.0.3",
77
77
  "@solid/access-token-verifier": "^2.1.1",
78
78
  "clownface": "^2.0.3",
79
+ "fediverse-session": "^0.2.1",
79
80
  "jose": "^6.2.8",
80
81
  "json-canon": "^1.0.1",
81
82
  "jsonld": "^9.0.0",
@@ -96,6 +97,7 @@
96
97
  "package-lock.json": "npm:empty-npm-package@1.0.0"
97
98
  },
98
99
  "devDependencies": {
99
- "esbuild": "^0.28.2"
100
+ "esbuild": "^0.28.2",
101
+ "fedipod-bb": "^0.4.1"
100
102
  }
101
103
  }
@@ -18,10 +18,6 @@ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fn-bundles-'));
18
18
  let fails = 0;
19
19
  const check = (ok, msg) => { console.log(`${ok ? 'PASS' : 'FAIL'} ${msg}`); if (!ok) fails++; };
20
20
  try {
21
- // The forum the front names (netlify/forum-support.mjs) imports fedipod by
22
- // name; in this checkout that must be the checkout itself, or the bundle
23
- // carries a registry copy beside the Gateway's own code.
24
- execFileSync(process.execPath, [path.join(root, 'packages/fedipod-bb/scripts/link-fedipod.mjs')], { cwd: root, stdio: 'inherit' });
25
21
  execFileSync('netlify', ['functions:build', '--src', path.join(root, 'netlify/functions'), '--functions', path.join(tmp, 'zips')],
26
22
  { cwd: root, stdio: 'ignore' });
27
23
  const manifest = JSON.parse(fs.readFileSync(path.join(tmp, 'zips/manifest.json'), 'utf8'));
@@ -179,116 +179,13 @@ if (!failures) pass('every in-scope caller reaches the pod through a named opera
179
179
  pass('the library imports and runs against a stub with nothing but rdflib on the path');
180
180
  }
181
181
 
182
- // ---- 8. lib/session/ is a library on the same terms ----
182
+ // ---- 8. the app binds the session library to its own database ----
183
+ // The library (fediverse-session) checks itself in its own repository; what
184
+ // is FediPod's is the name every signed-in browser holds its session under.
183
185
  {
184
- const SESSION = path.join(root, 'lib/session');
185
- const files = fs.readdirSync(SESSION).filter((f) => f.endsWith('.mjs'));
186
- let bad = 0;
187
- for (const f of files) {
188
- const src = code(read(path.join(SESSION, f)));
189
- for (const m of src.matchAll(/from\s+['"]([^'"]+)['"]/g)) {
190
- if (!m[1].startsWith('./')) { fail(`lib/session/${f} imports ${m[1]} — the session library imports nothing but its own files`); bad++; }
191
- }
192
- if (/fedipod/i.test(src)) { fail(`lib/session/${f} names FediPod in code — the database and client names are the caller's`); bad++; }
193
- }
194
- const { solidOidcSession } = await import(path.join(SESSION, 'oidc-session.mjs'));
195
- const oidc = solidOidcSession({ dbName: 'x', clientName: 'y' });
196
- for (const k of ['beginLogin', 'completeLogin', 'getSession', 'signOut']) {
197
- if (typeof oidc[k] !== 'function') { fail(`solidOidcSession() returned no ${k}`); bad++; }
198
- }
199
- // An address at a Gateway resolves to the POD's login, through the alias
200
- // WebFinger names; an address on a Mastodon server is refused in words.
201
- const { fediLogin, parseAddress } = await import(path.join(SESSION, 'fedi-login.mjs'));
202
- const jrd = (host, aliases, self) => ({ ok: true, json: async () => ({ aliases, links: [{ rel: 'self', type: 'application/activity+json', href: self }] }) });
203
- const stub = async (url) => {
204
- if (url.startsWith('https://front.example/.well-known/webfinger')) return jrd('front.example', ['https://mei.pod.example/anything/ap/actor'], 'https://front.example/u/mei/ap/actor');
205
- if (url.startsWith('https://masto.example/.well-known/webfinger')) return jrd('masto.example', ['https://masto.example/@kwame'], 'https://masto.example/users/kwame');
206
- if (url === 'https://mei.pod.example/.well-known/openid-configuration') return { ok: true, json: async () => ({ issuer: 'https://pod.example/', authorization_endpoint: 'https://pod.example/.oidc/auth' }) };
207
- // A pod whose host answers no discovery: the actor names the WebID, and
208
- // the WebID document names the issuer.
209
- if (url.startsWith('https://server.example/.well-known/webfinger')) return jrd('server.example', [], 'https://server.example/aisha/anything/ap/actor');
210
- if (url === 'https://server.example/aisha/anything/ap/actor') return { ok: true, json: async () => ({ id: url, alsoKnownAs: ['https://server.example/aisha/profile/card#me'] }) };
211
- if (url === 'https://server.example/aisha/profile/card#me') return { ok: true, json: async () => ([{ '@id': url, 'http://www.w3.org/ns/solid/terms#oidcIssuer': [{ '@id': 'https://idp.example/' }] }]) };
212
- if (url === 'https://idp.example/.well-known/openid-configuration') return { ok: true, json: async () => ({ issuer: 'https://idp.example/', authorization_endpoint: 'https://idp.example/auth' }) };
213
- return { ok: false, json: async () => ({}) };
214
- };
215
- const login = fediLogin({ dbName: 'x', fetch: stub });
216
- const found = await login.resolve('@mei@front.example').catch((e) => ({ error: e.message }));
217
- if (found.issuer !== 'https://pod.example/' || found.origin !== 'https://mei.pod.example' || found.actor !== 'https://front.example/u/mei/ap/actor') {
218
- fail(`a fronted address did not resolve to its pod's login: ${JSON.stringify(found)}`); bad++;
219
- }
220
- const viaWebId = await login.resolve('@aisha@server.example').catch((e) => ({ error: e.message }));
221
- if (viaWebId.issuer !== 'https://idp.example/' || viaWebId.webId !== 'https://server.example/aisha/profile/card#me') {
222
- fail(`an address on a host without discovery did not resolve through its WebID: ${JSON.stringify(viaWebId)}`); bad++;
223
- }
224
- const typedWebId = await login.resolve('https://server.example/aisha/profile/card#me').catch((e) => ({ error: e.message }));
225
- if (typedWebId.issuer !== 'https://idp.example/') { fail(`a typed WebID did not resolve: ${JSON.stringify(typedWebId)}`); bad++; }
226
- const refused = await login.resolve('kwame@masto.example').then(() => '', (e) => e.message);
227
- if (!/masto\.example is not a Solid pod/.test(refused)) { fail(`a Mastodon address was not refused in words: ${refused || 'resolved'}`); bad++; }
228
- if (parseAddress('nonsense') !== null || parseAddress('@mei@Front.Example')?.at !== '@mei@front.example') { fail('parseAddress does not read @you@host'); bad++; }
229
- // The account library tells a Mastodon address from a pod address and
230
- // speaks to each: the Mastodon API for one, the outbox door for the other.
231
- const { fediAccount, BROWSER_ACCOUNT_NOTICE } = await import(path.join(SESSION, 'fedi-account.mjs'));
232
- const seen = [];
233
- const acctStub = async (url, init = {}) => {
234
- seen.push(`${init.method || 'GET'} ${url}`);
235
- const ok = (body, headers = {}) => ({ ok: true, status: 200, headers: { get: (k) => headers[k] || null }, json: async () => body, text: async () => JSON.stringify(body) });
236
- if (url.startsWith('https://masto.example/.well-known/webfinger')) return jrd('masto.example', ['https://masto.example/@kwame'], 'https://masto.example/users/kwame');
237
- if (url === 'https://masto.example/api/v1/instance') return ok({ uri: 'masto.example', title: 'Masto' });
238
- if (url === 'https://masto.example/api/v1/statuses' && init.method === 'POST') return ok({ id: '77', url: 'https://masto.example/@kwame/77' });
239
- if (url.startsWith('https://masto.example/api/v1/timelines/home')) return ok([{ id: '1', url: 'https://masto.example/@aisha/1', created_at: '2026-09-20T10:00:00Z', content: '<p>hi</p>', account: { acct: 'aisha', display_name: 'Aisha', url: 'https://masto.example/@aisha' } }]);
240
- if (url.startsWith('https://masto.example/api/v1/accounts/lookup')) return ok({ id: '9' });
241
- if (url === 'https://masto.example/api/v1/accounts/9/follow') return ok({ following: true });
242
- if (url === 'https://front.example/api/v1/instance' || url === 'https://mei.pod.example/api/v1/instance') return { ok: false, status: 404, json: async () => ({}) };
243
- return stub(url, init);
244
- };
245
- const accounts = fediAccount({ dbName: 'x', fetch: acctStub, storage: undefined });
246
- const kwame = await accounts.describe('@kwame@masto.example').catch((e) => ({ error: e.message }));
247
- if (kwame.kind !== 'mastodon' || kwame.host !== 'masto.example') { fail(`a Mastodon address was not recognised: ${JSON.stringify(kwame)}`); bad++; }
248
- const mei = await accounts.describe('@mei@front.example').catch((e) => ({ error: e.message }));
249
- if (mei.kind !== 'pod' || mei.root !== 'https://mei.pod.example/anything/' || mei.issuer !== 'https://pod.example/') { fail(`a pod address was not recognised: ${JSON.stringify(mei)}`); bad++; }
250
- // The Mastodon account acts through its server's API.
251
- const store = { getItem: (k) => store.m.get(k) ?? null, setItem: (k, v) => store.m.set(k, String(v)), removeItem: (k) => store.m.delete(k), m: new Map() };
252
- store.setItem('x:masto:account', JSON.stringify({ host: 'masto.example', token: 't', handle: '@kwame@masto.example', name: 'Kwame', actor: 'https://masto.example/@kwame' }));
253
- const me = await fediAccount({ dbName: 'x', fetch: acctStub, storage: store }).current();
254
- const posted = await me.post({ text: 'hello' }).catch((e) => ({ error: e.message }));
255
- const tl = await me.timeline().catch((e) => ({ error: e.message }));
256
- const followed = await me.follow('@aisha@masto.example').catch((e) => ({ error: e.message }));
257
- if (me.kind !== 'mastodon' || me.notice !== null || posted.id !== '77' || tl[0]?.author?.handle !== '@aisha@masto.example' || followed.followed !== '@aisha@masto.example') {
258
- fail(`the Mastodon account did not act through its API: ${JSON.stringify({ posted, tl, followed })}`); bad++;
259
- }
260
- // The pod account acts through its outbox door and reads its own records;
261
- // a sealed key on the pod marks it browser-based, which sets the notice.
262
- const doorSeen = [];
263
- const podFetch = async (url, init = {}) => {
264
- doorSeen.push(`${init.method || 'GET'} ${url}`);
265
- if (url === 'https://front.example/u/mei/ap/outbox') return { ok: true, status: 202, headers: { get: (k) => (k === 'location' ? 'https://front.example/u/mei/ap/notes/n1' : null) }, text: async () => '' };
266
- if (url === 'https://mei.pod.example/anything/ap-state/keys.json') return { ok: true, status: 200, json: async () => ({ v: 1, kdf: 'PBKDF2-SHA256', ct: 'x', salt: 'y' }) };
267
- if (url === 'https://mei.pod.example/anything/ap-state/statuses.json') return { ok: true, status: 200, json: async () => ([{ noteId: 'https://x.example/n/1', actor: 'https://x.example/u/tamara', content: '<p>one</p>', published: '2026-09-20T09:00:00Z', kind: 'timeline' }, { noteId: 'https://x.example/n/2', actor: 'https://x.example/u/tamara', content: '<p>dm</p>', published: '2026-09-20T09:30:00Z', kind: 'timeline', direct: true }]) };
268
- if (url === 'https://mei.pod.example/anything/ap-state/actors.json') return { ok: true, status: 200, json: async () => ({ 'https://x.example/u/tamara': { preferredUsername: 'tamara', name: 'Tamara' } }) };
269
- return { ok: false, status: 404, json: async () => ({}), text: async () => '' };
270
- };
271
- const session = { webId: 'https://mei.pod.example/profile/card#me', fetch: podFetch, signOut: async () => {} };
272
- const stubWithCard = async (url, init = {}) => {
273
- if (url === 'https://mei.pod.example/profile/card#me') return { ok: true, json: async () => ([{ '@id': url, 'https://www.w3.org/ns/activitystreams#outbox': [{ '@id': 'https://front.example/u/mei/ap/outbox' }], 'http://xmlns.com/foaf/0.1/account': [{ '@id': 'https://front.example/u/mei/ap/actor' }] }]) };
274
- if (url === 'https://front.example/u/mei/ap/actor') return { ok: true, json: async () => ({ id: url, preferredUsername: 'mei', name: 'Mei', followers: 'https://front.example/u/mei/ap/followers' }) };
275
- return acctStub(url, init);
276
- };
277
- const podStore = { getItem: (k) => podStore.m.get(k) ?? null, setItem: (k, v) => podStore.m.set(k, String(v)), removeItem: (k) => podStore.m.delete(k), m: new Map() };
278
- // Reach the pod account the way resume() would, with the session handed in.
279
- const meiAcct = await fediAccount({ dbName: 'x', fetch: stubWithCard, storage: podStore })
280
- ._podAccount(session, { handle: '@mei@front.example', actor: 'https://front.example/u/mei/ap/actor', root: 'https://mei.pod.example/anything/' });
281
- const p2 = await meiAcct.post({ text: 'hello <b>' }).catch((e) => ({ error: e.message }));
282
- const t2 = await meiAcct.timeline().catch((e) => ({ error: e.message }));
283
- const f2 = await meiAcct.follow('@tamara@x.example').catch((e) => ({ error: e.message }));
284
- const doorBodies = doorSeen.filter((s) => s.startsWith('POST https://front.example/u/mei/ap/outbox')).length;
285
- if (meiAcct.kind !== 'pod' || meiAcct.notice !== BROWSER_ACCOUNT_NOTICE || !p2.queued || p2.id !== 'https://front.example/u/mei/ap/notes/n1'
286
- || t2.length !== 1 || t2[0].author.handle !== '@tamara@x.example' || !f2.queued || doorBodies !== 2) {
287
- fail(`the pod account did not act through its door: ${JSON.stringify({ notice: meiAcct.notice, p2, t2, f2, doorBodies })}`); bad++;
288
- }
289
186
  const bound = code(read(path.join(root, 'web/app/oidc-session.mjs')));
290
- if (!/dbName:\s*'fedipod-oidc'/.test(bound)) { fail("web/app/oidc-session.mjs must bind dbName 'fedipod-oidc' — every signed-in browser holds its session under that name"); bad++; }
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');
187
+ if (!/dbName:\s*'fedipod-oidc'/.test(bound)) fail("web/app/oidc-session.mjs must bind dbName 'fedipod-oidc' — every signed-in browser holds its session under that name");
188
+ else pass("the app binds the session library to its own database name");
292
189
  }
293
190
 
294
191
  // ---- 9. core is a library: nothing in it reaches the DeviceAgent, and every entry loads alone ----
@@ -1,14 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  // check-sizes.mjs — no runtime source file over 1,000 lines. The split of
3
3
  // 2026-09-11 made every module readable one part at a time; this keeps it so.
4
- // Run by `npm test`. Exits 1 naming the offenders.
4
+ // Run by `npm test`, and on the forum's and the Server's sources (named as
5
+ // arguments) by scripts/test-dependents.mjs. Exits 1 naming the offenders.
6
+ //
7
+ // node scripts/check-sizes.mjs [extra source folder ...]
5
8
  import fs from 'node:fs';
6
9
  import path from 'node:path';
7
10
  import { fileURLToPath } from 'node:url';
8
11
 
9
12
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
13
  const LIMIT = 1000;
11
- const ROOTS = ['lib', 'bin', 'web/app', 'web/front', 'web/admin', 'netlify/functions', 'packages/fedipod-server/src', 'packages/css-nextgraph/src', 'packages/fedipod-bb/src'];
14
+ const ROOTS = ['lib', 'bin', 'web/app', 'web/front', 'web/admin', 'netlify/functions', ...process.argv.slice(2).map(d => path.resolve(d))];
12
15
  const FILES = ['run-agent.mjs'];
13
16
  const SKIP = /\/(dist|site|node_modules|vendor|client|shims)\//u;
14
17
  const EXT = /\.(mjs|js|ts)$/u;
@@ -21,7 +24,7 @@ const walk = (dir, out = []) => {
21
24
  }
22
25
  return out;
23
26
  };
24
- const files = [...FILES.map(f => path.join(root, f)), ...ROOTS.flatMap(r => (fs.existsSync(path.join(root, r)) ? walk(path.join(root, r)) : []))];
27
+ const files = [...FILES.map(f => path.join(root, f)), ...ROOTS.flatMap(r => (fs.existsSync(path.resolve(root, r)) ? walk(path.resolve(root, r)) : []))];
25
28
  const over = files
26
29
  .map(f => ({ f: path.relative(root, f), n: fs.readFileSync(f, 'utf8').split('\n').length }))
27
30
  .filter(x => x.n > LIMIT)
@@ -0,0 +1,76 @@
1
+ // dependents.mjs — the packages built on fedipod, each in a repository of its
2
+ // own, and where this machine keeps each one. A checkout beside FediPod
3
+ // (../fedipod-bb and so on) is used as it stands, edits and all; failing that,
4
+ // a shallow clone of its main branch under .dependents/, which is what CI and
5
+ // anyone else's clone get.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { execFileSync } from 'node:child_process';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ export const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
+
13
+ // `fedipod`: whether its tests import fedipod, and so run against this checkout.
14
+ // `sizes`: its source folder, held to FediPod's size limit.
15
+ export const DEPENDENTS = {
16
+ 'fedipod-server': {
17
+ repo: 'https://github.com/jeff-zucker/fedipod-server.git', fedipod: true, sizes: 'src',
18
+ tests: [['npm', 'test'], ['npm', 'run', 'test:e2e'], ['npm', 'run', 'test:e2e:suffix']],
19
+ },
20
+ 'fedipod-bb': {
21
+ repo: 'https://github.com/jeff-zucker/fedipod-bb.git', fedipod: true, sizes: 'src',
22
+ tests: [['npm', 'test']],
23
+ },
24
+ 'fediverse-session': {
25
+ repo: 'https://github.com/jeff-zucker/fediverse-session.git', fedipod: false,
26
+ tests: [['npm', 'test']],
27
+ },
28
+ };
29
+
30
+ const nameOf = (dir) => {
31
+ try { return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).name; } catch { return null; }
32
+ };
33
+
34
+ /** The folder holding this dependent: the checkout beside FediPod, else .dependents/<name>. */
35
+ export function dependentDir(name) {
36
+ const beside = path.resolve(root, '..', name);
37
+ return nameOf(beside) === name ? beside : path.join(root, '.dependents', name);
38
+ }
39
+
40
+ /** The dependent's folder, cloned from its repository first when there is none. */
41
+ export function fetchDependent(name, { log = console.log } = {}) {
42
+ const dir = dependentDir(name);
43
+ if (nameOf(dir) === name) return dir;
44
+ log(`dependents: cloning ${DEPENDENTS[name].repo} into ${path.relative(root, dir)}`);
45
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
46
+ execFileSync('git', ['clone', '--quiet', '--depth', '1', DEPENDENTS[name].repo, dir], { stdio: 'inherit' });
47
+ return dir;
48
+ }
49
+
50
+ /** Its packages installed, when it has any and they are not there yet. */
51
+ export function installDependent(dir, { log = console.log } = {}) {
52
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
53
+ const wants = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).length > 0;
54
+ if (!wants || fs.existsSync(path.join(dir, 'node_modules'))) return;
55
+ const lock = fs.existsSync(path.join(dir, 'package-lock.json'));
56
+ log(`dependents: npm ${lock ? 'ci' : 'install'} in ${dir}`);
57
+ execFileSync('npm', [lock ? 'ci' : 'install', '--no-audit', '--no-fund'], { cwd: dir, stdio: 'inherit' });
58
+ }
59
+
60
+ /** Its `fedipod` pointed at this checkout, replacing whatever npm put there. */
61
+ export function linkFedipod(dir) {
62
+ const link = path.join(dir, 'node_modules', 'fedipod');
63
+ const real = (p) => { try { return fs.realpathSync(p); } catch { return null; } };
64
+ if (real(link) === real(root)) return;
65
+ fs.rmSync(link, { recursive: true, force: true });
66
+ fs.mkdirSync(path.dirname(link), { recursive: true });
67
+ fs.symlinkSync(root, link, 'dir');
68
+ }
69
+
70
+ /** The dependent made ready to run against this checkout. */
71
+ export function readyDependent(name, opts = {}) {
72
+ const dir = fetchDependent(name, opts);
73
+ installDependent(dir, opts);
74
+ if (DEPENDENTS[name].fedipod) linkFedipod(dir);
75
+ return dir;
76
+ }
@@ -13,10 +13,11 @@
13
13
  // source is the hardest kind of thing to see in the browser build.
14
14
  import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url';
15
15
  import { createHash } from 'node:crypto';
16
+ import { readyDependent } from './dependents.mjs';
16
17
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
18
  const site = path.join(root, 'web/app/site');
18
19
  fs.rmSync(site, { recursive: true, force: true }); fs.mkdirSync(path.join(site, 'app'), { recursive: true });
19
- const cp = (from, to) => fs.copyFileSync(path.join(root, from), path.join(site, to));
20
+ const cp = (from, to) => fs.copyFileSync(path.resolve(root, from), path.join(site, to));
20
21
  cp('web/app/index.html', 'index.html');
21
22
  cp('web/app/dist/boot.js', 'boot.js');
22
23
  cp('web/app/dist/sw.js', 'sw.js');
@@ -267,21 +268,22 @@ fs.writeFileSync(path.join(site, '_redirects'), [
267
268
  // own /u/<handle>/ or a pod named outright. Its script is a module file of
268
269
  // its own, so it is served under the same `script-src 'self'` as the app.
269
270
  fs.mkdirSync(path.join(site, 'bb'), { recursive: true });
270
- for (const f of ['index.html', 'bb.js', 'read.mjs', 'masto.mjs', 'pod.mjs', 'markdown.mjs', 'seen.mjs', 'mine.mjs', 'private.mjs', 'oidc-session.mjs']) cp(`packages/fedipod-bb/site/${f}`, `bb/${f}`);
271
+ for (const f of ['index.html', 'bb.js', 'read.mjs', 'masto.mjs', 'pod.mjs', 'markdown.mjs', 'seen.mjs', 'mine.mjs', 'private.mjs', 'oidc-session.mjs']) cp(`node_modules/fedipod-bb/site/${f}`, `bb/${f}`);
271
272
  cp('web/admin/tokens.css', 'bb/tokens.css'); // the site's shared palette, size and family
272
- cp('lib/session/oidc-session.mjs', 'bb/solid-oidc-session.mjs'); // signing in to a pod: the library the forum's binding names
273
- // The account library's demo page, with the library beside it, under /demo/.
273
+ cp('node_modules/fediverse-session/oidc-session.mjs', 'bb/solid-oidc-session.mjs'); // signing in to a pod: the library the forum's binding names
274
+ // The account library's demo page, with the library beside it, under /demo/:
275
+ // both from the library's own repository, since the package carries no demo.
276
+ const sessionRepo = readyDependent('fediverse-session');
274
277
  fs.mkdirSync(path.join(site, 'demo'), { recursive: true });
275
- for (const f of ['fedi-account.mjs', 'fedi-login.mjs', 'oidc-session.mjs']) cp(`lib/session/${f}`, `demo/${f}`);
278
+ for (const f of ['fedi-account.mjs', 'fedi-login.mjs', 'oidc-session.mjs']) cp(path.join(sessionRepo, f), `demo/${f}`);
276
279
  cp('web/admin/tokens.css', 'demo/tokens.css');
277
280
  // The page a Mastodon app sends its person to (lib/gateway/masto-gateway.mjs),
278
281
  // with the pod sign-in library and the palette beside it, as the demo has.
279
282
  fs.mkdirSync(path.join(site, 'app-signin'), { recursive: true });
280
283
  for (const f of ['index.html', 'app-signin.mjs']) cp(`web/app-signin/${f}`, `app-signin/${f}`);
281
- for (const f of ['fedi-login.mjs', 'oidc-session.mjs']) cp(`lib/session/${f}`, `app-signin/${f}`);
284
+ for (const f of ['fedi-login.mjs', 'oidc-session.mjs']) cp(`node_modules/fediverse-session/${f}`, `app-signin/${f}`);
282
285
  cp('web/admin/tokens.css', 'app-signin/tokens.css');
283
- fs.writeFileSync(path.join(site, 'demo/index.html'),
284
- fs.readFileSync(path.join(root, 'lib/session/demo.html'), 'utf8').replace('../../web/admin/tokens.css', './tokens.css'));
286
+ cp(path.join(sessionRepo, 'demo.html'), 'demo/index.html');
285
287
  // Each file the forum page loads is named with a hash of its content. A
286
288
  // browser holding the last build cannot serve half of it back: the page is
287
289
  // revalidated (no-cache below) and every url under it changes with its bytes.
package/scripts/start.mjs CHANGED
@@ -7,7 +7,7 @@ console.log(`FediPod is not started from this directory.
7
7
  In a browser: open a Gateway such as https://fedipod.net and create an
8
8
  account. Nothing is installed.
9
9
  A Gateway of your own: deploy this repository to Netlify — netlify/README.md
10
- A pod server with FediPod built in: packages/fedipod-server/README.md
11
- A forum: packages/fedipod-bb/README.md, or "A forum" on a
10
+ A pod server with FediPod built in: https://github.com/jeff-zucker/fedipod-server
11
+ A forum: https://github.com/jeff-zucker/fedipod-bb, or "A forum" on a
12
12
  Gateway's sign-up page
13
13
  `);
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ // test-dependents.mjs — every package built on fedipod, tested against this
3
+ // checkout: the Server, the forum and the session library, each from the
4
+ // folder dependents.mjs finds it in. Run by `npm test` before the browser rigs,
5
+ // which boot their scratch pod server from the Server's folder. A dependent
6
+ // that cannot be fetched, installed or run fails the whole run; none is ever
7
+ // skipped. Exits 1, the failures named in its last lines.
8
+ //
9
+ // node scripts/test-dependents.mjs [name ...]
10
+ import path from 'node:path';
11
+ import { spawnSync, execFileSync } from 'node:child_process';
12
+ import { DEPENDENTS, readyDependent, root } from './dependents.mjs';
13
+
14
+ const names = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(DEPENDENTS);
15
+ const failed = [];
16
+ for (const name of names) {
17
+ const spec = DEPENDENTS[name];
18
+ if (!spec) { failed.push(`${name}: not a dependent`); continue; }
19
+ let dir;
20
+ try { dir = readyDependent(name); }
21
+ catch (e) { failed.push(`${name}: could not be made ready (${e.message})`); continue; }
22
+ console.log(`\n== ${name} (${dir}) against this checkout`);
23
+ for (const cmd of spec.tests) {
24
+ const r = spawnSync(cmd[0], cmd.slice(1), { cwd: dir, stdio: 'inherit' });
25
+ if (r.status !== 0) failed.push(`${name}: \`${cmd.join(' ')}\` failed (exit ${r.status ?? r.signal})`);
26
+ }
27
+ if (spec.sizes) {
28
+ try { execFileSync(process.execPath, [path.join(root, 'scripts/check-sizes.mjs'), path.join(dir, spec.sizes)], { stdio: 'inherit' }); }
29
+ catch { failed.push(`${name}: a source file is over the size limit`); }
30
+ }
31
+ }
32
+ if (failed.length) {
33
+ console.error(`\ntest-dependents: ${failed.length} FAILED`);
34
+ for (const f of failed) console.error(` ${f}`);
35
+ process.exit(1);
36
+ }
37
+ console.log(`\ntest-dependents: ${names.join(', ')} pass against this checkout`);
@@ -19,43 +19,27 @@ async function refreshGateway() {
19
19
  GW_OPEN_ATTACH.hidden = true;
20
20
  GW_OPEN_DETACH.hidden = false;
21
21
  // The account's standing at the gateway — paused, closed. The browser
22
- // build's agent reports it; a DeviceAgent's does not, and the pause and
23
- // close controls stay hidden.
22
+ // build's agent reports it; a DeviceAgent's does not, and the keep
23
+ // control stays hidden.
24
24
  const st = g.standing;
25
25
  const known = !!st && (st.status === 200 || st.status === 410);
26
- $('gateway-pause').hidden = !known || !!st.closed || !!st.paused;
27
- $('gateway-resume').hidden = !known || !!st.closed || !st.paused;
28
- $('gateway-close').hidden = !known || !!st.closed;
29
26
  if (known && st.closed) GATEWAY_WORD.textContent += ' — this address is closed';
30
27
  else if (known && st.paused) GATEWAY_WORD.textContent += st.pausedBy === 'owner' ? ' — paused by you' : ' — paused';
31
- // Whether the gateway keeps the account running while FediPod is closed.
28
+ // Kept running while FediPod is closed; offered only to an account that is not.
32
29
  const kp = g.keeper;
33
30
  $('gateway-keep-on').hidden = !kp || !known || !!st.closed || kp.on;
34
- $('gateway-keep-off').hidden = !kp || !known || !!st.closed || !kp.on;
35
- if (kp?.on && known && !st.closed) GATEWAY_WORD.textContent += ' — kept running while you are away';
36
31
  } else {
37
32
  GATEWAY_WORD.textContent = '';
38
33
  GW_OPEN_ATTACH.hidden = false;
39
34
  GW_OPEN_DETACH.hidden = true;
40
- for (const id of ['gateway-pause', 'gateway-resume', 'gateway-keep-on', 'gateway-keep-off', 'gateway-close']) $(id).hidden = true;
35
+ $('gateway-keep-on').hidden = true;
41
36
  }
42
37
  }
43
- const setPausedAtGateway = async (paused) => {
44
- const r = await write('/gateway/pause', { paused },
45
- paused ? 'paused — posts sent to you are not kept until you resume; follows still arrive'
46
- : 'resumed — posts sent to you are kept again');
38
+ $('gateway-keep-on').addEventListener('click', async () => {
39
+ const r = await write('/gateway/keep', { on: true },
40
+ 'kept running — follows, deliveries to try again and scheduled posts are handled while FediPod is closed');
47
41
  if (r) refreshGateway();
48
- };
49
- $('gateway-pause').addEventListener('click', () => setPausedAtGateway(true));
50
- $('gateway-resume').addEventListener('click', () => setPausedAtGateway(false));
51
- const setKept = async (on) => {
52
- const r = await write('/gateway/keep', { on },
53
- on ? 'kept running — follows, deliveries to try again and scheduled posts are handled while FediPod is closed'
54
- : 'stopped — your account acts only while FediPod is open');
55
- if (r) refreshGateway();
56
- };
57
- $('gateway-keep-on').addEventListener('click', () => setKept(true));
58
- $('gateway-keep-off').addEventListener('click', () => setKept(false));
42
+ });
59
43
  const gwShape = () => document.querySelector('input[name=gwShape]:checked')?.value || 'pod';
60
44
  function gwPreviews() {
61
45
  $('gw-pod-preview').textContent = config?.address || `@${config?.handle || 'you'}@your.pod`;