fedipod 1.36.6 → 1.40.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 (94) hide show
  1. package/README.md +29 -8
  2. package/architecture.md +8 -0
  3. package/bin/fedipod.mjs +6 -0
  4. package/cli.md +7 -1
  5. package/device-agent.md +3 -3
  6. package/gateway.md +69 -8
  7. package/groups.md +2 -1
  8. package/gui.md +3 -1
  9. package/lib/client/c2s.mjs +97 -43
  10. package/lib/client/masto/accounts.mjs +1 -0
  11. package/lib/client/masto/bridge.mjs +61 -0
  12. package/lib/client/masto/index.mjs +4 -1
  13. package/lib/client/masto/oauth.mjs +1 -1
  14. package/lib/client/masto/statuses.mjs +3 -0
  15. package/lib/connections/acctfeed.mjs +13 -9
  16. package/lib/connections/bskyfeed.mjs +13 -9
  17. package/lib/connections/tagfeed.mjs +14 -9
  18. package/lib/core/deliver.mjs +17 -1
  19. package/lib/core/intake/activities.mjs +18 -2
  20. package/lib/core/intake/group.mjs +3 -1
  21. package/lib/core/intake/index.mjs +226 -48
  22. package/lib/core/intake/notes.mjs +12 -5
  23. package/lib/core/intake/verify.mjs +11 -0
  24. package/lib/core/lease.mjs +15 -1
  25. package/lib/core/place.mjs +82 -0
  26. package/lib/core/pod-only.mjs +4 -0
  27. package/lib/core/publisher/collections.mjs +37 -2
  28. package/lib/core/publisher/index.mjs +25 -1
  29. package/lib/core/publisher/notes.mjs +73 -7
  30. package/lib/core/publisher/own.mjs +143 -0
  31. package/lib/core/publisher/questions.mjs +5 -3
  32. package/lib/core/scheduled.mjs +39 -0
  33. package/lib/core/social.mjs +62 -32
  34. package/lib/core/storage.mjs +67 -0
  35. package/lib/core/store.mjs +77 -5
  36. package/lib/core/wire.mjs +51 -15
  37. package/lib/device/admin/routes/lifecycle.mjs +1 -1
  38. package/lib/device/admin/routes/setup.mjs +17 -1
  39. package/lib/device/cli/commands/setup.mjs +50 -8
  40. package/lib/device/cli/context.mjs +1 -1
  41. package/lib/device/migrate.mjs +1 -1
  42. package/lib/device/setup.mjs +45 -8
  43. package/lib/gateway/account-agent.mjs +111 -0
  44. package/lib/gateway/copy.mjs +381 -0
  45. package/lib/gateway/front-core.mjs +74 -63
  46. package/lib/gateway/gateway-core.mjs +107 -9
  47. package/lib/gateway/held-mail.mjs +198 -0
  48. package/lib/gateway/keeper-due.mjs +39 -0
  49. package/lib/gateway/keeper-session.mjs +10 -0
  50. package/lib/gateway/keeper.mjs +72 -0
  51. package/lib/gateway/masto-gateway.mjs +510 -0
  52. package/lib/gateway/quiet.mjs +7 -2
  53. package/lib/gateway/relay-extras.mjs +89 -0
  54. package/lib/gateway/state-api.mjs +207 -0
  55. package/lib/gateway/token-claims.mjs +16 -0
  56. package/lib/pod/containers.mjs +17 -0
  57. package/lib/pod/location.mjs +52 -0
  58. package/lib/pod/notes.mjs +2 -4
  59. package/lib/pod/transport.mjs +214 -24
  60. package/lib/pod/type-index.mjs +101 -0
  61. package/lib/pod/urls.mjs +6 -0
  62. package/lib/server/embed.mjs +7 -6
  63. package/lib/session/README.md +5 -5
  64. package/lib/session/demo.html +1 -1
  65. package/lib/session/fedi-account.mjs +19 -10
  66. package/lib/session/package.json +2 -2
  67. package/package.json +2 -2
  68. package/run-agent.mjs +6 -16
  69. package/scripts/stage-site.mjs +17 -4
  70. package/web/admin/actors.js +2 -0
  71. package/web/admin/gateway.js +14 -1
  72. package/web/admin/index.html +13 -0
  73. package/web/admin/oauth-signin.mjs +1 -1
  74. package/web/admin/record.js +4 -1
  75. package/web/admin/setup/index.html +17 -1
  76. package/web/admin/setup/setup.js +18 -5
  77. package/web/app/README.md +2 -2
  78. package/web/app/admin-facade.mjs +12 -2
  79. package/web/app/agent.mjs +228 -51
  80. package/web/app/boot.mjs +75 -32
  81. package/web/app/copy-mode.mjs +223 -0
  82. package/web/app/dist/boot.js +498 -88
  83. package/web/app/dist/boot.js.map +4 -4
  84. package/web/app/dist/sw.js +4016 -2539
  85. package/web/app/dist/sw.js.map +4 -4
  86. package/web/app/index.html +16 -0
  87. package/web/app/signup.mjs +65 -26
  88. package/web/app/sw-src.mjs +15 -53
  89. package/web/app/update.js +3 -2
  90. package/web/app/warm-start.mjs +115 -0
  91. package/web/app-signin/app-signin.mjs +78 -0
  92. package/web/app-signin/index.html +41 -0
  93. package/web/front/run.html +7 -1
  94. package/web/front/run.js +30 -4
