fedipod 0.16.0 → 0.17.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/lib/admin.mjs CHANGED
@@ -485,9 +485,19 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
485
485
  // C2S (ActivityPub §6) carries its own authentication — a Solid-OIDC
486
486
  // DPoP proof or the facade's bearer — so the dk-token gate does not
487
487
  // stand in front of it. The Host/Origin firewall above still does.
488
- if (p === '/ap/outbox' || p === '/ap/actor') {
488
+ if (p === '/ap/outbox' || p === '/ap/actor' || p === '/ap/inbox') {
489
489
  if (await c2s.handle(req, res, p, url)) return;
490
490
  }
491
+ // Where a client looks first to find out how to sign in (RFC 8414), and
492
+ // in front of the door for the same reason C2S is: a client that has to
493
+ // be handed a secret before it can ask how to sign in cannot set itself
494
+ // up at all. It names endpoints and nothing else, the endpoints it names
495
+ // refuse without a password anyway, and the host and origin firewall
496
+ // above still decides who gets this far.
497
+ if (p === '/.well-known/oauth-authorization-server') {
498
+ const scheme = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
499
+ return json(res, 200, masto.authorizationServerMetadata(`${scheme}://${req.headers.host}`));
500
+ }
491
501
  if (atDoor && gate(req, res)) return;
492
502
  if (p === '/api/v1/streaming/health') {
493
503
  res.writeHead(200, { 'content-type': 'text/plain' }); res.end('OK'); return;
package/lib/c2s.mjs CHANGED
@@ -5,14 +5,26 @@
5
5
  // SAME helper the facade and admin surfaces use — this module re-implements
6
6
  // no persistence and no delivery, so one write path stays one.
7
7
  //
8
- // GETs are redirects: the pod's documents are the canonical actor and outbox,
9
- // and a second renderer here would only drift from them.
8
+ // GETs on the actor and outbox are redirects: the pod's documents are the
9
+ // canonical ones, and a second renderer here would only drift from them.
10
+ //
11
+ // The inbox is the exception, and has to be. Deliveries land in a container on
12
+ // the pod which the drain empties as it handles each item, so reading that
13
+ // container tells the owner only what has not been dealt with yet. What was
14
+ // actually received is whole only in the archive, so §5.2's "the owner can
15
+ // read their own inbox" is served from there, by this agent, to the owner
16
+ // alone.
10
17
 
11
18
  import * as social from './social.mjs';
12
19
  import * as wire from './wire.mjs';
13
20
 
14
21
  const MAX_BODY = 512 * 1024; // same ceiling the inbox drain enforces
15
22
 
23
+ // How many archived items one page of the inbox will read. A page is one
24
+ // month, and a month with more than this is served short rather than costing
25
+ // the pod an unbounded read; the log says when that happened.
26
+ const MAX_INBOX_PAGE = 500;
27
+
16
28
  // §6 names activities; anything else with a type is an object to wrap.
17
29
  const ACTIVITY_TYPES = new Set([
18
30
  'Create', 'Update', 'Delete', 'Follow', 'Like', 'Announce', 'Undo',
@@ -60,14 +72,102 @@ export class C2S {
60
72
  return iri ? this.store.getStatuses().find((s) => s.noteId === iri) : null;
61
73
  }
62
74
 
75
+ /** The months the archive holds, newest first. One container listing. */
76
+ async archiveMonths() {
77
+ const archive = this.agent.intake?.archive;
78
+ if (!archive) return [];
79
+ const { names } = await archive.list('');
80
+ return (names || [])
81
+ .map((n) => n.replace(/\/$/u, ''))
82
+ .filter((n) => /^\d{4}-\d{2}$/u.test(n))
83
+ .sort()
84
+ .reverse();
85
+ }
86
+
87
+ /**
88
+ * The owner's own inbox, §5.2. Paged by month because that is how the
89
+ * archive is stored, so a page costs one listing and a read per item and no
90
+ * page is dearer for another month being large.
91
+ */
92
+ async sendInbox(res, url) {
93
+ const id = `${this.urls.base}ap/inbox`;
94
+ const page = url?.searchParams?.get('page') || null;
95
+ let months;
96
+ try {
97
+ months = await this.archiveMonths();
98
+ } catch (e) {
99
+ return this.send(res, 502, { error: `the archive could not be read: ${e.message}` });
100
+ }
101
+
102
+ if (!page) {
103
+ if (!months.length && this.store.getConfig()?.archiveInbox === false) {
104
+ this.log('inbox read: nothing to show — this identity does not keep what it receives');
105
+ }
106
+ return this.send(res, 200, {
107
+ '@context': wire.AS_CTX, id, type: 'OrderedCollection',
108
+ ...(months.length ? { first: `${id}?page=${months[0]}` } : { orderedItems: [] }),
109
+ });
110
+ }
111
+ if (!/^\d{4}-\d{2}$/u.test(page)) {
112
+ return this.send(res, 400, { error: 'page names a month, written 2026-09' });
113
+ }
114
+
115
+ const archive = this.agent.intake?.archive;
116
+ let names = [];
117
+ try {
118
+ // The trailing slash matters: without it this names a document, not the
119
+ // container, and a pod answers about the wrong thing.
120
+ ({ names } = await archive.list(`${page}/`));
121
+ } catch (e) {
122
+ return this.send(res, 502, { error: `the archive could not be read: ${e.message}` });
123
+ }
124
+ const files = (names || []).filter((n) => n.endsWith('.json')).sort();
125
+ if (files.length > MAX_INBOX_PAGE) {
126
+ this.log(`inbox read: ${page} holds ${files.length} items; serving the first ${MAX_INBOX_PAGE}`);
127
+ }
128
+ const kept = [];
129
+ for (const file of files.slice(0, MAX_INBOX_PAGE)) {
130
+ // Read as written: these records are JSON-LD, and the default read asks
131
+ // turtle-first, which a server is free to answer with turtle.
132
+ const read = await archive.read(`${page}/${file}`, { accept: '*/*' });
133
+ if (!read?.ok || !read.body) continue;
134
+ try {
135
+ const record = JSON.parse(read.body);
136
+ // The record wraps the bytes as they arrived; the activity is those
137
+ // bytes, not a retelling of them.
138
+ kept.push({ at: record.receivedAt || '', activity: JSON.parse(record.raw) });
139
+ } catch { /* a record that will not parse is not one that can be served */ }
140
+ }
141
+ kept.sort((a, b) => String(b.at).localeCompare(String(a.at)));
142
+ const older = months.filter((m) => m < page)[0] || null;
143
+ return this.send(res, 200, {
144
+ '@context': wire.AS_CTX,
145
+ id: `${id}?page=${page}`,
146
+ type: 'OrderedCollectionPage',
147
+ partOf: id,
148
+ ...(older ? { next: `${id}?page=${older}` } : {}),
149
+ orderedItems: kept.map((k) => k.activity),
150
+ });
151
+ }
152
+
63
153
  async handle(req, res, pathname, url) { // eslint-disable-line no-unused-vars
64
- if (pathname !== '/ap/outbox' && pathname !== '/ap/actor') return false;
154
+ if (pathname !== '/ap/outbox' && pathname !== '/ap/actor' && pathname !== '/ap/inbox') return false;
65
155
  if (req.method === 'OPTIONS') {
66
- res.writeHead(204, { allow: 'GET, POST, OPTIONS' }); res.end(); return true;
156
+ res.writeHead(204, { allow: pathname === '/ap/inbox' ? 'GET, OPTIONS' : 'GET, POST, OPTIONS' });
157
+ res.end(); return true;
67
158
  }
68
159
  if (!this.agent.configured() || !this.urls) {
69
160
  return this.send(res, 409, { error: 'agent not configured' });
70
161
  }
162
+ if (pathname === '/ap/inbox') {
163
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
164
+ return this.send(res, 405, { error: "deliveries go to this actor's inbox on the pod, which the "
165
+ + 'actor document names; this address is the owner reading their own' });
166
+ }
167
+ const reader = await this.auth(req, pathname);
168
+ if (!reader.ok) return this.send(res, reader.status, { error: reader.error });
169
+ return this.sendInbox(res, url);
170
+ }
71
171
  if (req.method === 'GET' || req.method === 'HEAD') {
72
172
  // The pod's copy is the document; send the reader there.
73
173
  const target = pathname === '/ap/actor' ? this.urls.actor : this.urls.outbox;
package/lib/mastoapi.mjs CHANGED
@@ -16,6 +16,7 @@ import { sanitizeHtml, followsNeedApproval, publicHandle } from './wire.mjs';
16
16
  import { authorOf } from './intake.mjs';
17
17
  import { profileUrl, postUrl } from './bskyfeed.mjs';
18
18
  import { Push } from './webpush.mjs';
19
+ import { safeFetch, readCapped } from './safefetch.mjs';
19
20
 
20
21
  // What an attachment is allowed to BE. Anything else is stored as bytes, which
21
22
  // a browser downloads rather than runs.
@@ -62,6 +63,8 @@ const AUTHZ_WINDOW_MS = 60_000;
62
63
  const AUTHZ_MAX_ATTEMPTS = 5;
63
64
  const CODE_TTL_MS = 5 * 60_000; // an authorization code is short-lived
64
65
  const MAX_APPS = 200; // registered third-party clients, capped
66
+ const CLIENT_DOC_TTL_MS = 10 * 60_000; // how long a fetched client document is trusted
67
+ const CLIENT_DOC_MAX = 64 * 1024; // it names a client; it is not a payload
65
68
 
66
69
  export class MastoApi {
67
70
  constructor({ agent, log = console.log, allowed = null, scheme = null, embedded = false }) {
@@ -127,7 +130,91 @@ export class MastoApi {
127
130
  // said, and only that client, presenting its secret, can exchange it for a
128
131
  // bearer. A redirect back to this agent's own origin keeps the local flow.
129
132
  apps() { return this.store.read('oauth-apps.json', []); }
133
+ /**
134
+ * What a client needs to know before it can sign in, at the address RFC 8414
135
+ * puts it. The actor carries the same two endpoints; a client that looks
136
+ * here first finds everything rather than the minimum.
137
+ *
138
+ * `none` among the authentication methods is what says a client keeping no
139
+ * secret is welcome, which is the whole of what a browser app needs to hear.
140
+ */
141
+ authorizationServerMetadata(origin) {
142
+ const at = (p) => `${origin.replace(/\/$/u, '')}${p}`;
143
+ return {
144
+ issuer: origin.replace(/\/$/u, ''),
145
+ authorization_endpoint: at('/oauth/authorize'),
146
+ token_endpoint: at('/oauth/token'),
147
+ revocation_endpoint: at('/oauth/revoke'),
148
+ registration_endpoint: at('/api/v1/apps'),
149
+ response_types_supported: [ 'code' ],
150
+ grant_types_supported: [ 'authorization_code' ],
151
+ code_challenge_methods_supported: [ 'S256', 'plain' ],
152
+ token_endpoint_auth_methods_supported: [ 'client_secret_post', 'none' ],
153
+ scopes_supported: [ 'read', 'write', 'follow', 'push' ],
154
+ };
155
+ }
156
+
130
157
  findApp(clientId) { return clientId ? this.apps().find(a => a.clientId === clientId) || null : null; }
158
+
159
+ /**
160
+ * A client that publishes its own metadata document is named by that
161
+ * document's URL and registers nothing here: the document says who it is
162
+ * and where it may be sent back to. Such a client keeps no secret, so it
163
+ * always proves itself with a challenge instead.
164
+ *
165
+ * The fetch is the guarded one — a client id is a URL a stranger chose, and
166
+ * an unguarded fetch of it would ask this machine to reach wherever they
167
+ * pointed.
168
+ */
169
+ async resolveClientDocument(clientId) {
170
+ if (!/^https:\/\//iu.test(String(clientId || ''))) return null; // cleartext is refused
171
+ this.clientDocs = this.clientDocs || new Map();
172
+ const seen = this.clientDocs.get(clientId);
173
+ if (seen && Date.now() - seen.at < CLIENT_DOC_TTL_MS) return seen.client;
174
+ let doc;
175
+ try {
176
+ const res = await safeFetch(clientId, { headers: { accept: 'application/json' } });
177
+ if (res.status >= 400) { this.log(`client document ${clientId} → ${res.status}`); return null; }
178
+ doc = JSON.parse(await readCapped(res, CLIENT_DOC_MAX));
179
+ } catch (e) {
180
+ this.log(`client document ${clientId} could not be read: ${e.message}`);
181
+ return null;
182
+ }
183
+ // It must claim to be itself: a document naming some other id would let
184
+ // one client borrow another's name.
185
+ if (doc?.client_id !== clientId) {
186
+ this.log(`client document ${clientId} names ${doc?.client_id ?? 'nothing'} — refused`);
187
+ return null;
188
+ }
189
+ const redirectUris = [].concat(doc.redirect_uris || []).filter((u) => typeof u === 'string');
190
+ if (!redirectUris.length) { this.log(`client document ${clientId} names no redirect — refused`); return null; }
191
+ const client = {
192
+ clientId, redirectUris,
193
+ name: String(doc.client_name || clientId).slice(0, 200),
194
+ scopes: 'read write follow',
195
+ };
196
+ this.clientDocs.set(clientId, { at: Date.now(), client });
197
+ return client;
198
+ }
199
+
200
+ /**
201
+ * Whether a redirect the client asked for is one it published.
202
+ *
203
+ * A native client listens on whatever port the machine gave it, so it can
204
+ * only publish the loopback address without one (RFC 8252). The port is
205
+ * therefore not part of the match there, and nowhere else.
206
+ */
207
+ static redirectMatches(published, asked) {
208
+ if (published === asked) return true;
209
+ try {
210
+ const a = new URL(published);
211
+ const b = new URL(asked);
212
+ const loopback = (h) => h === '127.0.0.1' || h === '[::1]' || h === 'localhost';
213
+ if (!loopback(a.hostname) || a.hostname !== b.hostname) return false;
214
+ return a.protocol === b.protocol
215
+ && a.pathname.replace(/\/$/u, '') === b.pathname.replace(/\/$/u, '');
216
+ } catch { return false; }
217
+ }
131
218
  registerApp({ name, website, redirectUris, scopes }) {
132
219
  const app = {
133
220
  clientId: crypto.randomBytes(16).toString('hex'),
@@ -143,13 +230,39 @@ export class MastoApi {
143
230
  // A short-lived, single-use authorization code for a registered client, kept
144
231
  // apart from masto-tokens.json so the code is NOT a bearer until it is
145
232
  // exchanged with the client secret.
146
- mintCode({ clientId, redirectUri, scope }) {
233
+ mintCode({ clientId, redirectUri, scope, challenge = null, challengeMethod = null }) {
147
234
  const code = crypto.randomBytes(24).toString('hex');
148
235
  const now = Date.now();
149
236
  const kept = this.store.read('oauth-codes.json', []).filter(c => now - c.createdAt < CODE_TTL_MS);
150
- this.store.write('oauth-codes.json', [...kept, { code, clientId, redirectUri, scope, createdAt: now }].slice(-50));
237
+ this.store.write('oauth-codes.json', [ ...kept, {
238
+ code, clientId, redirectUri, scope, createdAt: now,
239
+ // What the client promised to prove when it comes back for the token.
240
+ // A client that cannot keep a secret — anything running in a browser —
241
+ // has this instead, and it is the only thing standing between a stolen
242
+ // code and a token.
243
+ ...(challenge ? { challenge, challengeMethod: challengeMethod || 'plain' } : {}),
244
+ } ].slice(-50));
151
245
  return code;
152
246
  }
247
+ /**
248
+ * Whether this verifier is the one the challenge was made from (RFC 7636).
249
+ * Length is checked because a short verifier is guessable, which is the
250
+ * whole thing this is here to prevent.
251
+ */
252
+ static provesCode(rec, verifier) {
253
+ const v = String(verifier || '');
254
+ if (v.length < 43 || v.length > 128) return false;
255
+ if ((rec.challengeMethod || 'plain') === 'S256') {
256
+ const made = crypto.createHash('sha256').update(v).digest('base64url');
257
+ const given = Buffer.from(made);
258
+ const known = Buffer.from(String(rec.challenge));
259
+ return given.length === known.length && crypto.timingSafeEqual(given, known);
260
+ }
261
+ const given = Buffer.from(v);
262
+ const known = Buffer.from(String(rec.challenge));
263
+ return given.length === known.length && crypto.timingSafeEqual(given, known);
264
+ }
265
+
153
266
  consumeCode(code) {
154
267
  const now = Date.now();
155
268
  const all = this.store.read('oauth-codes.json', []);
@@ -620,19 +733,35 @@ export class MastoApi {
620
733
  if (req.method === 'POST') { body = await readBody(req); params = new URLSearchParams(body); }
621
734
  const redirect = params.get('redirect_uri') || '';
622
735
  const app = this.findApp(params.get('client_id') || '');
736
+ // A client that published its own metadata document needs no
737
+ // registration here: the document is its name and says where it may be
738
+ // sent back to.
739
+ const doc = app ? null : await this.resolveClientDocument(params.get('client_id') || '');
623
740
  // A REGISTERED client is always the third-party flow — its code is
624
741
  // bound to it and exchanged with its secret — even when its redirect
625
742
  // points back at this very agent (a web client served from our own
626
743
  // origin registers itself exactly like a phone app does). The local
627
744
  // code-is-the-token flow is only for the built-in client, which never
628
745
  // registers.
629
- const external = !!app;
630
- const client = { name: app?.name || null, redirect, scope: params.get('scope') || 'read' };
631
- if (external) {
746
+ const external = !!app || !!doc;
747
+ const client = { name: app?.name || doc?.name || null, redirect, scope: params.get('scope') || 'read' };
748
+ if (app) {
632
749
  if (!app.redirectUris.includes(redirect)) {
633
750
  this.log(`authorize refused: redirect_uri "${redirect}" not registered for ${app.clientId}`);
634
751
  return send(400, { error: 'redirect_uri was not registered by this client' });
635
752
  }
753
+ } else if (doc) {
754
+ if (!doc.redirectUris.some((u) => MastoApi.redirectMatches(u, redirect))) {
755
+ this.log(`authorize refused: redirect_uri "${redirect}" is not one ${doc.clientId} published`);
756
+ return send(400, { error: 'redirect_uri is not one this client published' });
757
+ }
758
+ // It keeps no secret, so the challenge is the only thing that will
759
+ // stand between its code and a token. Refuse now rather than mint a
760
+ // code nothing can prove.
761
+ if (!params.get('code_challenge')) {
762
+ this.log(`authorize refused: ${doc.clientId} keeps no secret and offered no challenge`);
763
+ return send(400, { error: 'a client identified by its own document must send a code_challenge' });
764
+ }
636
765
  } else if (!this.redirectAllowed(redirect)) {
637
766
  this.log(`authorize refused: redirect_uri "${redirect}" is not this agent`);
638
767
  return send(400, { error: 'redirect_uri must be an address of this agent' });
@@ -640,7 +769,11 @@ export class MastoApi {
640
769
  if (req.method === 'POST') {
641
770
  if (this.rateLimited()) {
642
771
  this.log('authorize rate limited');
643
- return sendLoginForm(res, params, 'too many attempts wait a minute', client);
772
+ // 429, not 401: a client that reads this as a wrong password will
773
+ // ask the person to type it again, which is the one thing that
774
+ // cannot help. Retry-After says how long the wait actually is.
775
+ return sendLoginForm(res, params, 'too many attempts — wait a minute', client,
776
+ 429, { 'retry-after': String(Math.ceil(AUTHZ_WINDOW_MS / 1000)) });
644
777
  }
645
778
  if (!pw || !checkPassword(pw, body.password || '')) {
646
779
  return sendLoginForm(res, params, 'wrong password — try again', client);
@@ -670,7 +803,9 @@ export class MastoApi {
670
803
  }
671
804
  // External clients get a bound code; the local flow keeps code==token.
672
805
  const code = external
673
- ? this.mintCode({ clientId: app.clientId, redirectUri: redirect, scope: client.scope })
806
+ ? this.mintCode({ clientId: (app || doc).clientId, redirectUri: redirect, scope: client.scope,
807
+ challenge: params.get('code_challenge') || null,
808
+ challengeMethod: params.get('code_challenge_method') || null })
674
809
  : this.mintToken();
675
810
  if (!redirect || redirect === 'urn:ietf:wg:oauth:2.0:oob') return send(200, { code });
676
811
  const target = new URL(redirect);
@@ -685,6 +820,44 @@ export class MastoApi {
685
820
  // A registered third-party client exchanges its bound code, proving its
686
821
  // secret, for a real bearer — the code alone is not a token.
687
822
  const app = this.findApp(body.client_id || '');
823
+ // A client named by its own document keeps no secret at all, so the
824
+ // challenge is the whole of its proof. The code carries the document's
825
+ // URL as the client it was bound to.
826
+ if (!app && body.code_verifier && /^https:\/\//iu.test(String(body.client_id || ''))) {
827
+ const rec = this.consumeCode(body.code || '');
828
+ if (!rec || rec.clientId !== body.client_id
829
+ || (body.redirect_uri && rec.redirectUri !== body.redirect_uri)) {
830
+ this.log('token refused: code is not a live authorization for that client document');
831
+ return send(400, { error: 'invalid_grant' });
832
+ }
833
+ if (!rec.challenge || !MastoApi.provesCode(rec, body.code_verifier)) {
834
+ this.log('token refused: the verifier does not answer the challenge this code was made with');
835
+ return send(400, { error: 'invalid_grant' });
836
+ }
837
+ return send(200, { access_token: this.mintToken(), token_type: 'Bearer',
838
+ scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
839
+ ...(this.urls?.actor ? { activitypub_actor_id: this.urls.actor } : {}) });
840
+ }
841
+ // A client that runs in a browser cannot keep a secret, so it proves it
842
+ // is the same caller that asked instead: it sends the verifier for the
843
+ // challenge it presented at authorize (RFC 7636). Sending a verifier is
844
+ // what says which of the two flows this is.
845
+ if (app && body.code_verifier) {
846
+ const rec = this.consumeCode(body.code || '');
847
+ if (!rec || rec.clientId !== app.clientId || (body.redirect_uri && rec.redirectUri !== body.redirect_uri)) {
848
+ this.log('token refused: code is not a live authorization for this client');
849
+ return send(400, { error: 'invalid_grant' });
850
+ }
851
+ // A code minted without a challenge cannot be redeemed with one: that
852
+ // would let anyone holding a stolen code invent the proof for it.
853
+ if (!rec.challenge || !MastoApi.provesCode(rec, body.code_verifier)) {
854
+ this.log('token refused: the verifier does not answer the challenge this code was made with');
855
+ return send(400, { error: 'invalid_grant' });
856
+ }
857
+ return send(200, { access_token: this.mintToken(), token_type: 'Bearer',
858
+ scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
859
+ ...(this.urls?.actor ? { activitypub_actor_id: this.urls.actor } : {}) });
860
+ }
688
861
  if (app && body.client_secret) {
689
862
  const given = Buffer.from(String(body.client_secret));
690
863
  const known = Buffer.from(app.clientSecret);
@@ -695,8 +868,18 @@ export class MastoApi {
695
868
  this.log('token refused: code is not a live authorization for this client');
696
869
  return send(400, { error: 'invalid_grant' });
697
870
  }
871
+ // A challenge, once made, is not optional: without this a client could
872
+ // present one and then skip past it with the secret alone.
873
+ if (rec.challenge && !MastoApi.provesCode(rec, body.code_verifier)) {
874
+ this.log('token refused: this code was made with a challenge and the verifier does not answer it');
875
+ return send(400, { error: 'invalid_grant' });
876
+ }
698
877
  return send(200, { access_token: this.mintToken(), token_type: 'Bearer',
699
- scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000) });
878
+ scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
879
+ // Which actor the token acts for. A Mastodon client ignores it; an
880
+ // ActivityPub API client needs it, and asking for it separately
881
+ // would mean a second round trip before it knows who it is.
882
+ ...(this.urls?.actor ? { activitypub_actor_id: this.urls.actor } : {}) });
700
883
  }
701
884
  // Local flow: the code IS the token, minted by /oauth/authorize after the
702
885
  // password gate. Minting one here for an unrecognised code handed a
@@ -707,7 +890,9 @@ export class MastoApi {
707
890
  this.log('token refused: code is not a live authorization');
708
891
  return send(400, { error: 'invalid_grant' });
709
892
  }
710
- return send(200, { access_token: body.code, token_type: 'Bearer', scope: body.scope || 'read write follow push', created_at: Math.floor(Date.now() / 1000) });
893
+ return send(200, { access_token: body.code, token_type: 'Bearer',
894
+ scope: body.scope || 'read write follow push', created_at: Math.floor(Date.now() / 1000),
895
+ ...(this.urls?.actor ? { activitypub_actor_id: this.urls.actor } : {}) });
711
896
  }
712
897
  if (pathname === '/oauth/revoke' && req.method === 'POST') {
713
898
  // It used to answer 200 and keep the token, so logging out of a client
@@ -1554,7 +1739,7 @@ const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c =>
1554
1739
  const parseRedirects = (v) => (Array.isArray(v) ? v : String(v || '').split(/\s+/))
1555
1740
  .map(s => s.trim()).filter(Boolean);
1556
1741
 
1557
- function sendLoginForm(res, params, error = '', client = null) {
1742
+ function sendLoginForm(res, params, error = '', client = null, status = null, headers = {}) {
1558
1743
  const hidden = [...params.entries()].filter(([k]) => k !== 'password')
1559
1744
  .map(([k, v]) => `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}">`).join('\n');
1560
1745
  // Name what is asking, so the owner approves a client they can see rather
@@ -1569,7 +1754,8 @@ function sendLoginForm(res, params, error = '', client = null) {
1569
1754
  + `${where ? `, sending the authorization to <code>${escapeHtml(where)}</code>` : ''}.</p>`
1570
1755
  + `<p>Scope: <code>${escapeHtml(client.scope || 'read')}</code>. Enter the agent password to allow it.</p>`;
1571
1756
  }
1572
- res.writeHead(error ? 401 : 200, { 'content-type': 'text/html; charset=utf-8' });
1757
+ res.writeHead(status || (error ? 401 : 200),
1758
+ { 'content-type': 'text/html; charset=utf-8', ...headers });
1573
1759
  res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
1574
1760
  <title>FediPod — authorize</title>
1575
1761
  <style>:root{color-scheme:light dark;font-size:125%;--heading:#1a4f8a}
package/lib/publisher.mjs CHANGED
@@ -23,7 +23,7 @@ const AGENT_VERSION = JSON.parse(fs.readFileSync(
23
23
 
24
24
  export class Publisher {
25
25
  constructor({ config, remote, local, store, deliverer, publicKeyPem, assertionKey = null, log = console.log,
26
- probeFetch = null, resolveMention = null, privateOnPod = true,
26
+ probeFetch = null, resolveMention = null, privateOnPod = true, clientOrigin = null,
27
27
  }) {
28
28
  this.config = config;
29
29
  this.remote = remote;
@@ -32,6 +32,10 @@ export class Publisher {
32
32
  this.deliverer = deliverer;
33
33
  this.publicKeyPem = publicKeyPem;
34
34
  this.assertionKey = assertionKey; // Ed25519 public half, multibase; null = no proofs
35
+ // Where this identity's client surface answers, when that is an address a
36
+ // stranger can reach. Null on a laptop, where the surface is on loopback
37
+ // and advertising it to the world would name somewhere nobody can go.
38
+ this.clientOrigin = clientOrigin;
35
39
  // A fronted identity (config.gateway.frontActor) advertises its ids on a
36
40
  // shared domain; the map tells RemotePod where each writes on the pod.
37
41
  const publicBase = config.gateway?.frontActor
@@ -88,6 +92,13 @@ export class Publisher {
88
92
  pendingFollowing: priv ? urls.pendingFollowing : null,
89
93
  blocked: priv ? urls.blocked : null,
90
94
  inbox: gwActive ? gw.url : null,
95
+ // The agent's own outbox endpoint, where it is reachable: a client
96
+ // following the actor must arrive somewhere that will take a write.
97
+ outbox: this.clientOrigin ? `${this.clientOrigin}ap/outbox` : null,
98
+ // How a client-to-server client finds the way in with nothing configured
99
+ // by hand. Advertised only where the surface is publicly reachable.
100
+ oauthAuthorize: this.clientOrigin ? `${this.clientOrigin}oauth/authorize` : null,
101
+ oauthToken: this.clientOrigin ? `${this.clientOrigin}oauth/token` : null,
91
102
  });
92
103
  const surface = crypto.createHash('sha256').update(JSON.stringify({
93
104
  actor: actorDoc, handle: this.config.handle, host,
package/lib/storage.mjs CHANGED
@@ -61,9 +61,15 @@ export class HttpStorage {
61
61
  return { notModified: false, names, etag: res.headers.get('etag') };
62
62
  }
63
63
 
64
- async read(p, { etag } = {}) {
64
+ // `accept` is for callers reading something that is RDF but is wanted as it
65
+ // was written: asking turtle-first for a JSON-LD document gets turtle back,
66
+ // because the server is entitled to convert between two RDF syntaxes.
67
+ async read(p, { etag, accept } = {}) {
65
68
  const res = await this.fetchImpl(this._url(p), {
66
- headers: { accept: 'text/turtle, application/json;q=0.9, */*;q=0.8', ...(etag ? { 'if-none-match': etag } : {}) },
69
+ headers: {
70
+ accept: accept || 'text/turtle, application/json;q=0.9, */*;q=0.8',
71
+ ...(etag ? { 'if-none-match': etag } : {}),
72
+ },
67
73
  });
68
74
  if (res.status === 304) return { ok: true, notModified: true, status: 304, body: null, etag };
69
75
  if (res.status >= 400) return { ok: false, notModified: false, status: res.status, body: null, etag: null };
package/lib/wire.mjs CHANGED
@@ -108,7 +108,7 @@ export const assertionKeyId = (urls) => urls.actor + '#ed25519-key';
108
108
  export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null, movedTo = null, kind = 'person',
109
109
  approveJoins = false, summary = null, icon = null, image = null, fields = [],
110
110
  webId = null, aliases = [], moderators = null, pendingFollowers = null, pendingFollowing = null,
111
- blocked = null, inbox = null }) {
111
+ blocked = null, inbox = null, outbox = null, oauthAuthorize = null, oauthToken = null }) {
112
112
  // manuallyApprovesFollowers is NOT in the base AS2 context, so it is declared
113
113
  // inline exactly as Mastodon declares it — and only when we actually use it.
114
114
  // It is what makes a client show "Request to follow" rather than "Follow" and
@@ -188,8 +188,23 @@ export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null
188
188
  inbox: inbox || urls.inbox,
189
189
  // Every Mastodon actor publishes one. With a single actor per pod ours is
190
190
  // just the inbox, but its absence is the non-standard thing.
191
- endpoints: { sharedInbox: inbox || urls.inbox },
192
- outbox: urls.outbox,
191
+ //
192
+ // The two OAuth entries are how a client-to-server client finds its way in
193
+ // without being told anything by hand (ActivityPub 4.1). They appear only
194
+ // where this identity's client surface answers on an address a stranger
195
+ // can reach, which is why the caller supplies them rather than this
196
+ // building them from the pod URL.
197
+ endpoints: {
198
+ sharedInbox: inbox || urls.inbox,
199
+ ...(oauthAuthorize ? { oauthAuthorizationEndpoint: oauthAuthorize } : {}),
200
+ ...(oauthToken ? { oauthTokenEndpoint: oauthToken } : {}),
201
+ },
202
+ // Where a client sends what this actor writes, which the protocol says is
203
+ // this same address. The pod holds the collection and answers reads of it;
204
+ // a client-to-server write has to reach the agent, and the pod cannot take
205
+ // one. So where the agent is reachable it is named here, and a read of it
206
+ // is sent straight on to the pod's own document.
207
+ outbox: outbox || urls.outbox,
193
208
  // The pinned posts, as the collection other servers read when they show
194
209
  // this profile. Mastodon's term, declared the way Mastodon declares it.
195
210
  featured: urls.featured,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod",
3
- "version": "0.16.0",
3
+ "version": "0.17.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",
package/run-agent.mjs CHANGED
@@ -338,6 +338,10 @@ export class Agent {
338
338
  // Whether the fediverse tree is on the pod at all, so the ACL check does
339
339
  // not probe for something the default layout keeps on local disk.
340
340
  privateOnPod: !cred.privateRoot,
341
+ // Inside a pod server the client surface answers on the pod's own
342
+ // origin, so it can be advertised. Standalone it is on loopback, and
343
+ // naming it in a world-readable actor would send clients nowhere.
344
+ clientOrigin: this.embedded ? this.urls.base : null,
341
345
  });
342
346
  // Intake is constructed even for viewers — its signed fetchAP powers
343
347
  // search/deref; start() (draining) is active-only.