fedipod 0.14.1 → 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/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/remote.mjs CHANGED
@@ -29,6 +29,12 @@ export { mintCredential, discoverTokenEndpoint, revokeCredentialViaAccount };
29
29
  // opened — and the callers' existing retry paths take it from there.
30
30
  // The parser is shared with the outbound path; see lib/safefetch.mjs.
31
31
  import { retryAfterMs, readCapped } from './safefetch.mjs';
32
+ import { linkTargets, REL } from './links.mjs';
33
+
34
+ // A pod whose access rules are ACP policies, not WAC authorizations. This
35
+ // agent writes WAC; over an ACP resource that would be noise where the pod's
36
+ // real rules used to be, so it stops instead.
37
+ const ACP_NS = 'http://www.w3.org/ns/solid/acp#';
32
38
 
33
39
  // The inbox is public-Append, so the listing's size is in other people's
34
40
  // hands; reading it whole must still have a ceiling.
@@ -82,6 +88,11 @@ export class RemotePod {
82
88
  this.pausedUntil = 0;
83
89
  this.probeCount = 0;
84
90
  this.log = log;
91
+ // Where each resource's access control lives, as the pod itself said. WAC
92
+ // forbids working it out from the resource's own URL, so it is asked for
93
+ // and remembered rather than assembled.
94
+ this.aclUrls = new Map();
95
+ this.aclFlavour = null; // null until the first write asks what this pod speaks
85
96
  // A fronted identity advertises ids on a shared domain but writes to the
86
97
  // pod. run-agent installs the fronted→pod mapping here, so every request
87
98
  // built from an advertised id lands on the pod — one choke point, and
@@ -153,9 +164,63 @@ export class RemotePod {
153
164
  method: 'PUT', headers: { 'content-type': contentType }, body,
154
165
  });
155
166
  if (res.status >= 400) throw new Error(`PUT ${url} → ${res.status}`);
167
+ // Writing a document is usually the step before setting its access, and
168
+ // the answer to the write already says where that lives. Taking it here
169
+ // spares the extra request the ACL write would otherwise make.
170
+ this.noteAclLink(url, res);
156
171
  return res;
157
172
  }
158
173
 
174
+ /** Remember an access-control location the pod volunteered on a response. */
175
+ noteAclLink(url, res) {
176
+ if (this.aclUrls.has(url)) return;
177
+ const [acl] = linkTargets(res?.headers?.get?.('link'), REL.acl, url);
178
+ if (acl) this.aclUrls.set(url, acl);
179
+ }
180
+
181
+ /**
182
+ * Where this resource's access control lives. The pod says so on any
183
+ * response about the resource; a pod that says nothing is taken to keep it
184
+ * at the usual suffix, which is what every server this runs against does.
185
+ */
186
+ async aclUrlFor(targetUrl) {
187
+ const known = this.aclUrls.get(targetUrl);
188
+ if (known) return known;
189
+ try {
190
+ const res = await this.fetch(targetUrl, { method: 'HEAD' });
191
+ this.noteAclLink(targetUrl, res);
192
+ } catch { /* unreachable or no such resource yet: the suffix below */ }
193
+ const resolved = this.aclUrls.get(targetUrl) || targetUrl + '.acl';
194
+ this.aclUrls.set(targetUrl, resolved);
195
+ return resolved;
196
+ }
197
+
198
+ /**
199
+ * Whether writing a WAC document here is meaningful. Asked once per pod, on
200
+ * the first access-control write. A pod that answers with ACP policies is
201
+ * left alone: replacing them with authorizations it does not read would take
202
+ * away the rules actually protecting it.
203
+ */
204
+ async aclWritable(aclUrl) {
205
+ if (this.aclFlavour !== null) return this.aclFlavour;
206
+ this.aclFlavour = true;
207
+ try {
208
+ const res = await this.fetch(aclUrl, { headers: { accept: 'text/turtle' } });
209
+ if (res.status < 300) {
210
+ const g = $rdf.graph();
211
+ $rdf.parse(await res.text(), g, aclUrl, 'text/turtle');
212
+ const acp = g.statements.some(st => st.predicate.value.startsWith(ACP_NS)
213
+ || st.object.value.startsWith(ACP_NS));
214
+ if (acp) {
215
+ this.aclFlavour = false;
216
+ this.log('this pod states access as ACP policies, which this agent does not write — '
217
+ + 'its access rules are left exactly as they are, and nothing here is published private');
218
+ }
219
+ }
220
+ } catch { /* absent, unreadable or unparsable: WAC is what we write */ }
221
+ return this.aclFlavour;
222
+ }
223
+
159
224
  async putJson(url, obj, contentType = 'application/activity+json') {
160
225
  return this.put(url, JSON.stringify(obj), contentType);
161
226
  }
