fedipod 0.14.0 → 0.16.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 +32 -4
- package/lib/embed.mjs +6 -1
- package/lib/front-core.mjs +27 -4
- package/lib/intake.mjs +21 -2
- package/lib/links.mjs +35 -0
- package/lib/remote.mjs +119 -3
- package/lib/setup.mjs +56 -0
- package/package.json +1 -1
- package/scripts/pin-solid-oidc.mjs +68 -0
- package/web/admin/setup/index.html +20 -4
- package/web/admin/setup/setup.js +50 -10
- package/web/front/admin.html +21 -17
- package/web/front/new-account.html +2 -2
- package/web/front/run.html +19 -16
- package/web/front/solid-oidc-client.js +6 -0
- package/web/front/solid-client-authn.bundle.js +0 -2
package/lib/admin.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import { identityHomes, rootOf, tildify, defaultProfile, writeJsonAtomic } from
|
|
|
27
27
|
import { copyPrivateHalf, isCurrent, CURRENT_LAYOUT } from './migrate.mjs';
|
|
28
28
|
import { normalizeImport, IMPORT_KINDS } from './import.mjs';
|
|
29
29
|
import { insecureUrlReason } from './safefetch.mjs';
|
|
30
|
-
import { newRun, preflight, runSetup, setupInputError, hasCredential } from './setup.mjs';
|
|
30
|
+
import { newRun, preflight, runSetup, setupInputError, hasCredential, credentialPath } from './setup.mjs';
|
|
31
31
|
import { portFree, freePortFrom } from './ports.mjs';
|
|
32
32
|
import { claimDirectory, yieldDirectory } from './directory.mjs';
|
|
33
33
|
import { localFetch } from './localapi.mjs';
|
|
@@ -112,7 +112,7 @@ const AGENT_VERSION = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package
|
|
|
112
112
|
// /shutdown is here because stopping an agent that was never set up is exactly
|
|
113
113
|
// the case it exists for; it is in LOCAL_ONLY_POSTS below, so it still answers
|
|
114
114
|
// only to this machine.
|
|
115
|
-
const OPEN_POSTS = new Set(['/block', '/unblock', '/setup', '/setup/check', '/shutdown']);
|
|
115
|
+
const OPEN_POSTS = new Set(['/block', '/unblock', '/setup', '/setup/check', '/setup/reset', '/shutdown']);
|
|
116
116
|
|
|
117
117
|
// The page the other server's redirect lands on. Self-contained on purpose:
|
|
118
118
|
// the browser arrives here from somewhere else, and nothing may load from
|
|
@@ -147,11 +147,11 @@ function callbackPage(ok, msg) {
|
|
|
147
147
|
// server there is no such process and no such machine: identities come from
|
|
148
148
|
// the server's own configuration, so these are not there to be found.
|
|
149
149
|
const EMBEDDED_CUT = new Set(['/profiles', '/shutdown', '/new-actor', '/start-actor',
|
|
150
|
-
'/state-move', '/setup', '/setup/check']);
|
|
150
|
+
'/state-move', '/setup', '/setup/check', '/setup/reset']);
|
|
151
151
|
// AP_ALLOWED_HOSTS may name a tailnet host or a reverse-proxy domain. The
|
|
152
152
|
// fediverse is welcome there; creating accounts and editing the record is for
|
|
153
153
|
// whoever is sitting at the machine.
|
|
154
|
-
const LOCAL_ONLY_POSTS = new Set(['/setup', '/setup/check', '/config', '/new-actor', '/start-actor', '/shutdown', '/state-move', '/atproto/connect', '/fediacct/connect', '/fediacct/disconnect', '/fediacct', '/gateway', '/alias', '/import', '/update']);
|
|
154
|
+
const LOCAL_ONLY_POSTS = new Set(['/setup', '/setup/check', '/setup/reset', '/config', '/new-actor', '/start-actor', '/shutdown', '/state-move', '/atproto/connect', '/fediacct/connect', '/fediacct/disconnect', '/fediacct', '/gateway', '/alias', '/import', '/update']);
|
|
155
155
|
// The identity itself. Changing any of these means a different actor at a
|
|
156
156
|
// different address, which is a new setup, not an edit.
|
|
157
157
|
const PERMANENT_CONFIG = ['handle', 'remotePod', 'issuer', 'root', 'kind'];
|
|
@@ -848,6 +848,34 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
848
848
|
}
|
|
849
849
|
// ---- setup, driven by the page at /admin/setup/ ----
|
|
850
850
|
case '/setup/check': return json(res, 200, preflight(body));
|
|
851
|
+
// Discard a credential that never finished setup, so the account and
|
|
852
|
+
// pod can be entered again. The credential a CSS server mints is shown
|
|
853
|
+
// once, so a setup that stops after the mint (a wrong pod answers 401
|
|
854
|
+
// to the first write) leaves the form in "finish" mode with no way to
|
|
855
|
+
// re-enter what was wrong. This removes it locally and reopens the full
|
|
856
|
+
// form. It does NOT revoke server-side — that needs the account
|
|
857
|
+
// password (`fedipod revoke-credential`); the old credential is left on
|
|
858
|
+
// the account, revocable from its dashboard.
|
|
859
|
+
case '/setup/reset': {
|
|
860
|
+
if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
|
|
861
|
+
// A working identity is never swapped out this way — that is a
|
|
862
|
+
// teardown (`fedipod retire`), not a half-finished setup.
|
|
863
|
+
if (agent.configured()) {
|
|
864
|
+
return json(res, 409, { error: 'this home holds a working identity — retire it, do not reset' });
|
|
865
|
+
}
|
|
866
|
+
if (setupRun?.phase === 'running') {
|
|
867
|
+
return json(res, 409, { error: 'setup is running — let it finish or stop it first', phase: 'running' });
|
|
868
|
+
}
|
|
869
|
+
const home = agent.home;
|
|
870
|
+
if (!home) return json(res, 500, { error: 'this agent has no AP_HOME to reset' });
|
|
871
|
+
const removed = hasCredential(home);
|
|
872
|
+
if (removed) fs.rmSync(credentialPath(home), { force: true });
|
|
873
|
+
// Drop the pod handle too, so configured() cannot flicker true off a
|
|
874
|
+
// stale in-memory session while the fresh form is filled in.
|
|
875
|
+
agent.remote = null;
|
|
876
|
+
setupRun = null;
|
|
877
|
+
return json(res, 200, { ok: true, removed });
|
|
878
|
+
}
|
|
851
879
|
case '/setup': {
|
|
852
880
|
// A visited page must not be able to navigate this into existence.
|
|
853
881
|
if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
|
package/lib/embed.mjs
CHANGED
|
@@ -210,5 +210,10 @@ export async function startEmbeddedAgent({
|
|
|
210
210
|
]);
|
|
211
211
|
};
|
|
212
212
|
|
|
213
|
-
|
|
213
|
+
// podHome and actorUrl are the identity's own locations on the pod. They are
|
|
214
|
+
// returned rather than rebuilt by the caller so the root name lives here.
|
|
215
|
+
return {
|
|
216
|
+
agent, handle, home, surface, host: authorities.host,
|
|
217
|
+
podHome: urls.home, actorUrl: urls.actor, stop,
|
|
218
|
+
};
|
|
214
219
|
}
|
package/lib/front-core.mjs
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import crypto from 'node:crypto';
|
|
19
19
|
import { handleDelivery } from './gateway-core.mjs';
|
|
20
20
|
import { readCapped } from './safefetch.mjs';
|
|
21
|
+
import { linkTargets, REL } from './links.mjs';
|
|
21
22
|
|
|
22
23
|
// The one WebFinger document, spelled out here rather than imported from
|
|
23
24
|
// wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
|
|
@@ -48,6 +49,24 @@ async function verifyPodToken(request, pathname, verifier) {
|
|
|
48
49
|
// Is this WebID served by the claimed pod? A pod owner's WebID lives on the pod
|
|
49
50
|
// origin — that is the whole proof: a token for a WebID under podHome could
|
|
50
51
|
// only be minted by someone who controls that pod's identity provider.
|
|
52
|
+
// A pod that will not answer must not hold up the person opting in; without
|
|
53
|
+
// an answer the older check stands on its own.
|
|
54
|
+
const OWNER_LOOKUP_MS = 5_000;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Who the pod server says owns the pod. The server that hosts it is the
|
|
58
|
+
* authority on that, so when it answers, its answer decides. A server that
|
|
59
|
+
* says nothing leaves where the WebID lives as the only evidence there is.
|
|
60
|
+
*/
|
|
61
|
+
async function podOwners(podBase, fetchImpl = fetch) {
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetchImpl(podBase, {
|
|
64
|
+
method: 'HEAD', signal: AbortSignal.timeout(OWNER_LOOKUP_MS),
|
|
65
|
+
});
|
|
66
|
+
return linkTargets(res?.headers?.get?.('link'), REL.owner, podBase);
|
|
67
|
+
} catch { return []; }
|
|
68
|
+
}
|
|
69
|
+
|
|
51
70
|
function webidUnderPod(webid, podHome) {
|
|
52
71
|
try { return new URL(webid).origin === new URL(podHome).origin; } catch { return false; }
|
|
53
72
|
}
|
|
@@ -285,7 +304,11 @@ export async function routeFront(request, ctx) {
|
|
|
285
304
|
} catch { return j(400, { error: 'podBase is not a URL' }); }
|
|
286
305
|
const webid = await verifyPodToken(request, pathname, ctx.verifier);
|
|
287
306
|
if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
|
|
288
|
-
|
|
307
|
+
// The pod's own server names its owner when it can. Where it does, that is
|
|
308
|
+
// the proof; where it does not, the WebID must at least live under the pod.
|
|
309
|
+
const owners = await podOwners(podBase, ctx.fetchImpl || fetch);
|
|
310
|
+
const proven = owners.length ? owners.includes(webid) : webid.startsWith(podBase);
|
|
311
|
+
if (!proven) {
|
|
289
312
|
return j(403, { error: 'the token proves a different pod than the one you listed' });
|
|
290
313
|
}
|
|
291
314
|
if (action === 'opt-in') {
|
|
@@ -304,9 +327,9 @@ export async function routeFront(request, ctx) {
|
|
|
304
327
|
return j(400, { error: 'action must be opt-in or opt-out' });
|
|
305
328
|
}
|
|
306
329
|
|
|
307
|
-
// The vendored Solid-OIDC browser library the
|
|
308
|
-
// here because this function owns every path on the domain.
|
|
309
|
-
if (pathname === '/solid-client
|
|
330
|
+
// The vendored Solid-OIDC browser library the /run and /admin pages load —
|
|
331
|
+
// served here because this function owns every path on the domain.
|
|
332
|
+
if (pathname === '/solid-oidc-client.js') {
|
|
310
333
|
if (!ctx.authBundle) return notFound();
|
|
311
334
|
return { status: 200, headers: { 'content-type': 'text/javascript' }, body: ctx.authBundle };
|
|
312
335
|
}
|
package/lib/intake.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import * as $rdf from 'rdflib';
|
|
|
18
18
|
import { USER_AGENT } from './ua.mjs';
|
|
19
19
|
import { PUBLIC } from './wire.mjs';
|
|
20
20
|
import { HTTP_TIMEOUT_MS, readCapped } from './safefetch.mjs';
|
|
21
|
+
import { linkTargets, REL } from './links.mjs';
|
|
21
22
|
import { dropFollower } from './store.mjs';
|
|
22
23
|
|
|
23
24
|
const RDF = $rdf.Namespace('http://www.w3.org/1999/02/22-rdf-syntax-ns#');
|
|
@@ -307,6 +308,24 @@ export class Intake {
|
|
|
307
308
|
}
|
|
308
309
|
}
|
|
309
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Where this pod describes the services it offers. The pod says so on any
|
|
313
|
+
* response about one of its resources; the well-known path is only what a
|
|
314
|
+
* pod that says nothing has always used.
|
|
315
|
+
*/
|
|
316
|
+
async _storageDescriptionUrl() {
|
|
317
|
+
try {
|
|
318
|
+
const head = await fetch(this.urls.base, {
|
|
319
|
+
method: 'HEAD',
|
|
320
|
+
headers: { 'user-agent': USER_AGENT },
|
|
321
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
322
|
+
});
|
|
323
|
+
const [found] = linkTargets(head.headers.get('link'), REL.storageDescription, this.urls.base);
|
|
324
|
+
if (found) return found;
|
|
325
|
+
} catch { /* the well-known path below */ }
|
|
326
|
+
return this.urls.base + '.well-known/solid';
|
|
327
|
+
}
|
|
328
|
+
|
|
310
329
|
async _subscribeOnce() {
|
|
311
330
|
// Reuse a channel we already have rather than asking for another one.
|
|
312
331
|
const saved = this.store.read(CHANNEL_DOC, null);
|
|
@@ -314,13 +333,13 @@ export class Intake {
|
|
|
314
333
|
this._openSocket(saved.receiveFrom, true);
|
|
315
334
|
return;
|
|
316
335
|
}
|
|
317
|
-
const
|
|
336
|
+
const descUrl = await this._storageDescriptionUrl();
|
|
337
|
+
const descRes = await fetch(descUrl, {
|
|
318
338
|
headers: { accept: 'text/turtle', 'user-agent': USER_AGENT },
|
|
319
339
|
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
320
340
|
});
|
|
321
341
|
// The service description is RDF; ask rdflib which subject is the
|
|
322
342
|
// WebSocketChannel2023 service rather than pattern-matching the document.
|
|
323
|
-
const descUrl = this.urls.base + '.well-known/solid';
|
|
324
343
|
const g = $rdf.graph();
|
|
325
344
|
try { $rdf.parse(await readCapped(descRes), g, descUrl, 'text/turtle'); }
|
|
326
345
|
catch (e) { this.wsState = 'unavailable'; this.log(`service description unparsable (${e.message}) — polling only`); return; }
|
package/lib/links.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// links.mjs — reading RFC 8288 Link headers.
|
|
2
|
+
//
|
|
3
|
+
// Solid says where a resource's access control lives, where a storage
|
|
4
|
+
// describes itself, and who owns a storage, by putting a link on the response.
|
|
5
|
+
// Working any of those out from the resource's own URL instead is exactly what
|
|
6
|
+
// the specs tell clients not to do, so this is the one place that reads them.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Every target a Link header gives for one relation, resolved against the URL
|
|
10
|
+
* the header came from. A header may carry several links, and one link may
|
|
11
|
+
* carry several relation names.
|
|
12
|
+
*/
|
|
13
|
+
export function linkTargets(headerValue, rel, baseUrl) {
|
|
14
|
+
if (!headerValue) return [];
|
|
15
|
+
const wanted = String(rel).toLowerCase();
|
|
16
|
+
const out = [];
|
|
17
|
+
// Split on the commas BETWEEN links: one inside a URI has its closing angle
|
|
18
|
+
// bracket still ahead of it, and is left alone.
|
|
19
|
+
for (const part of String(headerValue).split(/,(?![^<]*>)/u)) {
|
|
20
|
+
const link = /^\s*<([^>]*)>\s*(.*)$/u.exec(part);
|
|
21
|
+
if (!link) continue;
|
|
22
|
+
const relParam = /(?:^|;)\s*rel\s*=\s*(?:"([^"]*)"|([^;"\s]+))/iu.exec(link[2]);
|
|
23
|
+
const names = (relParam?.[1] ?? relParam?.[2] ?? '').toLowerCase().split(/\s+/u);
|
|
24
|
+
if (!names.includes(wanted)) continue;
|
|
25
|
+
try { out.push(new URL(link[1], baseUrl).href); } catch { /* not a URL we can follow */ }
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The relations this project follows. */
|
|
31
|
+
export const REL = {
|
|
32
|
+
acl: 'acl',
|
|
33
|
+
storageDescription: 'http://www.w3.org/ns/solid/terms#storageDescription',
|
|
34
|
+
owner: 'http://www.w3.org/ns/solid/terms#owner',
|
|
35
|
+
};
|
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
|
-
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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",
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// pin-solid-oidc.mjs — vendor a pinned copy of the browser Solid-OIDC client
|
|
3
|
+
// for the front pages (run.html, admin.html).
|
|
4
|
+
//
|
|
5
|
+
// Downloads the exact npm tarball, verifies its sha512 against the pin below,
|
|
6
|
+
// extracts the worker-free CORE build (SessionCore — no SharedWorker, right for
|
|
7
|
+
// these one-shot sign-in pages), strips the trailing sourceMappingURL, prepends
|
|
8
|
+
// a provenance banner, and writes web/front/solid-oidc-client.js.
|
|
9
|
+
//
|
|
10
|
+
// Re-pin: bump VERSION + INTEGRITY (from `npm view @uvdsl/solid-oidc-client-browser@<v> dist.integrity`)
|
|
11
|
+
// and re-run. The output filename stays unversioned so routes/tests don't churn.
|
|
12
|
+
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
15
|
+
import { dirname, resolve } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { gunzipSync } from 'node:zlib';
|
|
18
|
+
|
|
19
|
+
const NAME = '@uvdsl/solid-oidc-client-browser';
|
|
20
|
+
const VERSION = '0.2.3';
|
|
21
|
+
const INTEGRITY = 'sha512-WzVlxv46EUSoqm7ovsWJRZq8KEI/CdpA9O1fXoiP8bihs2cNxPnet3YcqvIYWYMsTrf0zsR031l5s/BzQ9MEgA==';
|
|
22
|
+
const TARBALL = `https://registry.npmjs.org/@uvdsl/solid-oidc-client-browser/-/solid-oidc-client-browser-${VERSION}.tgz`;
|
|
23
|
+
const ENTRY = 'package/dist/esm/core/index.min.js';
|
|
24
|
+
|
|
25
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const outFile = resolve(here, '..', 'web', 'front', 'solid-oidc-client.js');
|
|
27
|
+
|
|
28
|
+
function verifyIntegrity(buf, integrity) {
|
|
29
|
+
const [alg, expected] = integrity.split('-', 2);
|
|
30
|
+
const actual = createHash(alg).update(buf).digest('base64');
|
|
31
|
+
if (actual !== expected) {
|
|
32
|
+
throw new Error(`integrity mismatch: expected ${alg}-${expected}, got ${alg}-${actual}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Minimal ustar tar reader — enough to pull one file out of the npm tarball.
|
|
37
|
+
function readTarEntry(tar, wanted) {
|
|
38
|
+
let off = 0;
|
|
39
|
+
while (off + 512 <= tar.length) {
|
|
40
|
+
const name = tar.toString('utf8', off, off + 100).replace(/\0.*$/, '');
|
|
41
|
+
if (!name) break;
|
|
42
|
+
const size = parseInt(tar.toString('utf8', off + 124, off + 136).replace(/\0.*$/, '').trim() || '0', 8);
|
|
43
|
+
const start = off + 512;
|
|
44
|
+
if (name === wanted) return tar.subarray(start, start + size);
|
|
45
|
+
off = start + Math.ceil(size / 512) * 512;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const res = await fetch(TARBALL);
|
|
51
|
+
if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`);
|
|
52
|
+
const gz = Buffer.from(await res.arrayBuffer());
|
|
53
|
+
verifyIntegrity(gz, INTEGRITY);
|
|
54
|
+
|
|
55
|
+
const tar = gunzipSync(gz);
|
|
56
|
+
const entry = readTarEntry(tar, ENTRY);
|
|
57
|
+
if (!entry) throw new Error(`entry not found in tarball: ${ENTRY}`);
|
|
58
|
+
|
|
59
|
+
let code = entry.toString('utf8').replace(/\n?\/\/# sourceMappingURL=.*$/m, '');
|
|
60
|
+
const banner =
|
|
61
|
+
`// ${NAME}@${VERSION} — vendored worker-free Solid-OIDC client (core build).\n` +
|
|
62
|
+
`// tarball ${INTEGRITY}\n` +
|
|
63
|
+
`// license MIT — https://github.com/uvdsl/solid-oidc-client-browser\n` +
|
|
64
|
+
`// Regenerate with: node scripts/pin-solid-oidc.mjs\n`;
|
|
65
|
+
|
|
66
|
+
mkdirSync(dirname(outFile), { recursive: true });
|
|
67
|
+
writeFileSync(outFile, banner + code + '\n');
|
|
68
|
+
console.log(`[pin-solid-oidc] wrote ${outFile} (${(code.length / 1024).toFixed(1)} KB)`);
|
|
@@ -20,7 +20,7 @@ fieldset { border: 1px solid #bbb; border-radius: .4rem; margin: 1rem 0; padding
|
|
|
20
20
|
legend { padding: 0 .3rem; font-weight: 600; }
|
|
21
21
|
label { display: block; margin: .75rem 0 .2rem; }
|
|
22
22
|
.hint { color: #666; font-size: .9rem; margin: .15rem 0 0; }
|
|
23
|
-
input[type=text], input[type=email], input[type=url], input[type=password], textarea {
|
|
23
|
+
input[type=text], input[type=email], input[type=url], input[type=password], select, textarea {
|
|
24
24
|
font: inherit; width: 100%; padding: .5rem; box-sizing: border-box;
|
|
25
25
|
border: 1px solid #767676; border-radius: .3rem; background: Field; color: FieldText;
|
|
26
26
|
}
|
|
@@ -94,7 +94,8 @@ button:disabled { opacity: .5; cursor: default; }
|
|
|
94
94
|
<ul class="steps" id="run-steps"></ul>
|
|
95
95
|
<p class="sr-only" id="run-say" role="status"></p>
|
|
96
96
|
<p class="err" id="run-error" role="alert"></p>
|
|
97
|
-
<p><button id="run-again" title=" Try the step that failed again" hidden>Try again</button
|
|
97
|
+
<p><button id="run-again" title=" Try the step that failed again" hidden>Try again</button>
|
|
98
|
+
<button id="run-reenter" title=" Discard the credential and enter the account and pod again" hidden>Re-enter credentials</button></p>
|
|
98
99
|
</section>
|
|
99
100
|
|
|
100
101
|
<section id="pane-form" hidden>
|
|
@@ -124,8 +125,19 @@ button:disabled { opacity: .5; cursor: default; }
|
|
|
124
125
|
<label class="choice"><input type="radio" name="mode" value="existing">
|
|
125
126
|
Use a pod I already have</label>
|
|
126
127
|
|
|
127
|
-
<
|
|
128
|
-
|
|
128
|
+
<div id="row-issuer-new">
|
|
129
|
+
<label for="issuer-new">Solid pod provider</label>
|
|
130
|
+
<select id="issuer-new" name="issuerNew" aria-describedby="issuer-new-hint">
|
|
131
|
+
<option value="https://solidcommunity.net">solidcommunity.net</option>
|
|
132
|
+
</select>
|
|
133
|
+
<p class="hint" id="issuer-new-hint">Providers that give each pod its own subdomain, so the
|
|
134
|
+
Fediverse address works everywhere.</p>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<div id="row-issuer-existing" hidden>
|
|
138
|
+
<label for="issuer">Solid identity provider</label>
|
|
139
|
+
<input type="url" id="issuer" name="issuer" value="https://solidcommunity.net" autocomplete="url">
|
|
140
|
+
</div>
|
|
129
141
|
|
|
130
142
|
<label for="email">Solid Account email/username</label>
|
|
131
143
|
<input type="email" id="email" name="email" autocomplete="email" placeholder="you@example.org">
|
|
@@ -153,6 +165,10 @@ button:disabled { opacity: .5; cursor: default; }
|
|
|
153
165
|
</div>
|
|
154
166
|
</fieldset>
|
|
155
167
|
|
|
168
|
+
<p id="row-reenter" class="warn" hidden>Finishing a setup that already has an account credential.
|
|
169
|
+
Wrong account or pod?
|
|
170
|
+
<button type="button" id="form-reenter" title=" Discard the credential and enter the account and pod again">Re-enter credentials</button></p>
|
|
171
|
+
|
|
156
172
|
<h2>You will be</h2>
|
|
157
173
|
<p class="address" id="preview">…</p>
|
|
158
174
|
<div id="preview-notes"></div>
|