@@ -0,0 +1,510 @@
1
+ // masto-gateway.mjs — Mastodon apps at the gateway's own address: elk.zone,
2
+ // Ivory, Tusky or any other app, signed in to an account the gateway keeps
3
+ // running, reading and writing it from its copy (copy.mjs).
4
+ //
5
+ // POST /api/v1/apps an app registers here, for every account
6
+ // GET /oauth/authorize sends the person to the sign-in page
7
+ // (/app-signin/, web/app-signin/), which
8
+ // signs them in at their own pod
9
+ // GET /api/authorize?client_id=… what the page shows: the app's name
10
+ // GET /api/authorize?address=… the pod an address here belongs to
11
+ // POST /api/authorize the pod sign-in, proved: a code for the app
12
+ // POST /oauth/token, /oauth/revoke the code for a token, and back
13
+ // GET /api/v1/instance, /api/v2/instance fedipod.net itself
14
+ // anything else under /api/v1, /api/v2 with a token: the account's own
15
+ // Mastodon facade (lib/client/masto), over its copy
16
+ //
17
+ // Apps, codes and tokens are kept in `ctx.mastoKv` (the same kind of store as
18
+ // the copy); a token is kept only as its hash. Reading is answered from the
19
+ // copy without the lease. Acting takes the lease — an app acting outranks a
20
+ // browser sitting open, as one device does another — and reads the signing
21
+ // key from the pod for that one request. Mail that arrived while nobody was
22
+ // reading is read into the copy after an app's timeline or notification check
23
+ // has been answered, at most every thirty seconds.
24
+ //
25
+ // Phone notifications: an app signs up for them through the account's own
26
+ // facade (/api/v1/push/subscription), with the one push key pair this gateway
27
+ // keeps for all its accounts, made the first time it is needed. When the door
28
+ // holds a delivery that becomes a notification (gateway-core: notifies), it
29
+ // starts a short run (push-background.mjs, pushHeld below) that reads the held
30
+ // mail into the copy and pushes each notification it makes.
31
+ import crypto from 'node:crypto';
32
+ import { MastoApi } from '../client/masto/index.mjs';
33
+ import { instanceConfig } from '../client/masto/instance.mjs';
34
+ import { TRANSPARENT_PNG } from '../client/masto/render.mjs';
35
+ import { bridge } from '../client/masto/bridge.mjs';
36
+ import { PodStore } from '../core/store.mjs';
37
+ import { apUrls } from '../core/wire.mjs';
38
+ import { Publisher } from '../core/publisher/index.mjs';
39
+ import { nextDue } from '../core/scheduled.mjs';
40
+ import { reachAccount, stateAndLease, actingAgent } from './account-agent.mjs';
41
+ import { ensureCopy } from './state-api.mjs';
42
+ import { CopyStorage, lockCopy, keptNow, keptBefore, GATEWAY_HOLDER } from './copy.mjs';
43
+ import { heldEntries } from './held-mail.mjs';
44
+ import { Push } from '../client/webpush.mjs';
45
+ import webpush from 'web-push';
46
+
47
+ const TOKEN_TTL_MS = 90 * 86400_000;
48
+ const CODE_TTL_MS = 10 * 60_000;
49
+ const MAIL_EVERY_MS = 30_000;
50
+ const SIGNIN_PAGE = '/app-signin/';
51
+ const API_VERSIONS = { mastodon: 7 };
52
+
53
+ const sha = (s) => crypto.createHash('sha256').update(String(s)).digest('base64url');
54
+ // A client id as this gateway makes them. Anything else is not looked up: an
55
+ // id is part of a storage key.
56
+ const CLIENT_ID = /^[A-Za-z0-9_-]{16,64}$/u;
57
+ const appOf = (kv, id) => (CLIENT_ID.test(String(id || '')) ? getJson(kv, `app/${id}`) : null);
58
+ const rand = (n) => crypto.randomBytes(n).toString('base64url');
59
+ const json = (status, obj, headers = {}) => ({
60
+ status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store', ...headers }, body: JSON.stringify(obj),
61
+ });
62
+ // Mastodon apps call from their own pages: anyone may ask, the token is the credential.
63
+ const CORS = {
64
+ 'access-control-allow-origin': '*',
65
+ 'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
66
+ 'access-control-allow-headers': 'Authorization, Content-Type, Idempotency-Key',
67
+ 'access-control-expose-headers': 'Link',
68
+ 'access-control-max-age': '86400',
69
+ };
70
+
71
+ async function getJson(kv, key) {
72
+ const got = await kv.get(key);
73
+ if (!got) return null;
74
+ try { return JSON.parse(got.text); } catch { return null; }
75
+ }
76
+ const putJson = (kv, key, obj) => kv.set(key, JSON.stringify(obj));
77
+
78
+ // A body as a Mastodon app sends it: JSON, a form, or multipart fields.
79
+ async function formOf(request) {
80
+ const ct = String(request.headers.get('content-type') || '');
81
+ if (ct.includes('application/json')) return request.json().catch(() => ({}));
82
+ if (ct.includes('multipart/form-data')) {
83
+ const f = await request.formData().catch(() => null);
84
+ return f ? Object.fromEntries([...f].filter(([, v]) => typeof v === 'string')) : {};
85
+ }
86
+ return Object.fromEntries(new URLSearchParams(await request.text()));
87
+ }
88
+
89
+ const parseRedirects = (v) => (Array.isArray(v) ? v : String(v || '').split(/\s+/)).map((s) => s.trim()).filter(Boolean);
90
+
91
+ // The directory key an address at this gateway names: "you", "@you",
92
+ // "you@host" or "@you@host".
93
+ function handleOf(address, host) {
94
+ const a = String(address || '').trim().replace(/^@/u, '').toLowerCase();
95
+ if (!a) return null;
96
+ const [name, at] = a.split('@');
97
+ return !at || at === String(host || '').toLowerCase() ? name : a;
98
+ }
99
+
100
+ // An account an app may sign in to: a browser account here, kept running, not
101
+ // moved or closed.
102
+ const usable = (ctx, rec) => !!(rec && rec.openedAt && keptNow(ctx, rec) && !rec.movedTo && !rec.closedAt);
103
+ // Kept under the gateway's former identity: it works again once its owner has
104
+ // opened FediPod in a browser, which moves it to the new one.
105
+ const MOVING = 'this account is moving to fedipod.net\'s new identity: open FediPod in a browser once, then try again';
106
+
107
+ function instanceV1(host) {
108
+ return {
109
+ uri: host, title: host, short_description: 'FediPod: Fediverse accounts whose home is a Solid pod.',
110
+ description: 'FediPod: Fediverse accounts whose home is a Solid pod.', email: '',
111
+ version: '4.2.0 (compatible; fedipod)', api_versions: API_VERSIONS, urls: {},
112
+ stats: { user_count: 0, status_count: 0, domain_count: 0 },
113
+ languages: ['en'], registrations: false, approval_required: false, invites_enabled: false,
114
+ configuration: instanceConfig(), contact_account: null, rules: [],
115
+ };
116
+ }
117
+ function instanceV2(host, origin, vapidKey) {
118
+ return {
119
+ domain: host, title: host, version: '4.2.0 (compatible; fedipod)', api_versions: API_VERSIONS,
120
+ source_url: 'https://github.com/jeff-zucker/FediPod',
121
+ description: 'FediPod: Fediverse accounts whose home is a Solid pod.',
122
+ usage: { users: { active_month: 0 } }, thumbnail: { url: TRANSPARENT_PNG }, languages: ['en'],
123
+ urls: {}, configuration: { ...instanceConfig(), vapid: { public_key: vapidKey } },
124
+ // Accounts are made on the front page, with a pod, not through an app.
125
+ registrations: { enabled: false, approval_required: false, message: null, url: `${origin}/` },
126
+ contact: { email: '', account: null }, rules: [],
127
+ };
128
+ }
129
+
130
+ // ---- phone notifications ----
131
+
132
+ // The gateway's one push key pair, for every account here: an app asks for the
133
+ // key before it knows which account it will sign in to.
134
+ export async function vapidKeys(kv) {
135
+ const had = await getJson(kv, 'vapid');
136
+ if (had?.publicKey && had.privateKey) return had;
137
+ await kv.set('vapid', JSON.stringify(webpush.generateVAPIDKeys()), { ifNew: true });
138
+ return getJson(kv, 'vapid');
139
+ }
140
+
141
+ class GatewayPush extends Push {
142
+ constructor({ keys, ...o }) { super(o); this.keys = keys; }
143
+ vapid() { return this.keys; }
144
+ }
145
+
146
+ // ---- the account behind a token ----
147
+
148
+ // Loaded copies, kept while this running copy lives: a check that finds the
149
+ // copy unchanged costs one listing, not every document.
150
+ const reading = new Map(); // handle -> PodStore
151
+ const noAuthorities = { has: () => false, isLocalRequest: () => false, isLocal: () => false, wsAuthorities: () => [] };
152
+
153
+ async function readingStore(ctx, handle, log) {
154
+ let store = reading.get(handle);
155
+ if (!store) {
156
+ store = new PodStore({ storage: new CopyStorage(ctx.copyKv, handle, { holder: GATEWAY_HOLDER }), log });
157
+ if (reading.size > 200) reading.clear();
158
+ reading.set(handle, store);
159
+ }
160
+ await store.load();
161
+ return store;
162
+ }
163
+
164
+ // What an app reading needs of an agent: the store, the account's addresses
165
+ // and a publisher that only answers for them. Anything that must act — a
166
+ // search that fetches a remote account, say — gets the acting agent then.
167
+ function readingAgent(at, store, acting, log) {
168
+ const config = store.getConfig();
169
+ if (!config) return null;
170
+ const publicBase = config?.gateway?.frontActor ? config.gateway.frontActor.replace(/ap\/actor\/?$/u, '') : null;
171
+ if (publicBase) at.remote.setUrlMap(apUrls(at.pod, config.root || at.root, { publicBase }).toPod);
172
+ const publisher = new Publisher({ config, remote: at.remote, store, deliverer: null, publicKeyPem: null, log });
173
+ return {
174
+ store, publisher, remote: at.remote, config, viewer: false, copy: null,
175
+ configured: () => !!store.getConfig(), requestTakeover: async () => true, onScheduled: () => {},
176
+ intake: { fetchAP: async (u) => (await acting()).intake.fetchAP(u) },
177
+ };
178
+ }
179
+
180
+ // Who a push service is told sends the pushes: the gateway itself, as a
181
+ // contact address push services accept (https or mailto).
182
+ const pushContact = (ctx) => (/^https:\/\//u.test(ctx.frontOrigin || '') ? `${ctx.frontOrigin.replace(/\/$/u, '')}/` : `mailto:postmaster@${ctx.host || 'localhost'}`);
183
+
184
+ function facade(agent, token, log, keys = null, contact = null) {
185
+ const api = new MastoApi({ agent, log, scheme: 'https', streaming: false, webPush: !!keys, scheduling: true, allowed: noAuthorities });
186
+ api.tokenOf = () => token;
187
+ if (keys) api._push = new GatewayPush({ store: agent.store, keys, log, subject: () => contact });
188
+ return api;
189
+ }
190
+
191
+ // How many push subscriptions an account has, kept beside the tokens so the
192
+ // door can ask without reading the account.
193
+ async function notePushSubs(ctx, handle, store) {
194
+ const n = Object.keys(store.read('webpush.json', { subs: {} }).subs || {}).length;
195
+ await ctx.mastoKv.set(`push/${handle}`, String(n));
196
+ }
197
+ export async function pushWanted(ctx, handle) { return Number((await ctx.mastoKv?.get(`push/${handle}`))?.text || 0) > 0; }
198
+
199
+ /**
200
+ * Read what was held for an account into its copy, and push each notification
201
+ * that makes to the owner's phones and browsers. `takeover`: act even when a
202
+ * browser that went quiet still holds the lease (a push run); otherwise leave
203
+ * the mail to whoever holds it (an app's check).
204
+ */
205
+ async function readHeld(ctx, handle, rec, { log = console.log, takeover = false, waitMs = 1000 } = {}) {
206
+ if (!(await ctx.listHeld(handle)).length) return 0;
207
+ const unlock = await lockCopy(ctx.copyKv, handle, { waitMs });
208
+ if (!unlock) return 0;
209
+ try {
210
+ const at = await reachAccount(ctx, handle, rec, { log });
211
+ if (at.skipped || !at.inCopy) return 0;
212
+ const { store, lease } = stateAndLease(ctx, handle, at, { log });
213
+ if (!await lease.acquire() && !(takeover && await lease.takeover())) return 0;
214
+ await store.load();
215
+ const agent = await actingAgent(at, store, lease, { log });
216
+ if (agent.skipped) return 0;
217
+ const pushes = [];
218
+ if (await pushWanted(ctx, handle)) {
219
+ const api = facade(agent, null, log, await vapidKeys(ctx.mastoKv), pushContact(ctx));
220
+ store.onEvent = (type, n) => {
221
+ if (type !== 'notification') return;
222
+ // Named in the push by who they are: a sender only a note named is
223
+ // fetched first, as the app would when it showed the notification.
224
+ pushes.push((async () => {
225
+ if (n.actor && !store.getActors()[n.actor]) await agent.intake.fetchAP(n.actor).catch(() => null);
226
+ await api.pushNotify(n);
227
+ })().catch((e) => log(`push @${handle}: ${e.message}`)));
228
+ };
229
+ }
230
+ const entries = await heldEntries(ctx, handle);
231
+ const done = new Set(await agent.intake.takeHeld(entries));
232
+ for (const e of entries) if (done.has(e.name)) for (const n of e.held) await ctx.dropHeld(handle, n);
233
+ await Promise.all(pushes);
234
+ await store.commit();
235
+ await lease.release().catch(() => {});
236
+ await ctx.noteNext?.(handle, nextDue(store)).catch(() => {});
237
+ reading.delete(handle);
238
+ if (done.size) log(`@${handle}: ${done.size} held deliveries read into the copy, ${pushes.length} pushed`);
239
+ return done.size;
240
+ } finally { await unlock(); }
241
+ }
242
+
243
+ /** For an app checking in: at most every thirty seconds, never over a browser. */
244
+ export async function readHeldForApp(ctx, handle, rec, { log = console.log } = {}) {
245
+ if (!ctx.listHeld) return 0;
246
+ const key = `${handle}/mail-read-at`;
247
+ const last = Number((await ctx.copyKv.get(key))?.text || 0);
248
+ if (Date.now() - last < MAIL_EVERY_MS) return 0;
249
+ await ctx.copyKv.set(key, String(Date.now()));
250
+ return readHeld(ctx, handle, rec, { log });
251
+ }
252
+
253
+ /**
254
+ * For the door, when a delivery that becomes a notification was held. The
255
+ * door starts one run at a time for an account and notes each arrival
256
+ * (`pushwant/`); the run goes round again while arrivals keep coming, so a
257
+ * burst of likes is a run or two, not one each.
258
+ */
259
+ export async function pushHeld(ctx, handle, rec, { log = console.log } = {}) {
260
+ if (!ctx.listHeld || !await pushWanted(ctx, handle)) return 0;
261
+ const kv = ctx.mastoKv;
262
+ const wanted = async () => (await kv.get(`pushwant/${handle}`))?.text || null;
263
+ let n = 0;
264
+ let seen = null;
265
+ for (let round = 0; round < 6; round++) {
266
+ seen = await wanted();
267
+ n += await readHeld(ctx, handle, rec, { log, takeover: true, waitMs: 20_000 });
268
+ if ((await wanted()) !== seen) continue;
269
+ // Finished: the run is over. An arrival that came in just now saw this run
270
+ // still going and started none, so it is looked for once more after.
271
+ await kv.delete(`pushrun/${handle}`).catch(() => {});
272
+ if ((await wanted()) === seen) return n;
273
+ await kv.set(`pushrun/${handle}`, String(Date.now())).catch(() => {});
274
+ }
275
+ await kv.delete(`pushrun/${handle}`).catch(() => {});
276
+ return n;
277
+ }
278
+
279
+ // A request from an app, answered by the account's facade over its copy.
280
+ async function answerForAccount(request, url, ctx, tok, rec, log) {
281
+ const handle = tok.handle;
282
+ const at = await reachAccount(ctx, handle, rec, { log });
283
+ if (at.skipped) return json(503, { error: `this account cannot be reached now: ${at.skipped}` });
284
+ if (!at.inCopy) {
285
+ const made = await ensureCopy(ctx, handle, rec, log);
286
+ if (!made.ok) return json(made.status === 409 ? 503 : made.status, { error: made.why });
287
+ at.inCopy = true;
288
+ }
289
+ const token = { token: 'gateway', scope: tok.scope };
290
+ const writes = request.method !== 'GET' && request.method !== 'HEAD';
291
+ const b = await bridge(request, url);
292
+ if (!writes) {
293
+ const store = await readingStore(ctx, handle, log);
294
+ let actingP = null;
295
+ const acting = () => (actingP ||= (async () => {
296
+ const s = stateAndLease(ctx, handle, at, { log });
297
+ await s.store.load();
298
+ const a = await actingAgent(at, s.store, s.lease, { log });
299
+ if (a.skipped) throw new Error(a.skipped);
300
+ return a;
301
+ })());
302
+ const agent = readingAgent(at, store, acting, log);
303
+ if (!agent) return json(503, { error: 'this account\'s copy holds no account' });
304
+ const api = facade(agent, token, log, await vapidKeys(ctx.mastoKv), pushContact(ctx));
305
+ const handled = await api.handle(b.req, b.res, url.pathname, url);
306
+ // New mail read in after this answer, for the app's next check.
307
+ if (/^\/api\/v[12]\/(timelines\/home|notifications)$/u.test(url.pathname)) {
308
+ ctx.waitUntil?.(readHeldForApp(ctx, handle, rec, { log }).catch((e) => log(`app @${handle}: held mail: ${e.message}`)));
309
+ }
310
+ return handled ? b.response() : new Response(JSON.stringify({ error: 'not found' }), { status: 404 });
311
+ }
312
+ // Acting: this app outranks a browser sitting open, as one device does another.
313
+ const unlock = await lockCopy(ctx.copyKv, handle);
314
+ if (!unlock) return json(503, { error: 'this account is busy; try again' });
315
+ let lease = null;
316
+ try {
317
+ const s = stateAndLease(ctx, handle, at, { log });
318
+ if (!await s.lease.acquire() && !await s.lease.takeover()) return json(503, { error: 'could not take this account over; try again' });
319
+ lease = s.lease;
320
+ const store = s.store;
321
+ await store.load();
322
+ const agent = await actingAgent(at, store, lease, { log });
323
+ if (agent.skipped) return json(503, { error: agent.skipped });
324
+ const api = facade(agent, token, log, await vapidKeys(ctx.mastoKv), pushContact(ctx));
325
+ const handled = await api.handle(b.req, b.res, url.pathname, url);
326
+ await store.commit();
327
+ if (url.pathname === '/api/v1/push/subscription') await notePushSubs(ctx, handle, store);
328
+ agent.publisher.stopPolls?.();
329
+ await ctx.noteNext?.(handle, nextDue(store)).catch(() => {});
330
+ reading.delete(handle);
331
+ return handled ? b.response() : new Response(JSON.stringify({ error: 'not found' }), { status: 404 });
332
+ } finally {
333
+ // Done acting, however it went: the lease goes back, so a browser sitting
334
+ // open takes it up again rather than staying a viewer for its whole term.
335
+ await lease?.release().catch(() => {});
336
+ await unlock();
337
+ }
338
+ }
339
+
340
+ // ---- the routes ----
341
+
342
+ export async function routeMastoGateway(request, pathname, ctx, deps, { log = console.log } = {}) {
343
+ const isOurs = pathname.startsWith('/api/v1/') || pathname.startsWith('/api/v2/') || pathname.startsWith('/oauth/')
344
+ || pathname === '/api/authorize' || pathname === '/.well-known/oauth-authorization-server';
345
+ if (!isOurs) return null;
346
+ if (request.method === 'OPTIONS') return { status: 204, headers: CORS, body: null };
347
+ const out = await route(request, pathname, ctx, deps, log);
348
+ if (out instanceof Response) {
349
+ const headers = Object.fromEntries(out.headers);
350
+ return { status: out.status, headers: { ...headers, ...CORS }, body: out.status === 204 ? null : await out.text() };
351
+ }
352
+ return { ...out, headers: { ...(out.headers || {}), ...CORS } };
353
+ }
354
+
355
+ async function route(request, pathname, ctx, deps, log) {
356
+ const kv = ctx.mastoKv;
357
+ const url = new URL(request.url);
358
+ const origin = url.origin;
359
+ const host = ctx.host || url.host;
360
+ if (!kv || !ctx.copyKv) return json(501, { error: 'this gateway does not sign in apps' });
361
+
362
+ if (pathname === '/api/v1/instance') return json(200, instanceV1(host));
363
+ if (pathname === '/api/v2/instance') return json(200, instanceV2(host, origin, (await vapidKeys(kv)).publicKey));
364
+ if (['/api/v1/custom_emojis', '/api/v1/instance/peers', '/api/v1/instance/rules'].includes(pathname) && request.method === 'GET') return json(200, []);
365
+ if (pathname === '/.well-known/oauth-authorization-server') {
366
+ return json(200, {
367
+ issuer: `${origin}/`, authorization_endpoint: `${origin}/oauth/authorize`, token_endpoint: `${origin}/oauth/token`,
368
+ revocation_endpoint: `${origin}/oauth/revoke`, registration_endpoint: `${origin}/api/v1/apps`,
369
+ scopes_supported: ['read', 'write', 'follow', 'push', 'profile'], response_types_supported: ['code'],
370
+ grant_types_supported: ['authorization_code', 'client_credentials'],
371
+ token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post', 'none'],
372
+ code_challenge_methods_supported: ['S256'],
373
+ });
374
+ }
375
+
376
+ // Apps sign in only where the gateway can keep accounts running.
377
+ if (!ctx.keeperWebId) return json(501, { error: 'Mastodon apps cannot sign in at this server yet' });
378
+
379
+ // An app registering, for every account here.
380
+ if (pathname === '/api/v1/apps' && request.method === 'POST') {
381
+ const body = await formOf(request);
382
+ const redirectUris = parseRedirects(body.redirect_uris);
383
+ const app = {
384
+ clientId: rand(24), clientSecret: rand(32), name: String(body.client_name || 'an app').slice(0, 200),
385
+ website: String(body.website || '').slice(0, 500), redirectUris, scopes: String(body.scopes || 'read'), createdAt: Date.now(),
386
+ };
387
+ await putJson(kv, `app/${app.clientId}`, app);
388
+ return json(200, {
389
+ id: app.clientId, name: app.name, website: app.website || null, client_id: app.clientId, client_secret: app.clientSecret,
390
+ redirect_uri: redirectUris.join(' ') || 'urn:ietf:wg:oauth:2.0:oob', redirect_uris: redirectUris, scopes: app.scopes.split(/\s+/u),
391
+ vapid_key: (await vapidKeys(kv)).publicKey,
392
+ });
393
+ }
394
+
395
+ // The app sends the person here; the sign-in page does the rest.
396
+ if (pathname === '/oauth/authorize' && request.method === 'GET') {
397
+ return { status: 302, headers: { location: `${SIGNIN_PAGE}${url.search}`, 'cache-control': 'no-store' }, body: null };
398
+ }
399
+
400
+ if (pathname === '/api/authorize' && request.method === 'GET') {
401
+ const clientId = url.searchParams.get('client_id');
402
+ if (clientId) {
403
+ const app = await appOf(kv, clientId);
404
+ if (!app) return json(404, { error: 'this app has not registered here' });
405
+ const redirect = url.searchParams.get('redirect_uri') || '';
406
+ if (redirect !== 'urn:ietf:wg:oauth:2.0:oob' && !app.redirectUris.includes(redirect)) {
407
+ return json(400, { error: 'this app asked to be sent somewhere it did not register' });
408
+ }
409
+ let where = null; try { where = redirect.startsWith('urn:') ? null : new URL(redirect).host || null; } catch { /* a custom scheme */ }
410
+ return json(200, { name: app.name, website: app.website || null, sendsTo: where });
411
+ }
412
+ const handle = handleOf(url.searchParams.get('address'), host);
413
+ const rec = handle ? await ctx.lookup(handle) : null;
414
+ if (!rec?.openedAt || rec.movedTo || rec.closedAt) return json(404, { error: 'there is no account with that address here' });
415
+ if (keptBefore(ctx, rec)) return json(503, { error: MOVING });
416
+ if (!rec.keeper) {
417
+ return json(409, { error: 'this account works only while FediPod is open. To use it from an app, turn on '
418
+ + '"Keep my account running while I\'m away" on its manage page first.' });
419
+ }
420
+ return json(200, { handle, webId: rec.webId });
421
+ }
422
+
423
+ // The pod sign-in, proved: a code for the app.
424
+ if (pathname === '/api/authorize' && request.method === 'POST') {
425
+ const webid = await deps.verifyPodToken(request, pathname, ctx.verifier);
426
+ if (!webid) return json(401, { error: 'sign in at your pod first' });
427
+ const body = await request.clone().json().catch(() => ({}));
428
+ const handle = handleOf(body.address, host);
429
+ const rec = handle ? await ctx.lookup(handle) : null;
430
+ if (keptBefore(ctx, rec)) return json(503, { error: MOVING });
431
+ if (!usable(ctx, rec)) return json(404, { error: 'there is no account kept running with that address here' });
432
+ if (rec.webId !== webid) return json(403, { error: `you signed in as ${webid}, and this account belongs to ${rec.webId}` });
433
+ const app = await appOf(kv, body.client_id);
434
+ if (!app) return json(400, { error: 'this app has not registered here' });
435
+ const redirect = body.redirect_uri || 'urn:ietf:wg:oauth:2.0:oob';
436
+ if (redirect !== 'urn:ietf:wg:oauth:2.0:oob' && !app.redirectUris.includes(redirect)) {
437
+ return json(400, { error: 'this app asked to be sent somewhere it did not register' });
438
+ }
439
+ const made = await ensureCopy(ctx, handle, rec, log);
440
+ if (!made.ok) return json(made.status === 409 ? 503 : made.status, { error: made.why });
441
+ const code = rand(24);
442
+ await putJson(kv, `code/${sha(code)}`, {
443
+ handle, webId: webid, clientId: app.clientId, redirect, scope: String(body.scope || 'read'), at: Date.now(),
444
+ ...(body.code_challenge ? { challenge: body.code_challenge, challengeMethod: body.code_challenge_method || 'plain' } : {}),
445
+ });
446
+ log(`app "${app.name}" signed in to @${handle}`);
447
+ if (redirect === 'urn:ietf:wg:oauth:2.0:oob') return json(200, { code });
448
+ const to = new URL(redirect);
449
+ to.searchParams.set('code', code);
450
+ if (body.state) to.searchParams.set('state', body.state);
451
+ return json(200, { redirect: to.href });
452
+ }
453
+
454
+ if (pathname === '/oauth/token' && request.method === 'POST') {
455
+ const body = await formOf(request);
456
+ const basic = /^Basic (.+)$/u.exec(request.headers.get('authorization') || '')?.[1];
457
+ if (basic) { const [id, secret] = Buffer.from(basic, 'base64').toString().split(':'); body.client_id ||= decodeURIComponent(id); body.client_secret ||= decodeURIComponent(secret || ''); }
458
+ const app = await appOf(kv, body.client_id);
459
+ if (!app) return json(401, { error: 'invalid_client' });
460
+ const secretOk = body.client_secret && body.client_secret.length === app.clientSecret.length
461
+ && crypto.timingSafeEqual(Buffer.from(body.client_secret), Buffer.from(app.clientSecret));
462
+ const mint = async (handle, scope, webId = null) => {
463
+ const token = rand(32);
464
+ await putJson(kv, `token/${sha(token)}`, { handle, webId, scope, clientId: app.clientId, at: Date.now() });
465
+ return json(200, { access_token: token, token_type: 'Bearer', scope, created_at: Math.floor(Date.now() / 1000) });
466
+ };
467
+ if (body.grant_type === 'client_credentials') {
468
+ if (!secretOk) return json(401, { error: 'invalid_client' });
469
+ return mint(null, String(body.scope || 'read'));
470
+ }
471
+ if (body.grant_type && body.grant_type !== 'authorization_code') return json(400, { error: 'unsupported_grant_type' });
472
+ const key = `code/${sha(body.code || '')}`;
473
+ const rec = await getJson(kv, key);
474
+ if (!rec || Date.now() - rec.at > CODE_TTL_MS || rec.clientId !== app.clientId
475
+ || (body.redirect_uri && body.redirect_uri !== rec.redirect)) return json(400, { error: 'invalid_grant' });
476
+ // A code made with a challenge is only redeemed with its answer; one made
477
+ // without needs the app's secret.
478
+ if (rec.challenge ? !MastoApi.provesCode(rec, body.code_verifier) : !secretOk) return json(400, { error: 'invalid_grant' });
479
+ await kv.delete(key);
480
+ return mint(rec.handle, rec.scope, rec.webId);
481
+ }
482
+
483
+ if (pathname === '/oauth/revoke' && request.method === 'POST') {
484
+ const body = await formOf(request);
485
+ if (body.token) await kv.delete(`token/${sha(body.token)}`);
486
+ return json(200, {});
487
+ }
488
+
489
+ // Everything else needs a token.
490
+ const bearer = /^Bearer (.+)$/u.exec(request.headers.get('authorization') || '')?.[1];
491
+ const tok = bearer ? await getJson(kv, `token/${sha(bearer)}`) : null;
492
+ if (!tok || Date.now() - tok.at > TOKEN_TTL_MS) {
493
+ // A page of this site asking with a token the gateway never gave out is
494
+ // FediPod's own client in a browser whose worker is not answering yet:
495
+ // told to try again, not that it is signed out.
496
+ if (request.headers.get('sec-fetch-site') === 'same-origin') return json(503, { error: 'FediPod is starting in this browser; try again' });
497
+ return json(401, { error: 'The access token is invalid' });
498
+ }
499
+ if (pathname === '/api/v1/apps/verify_credentials') {
500
+ const app = await appOf(kv, tok.clientId);
501
+ return json(200, { name: app?.name || 'an app', website: app?.website || null, scopes: String(tok.scope).split(/\s+/u) });
502
+ }
503
+ if (!tok.handle) return json(403, { error: 'this token names no account' });
504
+ const rec = await ctx.lookup(tok.handle);
505
+ // The token is for the owner who signed in; an address given up and taken
506
+ // by somebody else is not theirs.
507
+ if (rec && rec.webId === tok.webId && keptBefore(ctx, rec)) return json(503, { error: MOVING });
508
+ if (!usable(ctx, rec) || rec.webId !== tok.webId) return json(401, { error: 'this account is not kept running here any more' });
509
+ return answerForAccount(request, url, ctx, tok, rec, log);
510
+ }
@@ -4,6 +4,8 @@
4
4
  // public read, stamps noteOpened from the relay, counts noteReceived after
5
5
  // a forwarded delivery, and hands /api/open, /api/pause and /api/close here.
6
6
 
7
+ import { keptNow } from './copy.mjs';
8
+
7
9
  // ---- accounts that go quiet -------------------------------------------------
8
10
  //
9
11
  // The gateway holds no mail: every delivery it takes is two writes into the
@@ -107,7 +109,7 @@ export const closedAnswer = (headers = {}, why = 'this address is closed') => ({
107
109
  // The owner of a row, proved the way the relay proves them: a pod token whose
108
110
  // WebID is the row's, or lives on the row's pod for a row from before WebIDs
109
111
  // were recorded.
110
- async function provedOwner(request, pathname, ctx, rec, { j, verifyPodToken, webidUnderPod }) {
112
+ export async function provedOwner(request, pathname, ctx, rec, { j, verifyPodToken, webidUnderPod }) {
111
113
  const webid = await verifyPodToken(request, pathname, ctx.verifier);
112
114
  if (!webid) return { error: j(401, { error: 'a Solid-OIDC token proving the pod is required' }) };
113
115
  const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
@@ -141,7 +143,10 @@ export async function routeQuietApi(request, pathname, ctx, deps) {
141
143
  const before = await closedState(ctx, handle, rec);
142
144
  if (before.closed) return j(410, { error: 'this address is closed', closedAt: before.closedAt, closedBy: before.closedBy });
143
145
  rec = await noteOpened(ctx, handle, rec, { always: true });
144
- return j(200, { ok: true, handle, ...await accountState(ctx, handle, rec) });
146
+ // Whom the app would name if its owner lets the gateway act for them while
147
+ // it is closed, and whether they have (held-mail.mjs, keeper.mjs).
148
+ return j(200, { ok: true, handle, ...await accountState(ctx, handle, rec),
149
+ keeper: ctx.keeperWebId || null, kept: keptNow(ctx, rec), keptBy: rec.keeper?.webId || null });
145
150
  }
146
151
 
147
152
  // The owner pausing their own account, or lifting the pause they set.
@@ -0,0 +1,89 @@
1
+ // relay-extras.mjs — the relay's single request, and what the relay notices
2
+ // about the requests it carries.
3
+ //
4
+ // relayOne(): sends one request a browser-run agent has already signed, with
5
+ // exactly the headers that were signed. It forwards a signature and cannot
6
+ // make one: a POST must carry one, by this account's own key, over the body
7
+ // its Digest names.
8
+ //
9
+ // announced(): a relayed delivery of the account's own activity — a post, an
10
+ // edit, a pin, an Accept, a Delete, an Undo — means its documents have just
11
+ // changed, so the copies the edge holds of them are out of date; the front
12
+ // purges them rather than serving the old ones for the rest of their hold.
13
+ //
14
+ // overLimit(): how many requests one account may have the relay carry. Counted
15
+ // per running instance, which bounds a runaway agent or a stolen token without
16
+ // any shared state; a real account sends nowhere near it.
17
+
18
+ import crypto from 'node:crypto';
19
+ import { readCapped, safeFetch } from '../shared/safefetch.mjs';
20
+
21
+ const RELAY_MAX_BODY = 1024 * 1024;
22
+ const RELAY_TIMEOUT_MS = 8_000;
23
+ // The only headers a relayed request may carry to the remote server. Host
24
+ // comes from the URL; the user agent from safeFetch.
25
+ const RELAY_HEADERS = new Set(['date', 'digest', 'signature', 'content-type', 'accept']);
26
+ const keyIdOf = (signature) => (/keyId="([^"]+)"/.exec(signature || '') || [])[1] || null;
27
+
28
+ export async function relayOne(item, rec, fetchImpl) {
29
+ const url = String(item?.url || '');
30
+ const method = String(item?.method || 'POST').toUpperCase();
31
+ if (method !== 'GET' && method !== 'POST') return { url, status: 0, error: 'method must be GET or POST' };
32
+ let u;
33
+ try { u = new URL(url); } catch { return { url, status: 0, error: 'not a URL' }; }
34
+ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && process.env.AP_ALLOW_PRIVATE_TARGETS === '1')) {
35
+ return { url, status: 0, error: 'https only' };
36
+ }
37
+ const headers = {};
38
+ for (const [k, v] of Object.entries(item?.headers || {})) {
39
+ const name = k.toLowerCase();
40
+ if (RELAY_HEADERS.has(name) && typeof v === 'string') headers[name] = v;
41
+ }
42
+ const body = method === 'POST' ? String(item?.body ?? '') : undefined;
43
+ if (body !== undefined && Buffer.byteLength(body) > RELAY_MAX_BODY) return { url, status: 0, error: 'body too large' };
44
+ const keyId = keyIdOf(headers.signature);
45
+ if (method === 'POST' && !keyId) return { url, status: 0, error: 'a delivery must be signed' };
46
+ if (keyId && !keyId.startsWith(rec.actorUrl + '#')) return { url, status: 0, error: "signed with a key that is not this account's" };
47
+ if (headers.digest) {
48
+ const want = 'SHA-256=' + crypto.createHash('sha256').update(body || '').digest('base64');
49
+ if (headers.digest !== want) return { url, status: 0, error: 'digest does not match the body' };
50
+ }
51
+ try {
52
+ const res = await safeFetch(url, { method, headers, body, signal: AbortSignal.timeout(RELAY_TIMEOUT_MS) }, fetchImpl);
53
+ const out = { url, method, status: res.status };
54
+ // The far server asking to be left alone has to reach the agent that will
55
+ // do the asking again. Without this the browser build could not honour a
56
+ // Retry-After at all — every delivery it makes goes through here — and fell
57
+ // back to its own ladder against a server that had already said how long.
58
+ const retryAfter = res.headers.get('retry-after');
59
+ if (retryAfter) out.retryAfter = retryAfter;
60
+ if (method === 'GET') {
61
+ out.contentType = res.headers.get('content-type') || null;
62
+ out.body = await readCapped(res, RELAY_MAX_BODY);
63
+ }
64
+ return out;
65
+ } catch (e) { return { url, method, status: 0, error: e.message }; }
66
+ }
67
+
68
+ const WINDOW_MS = 10 * 60 * 1000;
69
+ const PER_WINDOW = 600;
70
+ const counts = new Map(); // handle → { since, n }
71
+
72
+ export function announced(items, actorUrl) {
73
+ return items.some((it) => {
74
+ if (String(it?.method || 'POST').toUpperCase() !== 'POST') return false;
75
+ try {
76
+ const a = JSON.parse(String(it?.body ?? ''));
77
+ return a?.actor === actorUrl && typeof a.type === 'string';
78
+ } catch { return false; }
79
+ });
80
+ }
81
+
82
+ export function overLimit(handle, n, now = Date.now()) {
83
+ const c = counts.get(handle);
84
+ const fresh = !c || now - c.since > WINDOW_MS ? { since: now, n: 0 } : c;
85
+ fresh.n += n;
86
+ counts.set(handle, fresh);
87
+ if (counts.size > 10_000) counts.clear();
88
+ return fresh.n > PER_WINDOW;
89
+ }