@@ -173,6 +238,11 @@ export class RemotePod {
173
238
 
174
239
  async delete(url) {
175
240
  protectedFromDeletion(url);
241
+ // The pattern list above knows the usual name for an access-control
242
+ // document. One the pod named itself is just as fatal to remove.
243
+ for (const acl of this.aclUrls.values()) {
244
+ if (acl === url) throw new Error(`refusing to DELETE an access-control document: ${url}`);
245
+ }
176
246
  const res = await this.fetch(url, { method: 'DELETE' });
177
247
  return res.status < 400 || res.status === 404;
178
248
  }
@@ -238,8 +308,8 @@ export class RemotePod {
238
308
  // $rdf.sym() also throws on an illegal IRI, so a pod URL with something odd
239
309
  // in it fails here rather than silently producing a document that means
240
310
  // something else.
241
- aclDoc(targetUrl, publicModes, { appendAgents = [] } = {}) {
242
- const url = targetUrl + '.acl';
311
+ aclDoc(targetUrl, publicModes, { appendAgents = [], aclUrl = null } = {}) {
312
+ const url = aclUrl || targetUrl + '.acl';
243
313
  const doc = $rdf.sym(url);
244
314
  const target = $rdf.sym(targetUrl);
245
315
  const g = $rdf.graph();
@@ -263,7 +333,9 @@ export class RemotePod {
263
333
  }
264
334
 
265
335
  async setAcl(targetUrl, publicModes, opts = {}) {
266
- return this.put(targetUrl + '.acl', this.aclDoc(targetUrl, publicModes, opts), 'text/turtle');
336
+ const url = await this.aclUrlFor(targetUrl);
337
+ if (!await this.aclWritable(url)) return null;
338
+ return this.put(url, this.aclDoc(targetUrl, publicModes, { ...opts, aclUrl: url }), 'text/turtle');
267
339
  }
268
340
 
269
341
  // The WebID profile advertises the actor as an account:
@@ -295,9 +367,53 @@ export class RemotePod {
295
367
  const stale = g.statementsMatching(actor, FOAF('accountName'), null, doc)
296
368
  .filter(st => st.object.value !== accountName);
297
369
  if (!missing.length && !stale.length) return false;
370
+ // A patch touches these statements and nothing else. Rewriting the whole
371
+ // profile re-serialises statements that are not ours — the OIDC issuer
372
+ // among them — and a server is entitled to refuse a write that would.
373
+ const deletes = stale.map(st => [ st.subject, st.predicate, st.object ]);
374
+ if (await this.patchDocument(docUrl, missing, deletes)) return true;
298
375
  for (const st of stale) g.remove(st);
299
376
  for (const [s, p, o] of missing) g.add(s, p, o, doc);
300
377
  await this.put(docUrl, $rdf.serialize(doc, g, docUrl, 'text/turtle'), 'text/turtle');
301
378
  return true;
302
379
  }
380
+
381
+ /**
382
+ * An N3 Patch of exactly these statements, or false when the pod will not
383
+ * take one and the caller should write the document instead.
384
+ *
385
+ * The statements are serialised by rdflib; only the wrapper naming what is
386
+ * being patched is assembled here, because N3's braces have no rdflib form.
387
+ */
388
+ n3Patch(docUrl, inserts, deletes) {
389
+ const block = (triples) => {
390
+ const g = $rdf.graph();
391
+ for (const [s, p, o] of triples) g.add(s, p, o);
392
+ return $rdf.serialize(null, g, docUrl, 'application/n-triples').trim();
393
+ };
394
+ const clauses = [];
395
+ if (deletes.length) clauses.push(` solid:deletes { ${block(deletes)} }`);
396
+ if (inserts.length) clauses.push(` solid:inserts { ${block(inserts)} }`);
397
+ return `@prefix solid: <http://www.w3.org/ns/solid/terms#>.\n`
398
+ + `<> a solid:InsertDeletePatch;\n${clauses.join(';\n')}.\n`;
399
+ }
400
+
401
+ async patchDocument(docUrl, inserts, deletes) {
402
+ let res;
403
+ try {
404
+ res = await this.fetch(docUrl, {
405
+ method: 'PATCH',
406
+ headers: { 'content-type': 'text/n3' },
407
+ body: this.n3Patch(docUrl, inserts, deletes),
408
+ });
409
+ } catch {
410
+ return false; // no PATCH on this transport at all
411
+ }
412
+ if (res.status < 300) return true;
413
+ // The pod cannot patch. Anything else — a 409 saying what we meant to
414
+ // remove is not there any more — is a real answer, and rewriting the whole
415
+ // document over the top of it would destroy whatever changed it.
416
+ if (res.status === 405 || res.status === 415 || res.status === 501) return false;
417
+ throw new Error(`PATCH ${docUrl} → ${res.status}`);
418
+ }
303
419
  }
package/lib/setup.mjs CHANGED
@@ -11,6 +11,7 @@
11
11
  import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import { pathToFileURL } from 'node:url';
14
+ import * as $rdf from 'rdflib';
14
15
 
15
16
  import { createAccountWithPod as realCreateAccount } from './account.mjs';
16
17
  import { mintCredential as realMint } from './remote.mjs';
@@ -20,8 +21,48 @@ import { rootOf, recordLastUsed, writeJsonAtomic } from './home.mjs';
20
21
  import { insecureUrlReason } from './safefetch.mjs';
21
22
  import { CURRENT_LAYOUT, isCurrent } from './migrate.mjs';
22
23
 
24
+ const SOLID = $rdf.Namespace('http://www.w3.org/ns/solid/terms#');
25
+
23
26
  export const STEPS = ['account', 'credential', 'bootstrap', 'connect', 'publish', 'verify'];
24
27
 
28
+ // Read-only pre-check on the pod behind an existing-pod setup, before a
29
+ // credential is minted or the first write is attempted. Two failures it turns
30
+ // from a bare "PUT … → 401" into a plain sentence:
31
+ // 1. the pod host is unreachable — no account should be built on a pod that
32
+ // is not there;
33
+ // 2. the pod's WebID document declares no OIDC issuer. A pod verifies a token
34
+ // by dereferencing its WebID and looking for solid:oidcIssuer; an empty or
35
+ // issuer-less profile makes it reject every write with 401, so setup would
36
+ // stall on the first one. Only judged when the document is publicly
37
+ // readable — a protected one cannot be read here and is left to bootstrap.
38
+ export async function checkPodUsable(pod, { webId, fetch: doFetch = globalThis.fetch } = {}) {
39
+ const base = pod.endsWith('/') ? pod : pod + '/';
40
+ const wid = webId || new URL('profile/card#me', base).href;
41
+ const cardUrl = wid.split('#')[0];
42
+
43
+ let root;
44
+ try { root = await doFetch(base, { headers: { accept: 'text/turtle' } }); }
45
+ catch (e) { return { ok: false, error: `${base} could not be reached (${e.message}). Check the address and that the server is running.` }; }
46
+ if (root.status === 404) return { ok: false, error: `there is no pod at ${base} — the server answered 404.` };
47
+ if (root.status >= 500) return { ok: false, error: `the pod host at ${base} is not responding (HTTP ${root.status}).` };
48
+
49
+ let card;
50
+ try { card = await doFetch(cardUrl, { headers: { accept: 'text/turtle' } }); }
51
+ catch (e) { return { ok: false, error: `the pod's WebID document ${cardUrl} could not be read (${e.message}).` }; }
52
+ if (card.status === 404) return { ok: false, error: `the pod has no WebID document at ${cardUrl} — the identity it needs is missing.` };
53
+ if (card.status >= 500) return { ok: false, error: `the pod host is not serving ${cardUrl} (HTTP ${card.status}).` };
54
+ if (card.status === 401 || card.status === 403) return { ok: true }; // cannot read unauthenticated; leave it to bootstrap
55
+
56
+ const body = await card.text().catch(() => '');
57
+ const g = $rdf.graph();
58
+ try { $rdf.parse(body, g, cardUrl, (card.headers.get('content-type') || 'text/turtle').split(';')[0].trim()); }
59
+ catch { return { ok: false, error: `the pod's WebID document ${cardUrl} could not be parsed as RDF.` }; }
60
+ if (!g.each($rdf.sym(wid), SOLID('oidcIssuer'), null).length) {
61
+ return { ok: false, error: `the pod's WebID document ${cardUrl} declares no OIDC issuer, so the pod will reject every write. It looks empty or incomplete — restore it, or set up with a fresh pod.` };
62
+ }
63
+ return { ok: true };
64
+ }
65
+
25
66
  export const credentialPath = (home) => path.join(home, 'credential.json');
26
67
  export const hasCredential = (home) => fs.existsSync(credentialPath(home));
27
68
 
@@ -118,6 +159,7 @@ export function preflight({ mode, pod, issuer, podName, handle, kind }) {
118
159
  export async function runSetup({ home, agent, answers, run, deps = {}, log = () => {} }) {
119
160
  const createAccount = deps.createAccountWithPod || realCreateAccount;
120
161
  const mint = deps.mintCredential || realMint;
162
+ const checkPod = deps.checkPodUsable || checkPodUsable;
121
163
 
122
164
  const at = (key) => run.steps.find(s => s.key === key);
123
165
  const begin = (key) => { at(key).state = 'running'; };
@@ -166,14 +208,21 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
166
208
  }
167
209
  done('account', pod);
168
210
  } else {
211
+ // A pod you bring must be there and carry a usable WebID before a
212
+ // credential is minted against it — no account on a pod that is not
213
+ // reachable, and no silent 401 later on a pod whose profile is empty.
214
+ const usable = await checkPod(pod);
215
+ if (!usable.ok) throw new Error(usable.error);
169
216
  skip('account', 'using the pod you already have');
170
217
  }
171
218
 
172
219
  // --- credential: the point of no return, and the durability boundary ---
220
+ let resumeWebId = null;
173
221
  if (resuming) {
174
222
  const rec = JSON.parse(fs.readFileSync(credPath, 'utf8'));
175
223
  pod = rec.remotePod;
176
224
  root = rec.root;
225
+ resumeWebId = rec.webId || null;
177
226
  skip('credential', `already minted — ${credPath}`);
178
227
  } else {
179
228
  begin('credential');
@@ -212,6 +261,13 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
212
261
 
213
262
  // --- provision the pod and bring federation up ---
214
263
  begin('bootstrap');
264
+ // Resuming skipped the checks the fresh paths ran, and the credential it
265
+ // carries may be the one bound to a pod whose profile is empty — the write
266
+ // below would 401. Catch it here, as this step, with a sentence.
267
+ if (resuming) {
268
+ const ready = await checkPod(pod, { webId: resumeWebId || undefined });
269
+ if (!ready.ok) throw new Error(ready.error);
270
+ }
215
271
  await agent.bootstrap({ handle, name: name || handle, root, kind, approveJoins, summary, icon, gateway });
216
272
  done('bootstrap');
217
273
 
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 };