fedipod 1.47.0 → 1.48.1

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.
@@ -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.1",
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`);
@@ -527,7 +527,8 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
527
527
  <p><strong>This cannot be undone.</strong> A Delete goes to every follower's inbox telling
528
528
  those servers to drop the account and the posts they cached, the actor document is
529
529
  replaced with a Tombstone, and this agent will refuse to start again for this pod. Your
530
- posts and RDF stay on the pod; the identity does not come back.</p>
530
+ WebID profile and public type index stop naming the account. Your posts and RDF stay on
531
+ the pod; the identity does not come back.</p>
531
532
  <p>Two things that are not this, and can be done from here:
532
533
  <button type="button" id="go-park" title=" Park instead: keeps the handle, just stops the mail" class="inline">Park it instead</button>
533
534
  keeps the handle resolving and just stops the mail — setting status back to
package/web/app/agent.mjs CHANGED
@@ -152,8 +152,9 @@ export class BrowserAgent {
152
152
 
153
153
  // One call to the gateway's owner API, proved with the pod session. What
154
154
  // comes back on 200 or 410 is the account's standing there, kept for the
155
- // manage page; anything else is logged and forgotten.
156
- async tellGateway(what, body) {
155
+ // manage page; anything else is logged and forgotten. `standing: false` for
156
+ // a call whose answer says nothing about the account's standing.
157
+ async tellGateway(what, body, { standing = true } = {}) {
157
158
  if (!this.gatewayApi) return null;
158
159
  let res;
159
160
  try {
@@ -163,15 +164,38 @@ export class BrowserAgent {
163
164
  } catch (e) { this.log(`gateway ${what}: ${e.message}`); return null; }
164
165
  const json = await res.json().catch(() => ({}));
165
166
  const out = { status: res.status, ...json };
166
- if (res.status === 200 || res.status === 410) this.gatewayStanding = out;
167
+ if (res.status === 200 || res.status === 410) { if (standing) this.gatewayStanding = out; }
167
168
  else this.log(`gateway ${what}: ${res.status} ${json.error || ''}`);
168
169
  return out;
169
170
  }
170
- openAtGateway() { return this.tellGateway('open', { handle: this.doorKey }); }
171
+ // The account's picture goes with it, for the address's WebFinger answer;
172
+ // said only once the account has been read, so "none" is never a guess.
173
+ openAtGateway() {
174
+ const config = this.store?.getConfig?.();
175
+ return this.tellGateway('open', { handle: this.doorKey, ...(config ? { icon: config.icon || null } : {}) });
176
+ }
171
177
  pauseAtGateway(paused) { return this.tellGateway('pause', { handle: this.doorKey, paused: !!paused }); }
172
178
  closeAtGateway() { return this.tellGateway('close', { handle: this.doorKey, confirm: true }); }
173
179
  static OPEN_EVERY_MS = 60 * 60_000;
174
180
 
181
+ // A notification made here, from mail read while FediPod is open, goes to
182
+ // the phones the owner signed up, as one made while it is closed does
183
+ // (lib/gateway/held-mail.mjs: /api/push). Named a moment later, once written,
184
+ // so a burst is one call. One from another network is not pushed.
185
+ static PUSH_AFTER_MS = 2_000;
186
+ notePush(n) {
187
+ if (!this.gatewayApi || n?.bsky || n?.via) return;
188
+ if (!Object.keys(this.store.read('webpush.json', { subs: {} }).subs || {}).length) return;
189
+ (this._toPush ||= new Set()).add(n.id);
190
+ this._pushTimer ||= setTimeout(async () => {
191
+ const ids = [...this._toPush];
192
+ this._toPush.clear();
193
+ this._pushTimer = null;
194
+ await this.store.flush?.().catch(() => {});
195
+ await this.tellGateway('push', { handle: this.doorKey, ids }, { standing: false });
196
+ }, BrowserAgent.PUSH_AFTER_MS);
197
+ }
198
+
175
199
  // The gateway acting for this account while the app is closed, or not. On:
176
200
  // the rules on the account's folders name the gateway's pod identity, then
177
201
  // the gateway is told. Off: the gateway is told first, so it stops, then the
@@ -304,6 +328,7 @@ export class BrowserAgent {
304
328
  if (this.viewer) return; // another agent holds the copy: reading only
305
329
  }
306
330
  }
331
+ this.store.onEvent = (type, n) => { if (type === 'notification') this.notePush(n); };
307
332
  const now = Date.now();
308
333
  // Said before the drain starts, so held mail is in the inbox to be read.
309
334
  const hereDue = !warm || now - (warm.hereAt || 0) >= BrowserAgent.HERE_EVERY_MS;