fedipod 1.47.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.
package/README.md CHANGED
@@ -137,11 +137,11 @@ not change.
137
137
 
138
138
  ## Other ways to use FediPod
139
139
 
140
- - [fediverse-session](lib/session/README.md) - an ESM library that supports both Solid-based and regular Fediverse accounts with methods to login, reply, boost, etc.
141
- - [FediPod Server](packages/fedipod-server/README.md): a full ActivityPub
140
+ - [fediverse-session](https://github.com/jeff-zucker/fediverse-session) - an ESM library that supports both Solid-based and regular Fediverse accounts with methods to login, reply, boost, etc.
141
+ - [FediPod Server](https://github.com/jeff-zucker/fedipod-server): a full ActivityPub
142
142
  server as a Community Solid Server component, giving every pod on the server
143
143
  the option of a Fediverse account.
144
- - [FediPod BB](packages/fedipod-bb/README.md) - A Lemmy-like bulletin board forum with the structure stored on a group pod and users' data stored on their own pod or on their Fediverse server if they don't have a pod.
144
+ - [FediPod BB](https://github.com/jeff-zucker/fedipod-bb) - A Lemmy-like bulletin board forum with the structure stored on a group pod and users' data stored on their own pod or on their Fediverse server if they don't have a pod.
145
145
  - [FediPod DeviceAgent](device-agent.md) a single-user AP server that lives partly in a gateway, partly on a local device, with all data stored on your pod.
146
146
  - [FediPod Groups](groups.md): hosting a discussion group of Fediverse and Bluesky users from a pod.
147
147
  - [FediPod Gateway](gateway.md): A thin, always-on door running on Netlify or any small host that provides a middleman between a pod and the single-user agents. (only needed with `BrowserAgent` and `DeviceAgent`)
package/architecture.md CHANGED
@@ -27,7 +27,7 @@ hold). A gateway's admin may turn the hold off: then there is no copy, and
27
27
  everything works on the pod, the browser and the gateway taking turns by the
28
28
  pod's lease. See [the gateway](gateway.md).
29
29
 
30
- [FediPod Server](packages/fedipod-server/README.md) puts the agent inside a
30
+ [FediPod Server](https://github.com/jeff-zucker/fedipod-server) puts the agent inside a
31
31
  Community Solid Server, so anyone with a pod on that server can opt in to a
32
32
  Fediverse account fed by the server itself.
33
33
 
package/gateway.md CHANGED
@@ -308,7 +308,7 @@ Two ways are ready to use:
308
308
  [netlify/README.md](netlify/README.md) for the deployment specifics.
309
309
  - **On any box of your own**, with your own adapter around the same core.
310
310
  - **Inside a Community Solid Server**, as the same door run in-process by the
311
- CSS component — see [packages/fedipod-server](packages/fedipod-server/README.md).
311
+ CSS component — see [fedipod-server](https://github.com/jeff-zucker/fedipod-server).
312
312
 
313
313
  ### Offering accounts to other people
314
314
 
@@ -396,7 +396,7 @@ export async function startEmbeddedAgent({
396
396
  basePath: uiPath,
397
397
  mount,
398
398
  publicOrigin: base,
399
- scheme: new URL(base).protocol,
399
+ scheme: new URL(base).protocol.slice(0, -1),
400
400
  });
401
401
 
402
402
  // A delivery landing in the inbox — through the door, or from any other
@@ -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
@@ -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.47.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/hold-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`);