fedipod 1.5.0 → 1.7.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/README.md +5 -5
- package/architecture.md +4 -4
- package/cli.md +7 -0
- package/{installed-agent.md → device-agent.md} +6 -4
- package/gateway.md +1 -1
- package/groups.md +2 -2
- package/gui.md +2 -2
- package/lib/client/masto/index.mjs +7 -1
- package/lib/client/masto/timelines.mjs +1 -1
- package/lib/client/oidc-auth.mjs +5 -3
- package/lib/core/publisher/index.mjs +1 -1
- package/lib/core/wire.mjs +4 -1
- package/lib/device/admin/routes/gateway.mjs +1 -1
- package/lib/device/admin/routes/lifecycle.mjs +1 -1
- package/lib/device/admin/routes/setup.mjs +20 -1
- package/lib/device/admin/surface.mjs +25 -17
- package/lib/device/cli/commands/setup.mjs +53 -21
- package/lib/device/setup.mjs +105 -25
- package/lib/gateway/front-core.mjs +23 -11
- package/lib/pod/root.mjs +12 -1
- package/lib/server/embed.mjs +23 -6
- package/package.json +2 -2
- package/run-agent.mjs +1 -1
- package/vendor/gate.cjs +5 -2
- package/web/admin/setup/index.html +25 -2
- package/web/admin/setup/setup.js +30 -1
- package/web/app/README.md +2 -2
- package/web/app/admin-facade.mjs +1 -1
- package/web/app/agent.mjs +3 -3
- package/web/app/boot.mjs +2 -2
- package/web/app/dist/boot.js +43 -31
- package/web/app/dist/boot.js.map +3 -3
- package/web/app/dist/sw.js +4 -4
- package/web/app/dist/sw.js.map +2 -2
- package/web/app/index.html +1 -1
- package/web/app/signup.mjs +5 -3
- package/web/app/site/admin/setup/index.html +25 -2
- package/web/app/site/admin/setup/setup.js +30 -1
- package/web/app/site/boot.js +1 -1
- package/web/app/site/index.html +1 -1
- package/web/app/site/sw.js +3 -3
package/lib/device/setup.mjs
CHANGED
|
@@ -14,11 +14,12 @@ import { pathToFileURL } from 'node:url';
|
|
|
14
14
|
import * as $rdf from 'rdflib';
|
|
15
15
|
|
|
16
16
|
import { createAccountWithPod as realCreateAccount } from './account.mjs';
|
|
17
|
-
import { mintCredential as realMint } from './remote.mjs';
|
|
17
|
+
import { mintCredential as realMint, RemotePod } from './remote.mjs';
|
|
18
18
|
import { hashPassword } from '../client/masto/index.mjs';
|
|
19
|
-
import { webfingerHost } from '../core/wire.mjs';
|
|
19
|
+
import { webfingerHost, apUrls, DEFAULT_ROOT } from '../core/wire.mjs';
|
|
20
20
|
import { rootOf, recordLastUsed, writeJsonAtomic } from './home.mjs';
|
|
21
21
|
import { insecureUrlReason } from '../shared/safefetch.mjs';
|
|
22
|
+
import { resourceExists } from '../pod/root.mjs';
|
|
22
23
|
import { CURRENT_LAYOUT, isCurrent } from './migrate.mjs';
|
|
23
24
|
|
|
24
25
|
const SOLID = $rdf.Namespace('http://www.w3.org/ns/solid/terms#');
|
|
@@ -96,6 +97,11 @@ export function setupInputError(a, resuming = false) {
|
|
|
96
97
|
const badGw = insecureUrlReason(a.gateway.url, 'gateway address');
|
|
97
98
|
if (badGw) return badGw;
|
|
98
99
|
}
|
|
100
|
+
if (a.shape && a.shape !== 'pod' && a.shape !== 'front') return 'shape must be "pod" or "front"';
|
|
101
|
+
if (a.gatewayOrigin) {
|
|
102
|
+
const badO = insecureUrlReason(a.gatewayOrigin, 'gateway address');
|
|
103
|
+
if (badO) return badO;
|
|
104
|
+
}
|
|
99
105
|
if (resuming) return null;
|
|
100
106
|
if (a.mode !== 'new' && a.mode !== 'existing') return 'mode must be "new" or "existing"';
|
|
101
107
|
if (!a.issuer) return 'an identity provider is required';
|
|
@@ -111,23 +117,61 @@ export function setupInputError(a, resuming = false) {
|
|
|
111
117
|
return null;
|
|
112
118
|
}
|
|
113
119
|
|
|
120
|
+
// Where an identity's address lives. A pod on a suffix-based host cannot
|
|
121
|
+
// answer WebFinger for a handle, so its address must live at the Gateway
|
|
122
|
+
// whatever was asked; a pod at its own host root takes the shape chosen.
|
|
123
|
+
export function frontedAddress({ pod, shape }) {
|
|
124
|
+
const pathPod = pod ? !webfingerHost(pod) : false;
|
|
125
|
+
return pathPod || shape === 'front';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Take an address at a Gateway: attach the pod to it, fronted, and return the
|
|
129
|
+
// gateway config bootstrap writes and connect reads. The pod session proves
|
|
130
|
+
// the pod — no password reaches the Gateway. Injected for tests.
|
|
131
|
+
export async function takeGatewayAddress({ home, credential, gatewayOrigin, handle, podHome, actorUrl, kind, log = () => {} }) {
|
|
132
|
+
const remote = new RemotePod(credential, { home, log });
|
|
133
|
+
await remote.warmup?.();
|
|
134
|
+
const origin = String(gatewayOrigin).replace(/\/$/, '');
|
|
135
|
+
const res = await remote.session.fetch(`${origin}/api/attach`, {
|
|
136
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
137
|
+
body: JSON.stringify({ handle, podHome, actorUrl, kind: kind === 'group' ? 'group' : 'person', fronted: true }),
|
|
138
|
+
});
|
|
139
|
+
const d = await res.json().catch(() => ({}));
|
|
140
|
+
if (res.status !== 201 || !d.hmacSecret) {
|
|
141
|
+
throw new Error(`could not take a gateway address at ${new URL(origin).host} (HTTP ${res.status})${d.error ? ': ' + d.error : ''}`);
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
url: `${origin}/u/${handle}/ap/inbox/`,
|
|
145
|
+
frontActor: String(d.frontActor || `${origin}/u/${handle}/ap/actor`),
|
|
146
|
+
hmacSecret: String(d.hmacSecret), mode: 'trust',
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
114
150
|
// What the CLI printed before asking "create pod and fediverse account?"
|
|
115
151
|
// (bin/fedipod.mjs, the address preview) — as data, so the page can show
|
|
116
152
|
// the same warnings. Pure: no network, so it can answer while you type.
|
|
117
|
-
export function preflight({ mode, pod, issuer, podName, handle, kind }) {
|
|
153
|
+
export function preflight({ mode, pod, issuer, podName, handle, kind, shape = 'pod', gatewayOrigin = 'https://fedipod.net' }) {
|
|
118
154
|
const warnings = [];
|
|
119
155
|
if (!handle) return { ok: false, error: 'a handle is required' };
|
|
120
156
|
let issuerHost;
|
|
121
157
|
try { issuerHost = new URL(issuer || 'https://solidcommunity.net').host; }
|
|
122
158
|
catch { return { ok: false, error: `"${issuer}" is not a URL` }; }
|
|
123
159
|
|
|
160
|
+
let gwHost; try { gwHost = new URL(gatewayOrigin).host; } catch { gwHost = 'fedipod.net'; }
|
|
161
|
+
|
|
124
162
|
if (mode === 'new') {
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
163
|
+
// Whether the provider puts the pod on its own subdomain or on a path is
|
|
164
|
+
// the provider's call, learned only once the pod is made. If the address
|
|
165
|
+
// was asked to live at the Gateway, it does. Otherwise the pod's own host
|
|
166
|
+
// is the address, and the run moves it to the Gateway if the provider
|
|
167
|
+
// turned out to use paths.
|
|
168
|
+
if (shape === 'front') {
|
|
169
|
+
if (kind === 'group') return { ok: false, mode, handle, kind, error: 'group-needs-host-root', refusal: 'group-needs-host-root', warnings };
|
|
170
|
+
return { ok: true, mode, handle, kind, fronted: true, forced: false, gatewayHost: gwHost, shape: 'front',
|
|
171
|
+
address: `@${handle}@${gwHost}`, webfingerHost: null, resolvable: true, warnings, refusal: null };
|
|
172
|
+
}
|
|
129
173
|
return {
|
|
130
|
-
ok: true, mode, handle, kind,
|
|
174
|
+
ok: true, mode, handle, kind, fronted: false, shape: 'pod',
|
|
131
175
|
address: `@${handle}@${podName || handle}.${issuerHost}`,
|
|
132
176
|
webfingerHost: null, resolvable: null, warnings, refusal: null,
|
|
133
177
|
};
|
|
@@ -137,20 +181,26 @@ export function preflight({ mode, pod, issuer, podName, handle, kind }) {
|
|
|
137
181
|
try { podUrl = new URL(pod); }
|
|
138
182
|
catch { return { ok: false, error: `"${pod}" is not a pod address` }; }
|
|
139
183
|
// A handle resolves through <host>/.well-known/webfinger, so it only works
|
|
140
|
-
// when the pod owns the root of its host.
|
|
184
|
+
// when the pod owns the root of its host. A pod that does not can still be
|
|
185
|
+
// an account, with its address at the Gateway.
|
|
141
186
|
const wfHost = webfingerHost(podUrl.href);
|
|
142
|
-
|
|
143
|
-
if (
|
|
144
|
-
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
187
|
+
const fronted = !wfHost || shape === 'front';
|
|
188
|
+
if (fronted) {
|
|
189
|
+
// A person's address moves to the Gateway; a group cannot, yet — a group
|
|
190
|
+
// needs a pod at its own host until fronted groups are proven.
|
|
191
|
+
if (kind === 'group') {
|
|
192
|
+
return { ok: false, mode: 'existing', handle, kind, refusal: 'group-needs-host-root', warnings,
|
|
193
|
+
address: `@${handle}@${podUrl.host}`, webfingerHost: null, resolvable: false };
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
ok: true, mode: 'existing', handle, kind, fronted: true, forced: !wfHost, gatewayHost: gwHost, shape: 'front',
|
|
197
|
+
address: `@${handle}@${gwHost}`, webfingerHost: null, resolvable: true, warnings, refusal: null,
|
|
198
|
+
};
|
|
149
199
|
}
|
|
150
200
|
return {
|
|
151
|
-
ok:
|
|
152
|
-
address: `@${handle}@${wfHost
|
|
153
|
-
webfingerHost: wfHost, resolvable:
|
|
201
|
+
ok: true, mode: 'existing', handle, kind, fronted: false, shape: 'pod',
|
|
202
|
+
address: `@${handle}@${wfHost}`,
|
|
203
|
+
webfingerHost: wfHost, resolvable: true, warnings, refusal: null,
|
|
154
204
|
};
|
|
155
205
|
}
|
|
156
206
|
|
|
@@ -160,6 +210,7 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
160
210
|
const createAccount = deps.createAccountWithPod || realCreateAccount;
|
|
161
211
|
const mint = deps.mintCredential || realMint;
|
|
162
212
|
const checkPod = deps.checkPodUsable || checkPodUsable;
|
|
213
|
+
const attachGateway = deps.attachGateway || takeGatewayAddress;
|
|
163
214
|
|
|
164
215
|
const at = (key) => run.steps.find(s => s.key === key);
|
|
165
216
|
const begin = (key) => { at(key).state = 'running'; };
|
|
@@ -169,9 +220,11 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
169
220
|
const {
|
|
170
221
|
mode, issuer, email, password, handle, name, podName,
|
|
171
222
|
kind = 'person', approveJoins = false, summary, icon, keys, uiPassword,
|
|
172
|
-
gateway = null,
|
|
223
|
+
gateway = null, shape = 'pod', gatewayOrigin = 'https://fedipod.net',
|
|
173
224
|
} = answers;
|
|
174
225
|
let { pod, root } = answers;
|
|
226
|
+
if (!root) root = 'fedipod/'; // new installs default to the fedipod/ container; a
|
|
227
|
+
// resuming run overwrites this with the credential's own root below.
|
|
175
228
|
let accountWebId = null; // what createAccountWithPod reported, when it ran
|
|
176
229
|
// The private half always starts here, beside the credential and the keys —
|
|
177
230
|
// not on the pod. Every activity you receive would otherwise cost the pod
|
|
@@ -202,7 +255,7 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
202
255
|
// nobody could ever find this group. A person was warned before we got
|
|
203
256
|
// here and chose to continue; a group cannot.
|
|
204
257
|
if (kind === 'group' && !webfingerHost(pod)) {
|
|
205
|
-
throw new Error(`${issuer} created the pod at ${pod} — a
|
|
258
|
+
throw new Error(`${issuer} created the pod at ${pod} — a suffix-based host, `
|
|
206
259
|
+ 'not a host root. WebFinger is only answered at a host root, so nobody '
|
|
207
260
|
+ 'could find this group. The pod exists; no actor was published.');
|
|
208
261
|
}
|
|
@@ -213,6 +266,9 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
213
266
|
// reachable, and no silent 401 later on a pod whose profile is empty.
|
|
214
267
|
const usable = await checkPod(pod);
|
|
215
268
|
if (!usable.ok) throw new Error(usable.error);
|
|
269
|
+
if (await (deps.resourceExists || resourceExists)(deps.fetch || fetch, apUrls(pod, root || DEFAULT_ROOT).actor)) {
|
|
270
|
+
throw new Error('The pod already hosts a FediPod account. If you want a second account, put it on a different pod.');
|
|
271
|
+
}
|
|
216
272
|
skip('account', 'using the pod you already have');
|
|
217
273
|
}
|
|
218
274
|
|
|
@@ -259,6 +315,26 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
259
315
|
done('credential', credPath);
|
|
260
316
|
}
|
|
261
317
|
|
|
318
|
+
// --- take an address at the Gateway, if this pod needs one or asked for
|
|
319
|
+
// one --- before bootstrap, so the config it writes carries the Gateway
|
|
320
|
+
// ids and the key connect mints below is stamped to the Gateway actor from
|
|
321
|
+
// the start. A signup-arranged gateway (the installer carry-over) wins.
|
|
322
|
+
let gatewayCfg = gateway;
|
|
323
|
+
if (!gatewayCfg && frontedAddress({ pod, shape })) {
|
|
324
|
+
if (kind === 'group') {
|
|
325
|
+
throw new Error(`a group needs a pod at its own host — a Gateway address for a group is not supported yet`
|
|
326
|
+
+ (webfingerHost(pod) ? '' : `; ${pod} is a suffix-based host`) + '.');
|
|
327
|
+
}
|
|
328
|
+
const urls = apUrls(pod, root || DEFAULT_ROOT);
|
|
329
|
+
const cred = JSON.parse(fs.readFileSync(credPath, 'utf8'));
|
|
330
|
+
log(`taking a gateway address at ${new URL(gatewayOrigin).host}`);
|
|
331
|
+
gatewayCfg = await attachGateway({
|
|
332
|
+
home, credential: cred, gatewayOrigin, handle,
|
|
333
|
+
podHome: urls.home, actorUrl: urls.actor, kind, log,
|
|
334
|
+
});
|
|
335
|
+
log(`gateway address @${handle}@${new URL(gatewayOrigin).host}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
262
338
|
// --- provision the pod and bring federation up ---
|
|
263
339
|
begin('bootstrap');
|
|
264
340
|
// Resuming skipped the checks the fresh paths ran, and the credential it
|
|
@@ -268,7 +344,7 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
268
344
|
const ready = await checkPod(pod, { webId: resumeWebId || undefined });
|
|
269
345
|
if (!ready.ok) throw new Error(ready.error);
|
|
270
346
|
}
|
|
271
|
-
await agent.bootstrap({ handle, name: name || handle, root, kind, approveJoins, summary, icon, gateway });
|
|
347
|
+
await agent.bootstrap({ handle, name: name || handle, root, kind, approveJoins, summary, icon, gateway: gatewayCfg });
|
|
272
348
|
done('bootstrap');
|
|
273
349
|
|
|
274
350
|
begin('connect');
|
|
@@ -293,14 +369,18 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
293
369
|
? `not readable without credentials: ${unreachable.join(', ')}`
|
|
294
370
|
: 'the public surface is reachable');
|
|
295
371
|
|
|
372
|
+
// A fronted identity resolves at the Gateway, not the pod host.
|
|
373
|
+
const frontActor = gatewayCfg?.frontActor || agent.store.getConfig()?.gateway?.frontActor || null;
|
|
374
|
+
const gwHost = frontActor ? new URL(frontActor).host : null;
|
|
296
375
|
run.result = {
|
|
297
376
|
kind,
|
|
298
377
|
pod,
|
|
299
378
|
handle,
|
|
300
379
|
actor: agent.urls?.actor || null,
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
380
|
+
fronted: !!frontActor,
|
|
381
|
+
webfingerHost: gwHost || wfHost,
|
|
382
|
+
resolvable: !!(gwHost || wfHost),
|
|
383
|
+
address: gwHost ? `@${handle}@${gwHost}` : (wfHost ? `@${handle}@${wfHost}` : null),
|
|
304
384
|
unreachable,
|
|
305
385
|
};
|
|
306
386
|
run.phase = 'done';
|
|
@@ -20,6 +20,7 @@ import { handleDelivery } from './gateway-core.mjs';
|
|
|
20
20
|
import { readCapped, safeFetch, isLoopbackHost } from '../shared/safefetch.mjs';
|
|
21
21
|
import * as podRoot from '../pod/root.mjs';
|
|
22
22
|
import * as podPolicy from '../pod/policy.mjs';
|
|
23
|
+
import { podBaseOfWebId } from '../pod/urls.mjs';
|
|
23
24
|
|
|
24
25
|
// The one WebFinger document, spelled out here rather than imported from
|
|
25
26
|
// wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
|
|
@@ -513,20 +514,31 @@ async function route(request, ctx) {
|
|
|
513
514
|
if (!/^https?:\/\/\S+\/$/.test(podBase)) {
|
|
514
515
|
return j(400, { error: 'podBase must be a URL ending in /' });
|
|
515
516
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
// of
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
517
|
+
let podUrl;
|
|
518
|
+
try { podUrl = new URL(podBase); } catch { return j(400, { error: 'podBase is not a URL' }); }
|
|
519
|
+
// A pod may be host-root (its own origin) or on a PATH of a shared host — a
|
|
520
|
+
// suffix pod, which the Server runs at `https://server/aisha/`. What is
|
|
521
|
+
// refused is the gateway's own origin root itself: that is where the
|
|
522
|
+
// gateway lives, not a pod, and admitting it would (with the ownership
|
|
523
|
+
// fallback below) let a co-tenant claim the whole origin.
|
|
524
|
+
if (ctx.frontOrigin) {
|
|
525
|
+
try {
|
|
526
|
+
if (podUrl.origin === new URL(ctx.frontOrigin).origin && podUrl.pathname === '/') {
|
|
527
|
+
return j(403, { error: 'that is this gateway, not a pod — name the pod that holds your data' });
|
|
528
|
+
}
|
|
529
|
+
} catch { /* no usable frontOrigin: the checks below still apply */ }
|
|
530
|
+
}
|
|
531
|
+
if (/(^|\/)\.internal(\/|$)/u.test(podUrl.pathname) || /(^|\/)\.\.(\/|$)/u.test(podUrl.pathname)) {
|
|
532
|
+
return j(403, { error: 'that is not a pod address' });
|
|
533
|
+
}
|
|
524
534
|
const webid = await verifyPodToken(request, pathname, ctx.verifier);
|
|
525
535
|
if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
|
|
526
|
-
// The pod's own server names its owner when it can
|
|
527
|
-
// the proof
|
|
536
|
+
// The pod's own server names its owner when it can, and where it does that
|
|
537
|
+
// is the proof. Where it does not, the WebID must live in EXACTLY this pod
|
|
538
|
+
// — its own pod base equal to podBase, not merely starting with it, which on
|
|
539
|
+
// a path server an ancestor of another's pod would.
|
|
528
540
|
const owners = await podOwners(podBase, ctx.fetchImpl || fetch);
|
|
529
|
-
const proven = owners.length ? owners.includes(webid) : webid
|
|
541
|
+
const proven = owners.length ? owners.includes(webid) : podBaseOfWebId(webid) === podBase;
|
|
530
542
|
if (!proven) {
|
|
531
543
|
return j(403, { error: 'the token proves a different pod than the one you listed' });
|
|
532
544
|
}
|
package/lib/pod/root.mjs
CHANGED
|
@@ -41,7 +41,7 @@ export async function readOwnerLinks(fetchImpl, podBase, { timeoutMs = OWNER_LOO
|
|
|
41
41
|
*/
|
|
42
42
|
/**
|
|
43
43
|
* Where a provider puts its pods: on hosts of their own, or on paths of one
|
|
44
|
-
*
|
|
44
|
+
* suffix-based host. No spec says. What does say is the storage description at the
|
|
45
45
|
* provider's root: a CSS that keeps pods on subdomains answers 501 there,
|
|
46
46
|
* because its root is not a storage; one that keeps them on paths answers 200
|
|
47
47
|
* with the root described as a storage. Anything else is unknown.
|
|
@@ -66,6 +66,17 @@ export async function podLayout(fetchImpl, providerOrigin, { timeoutMs = OWNER_L
|
|
|
66
66
|
return /ns\/pim\/space#Storage|pim:Storage/u.test(body) ? 'path' : null;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// Whether a document is there — one unauthenticated GET, 200 or not. The caller
|
|
70
|
+
// names the URL; asked before a second setup, so a pod that already serves an
|
|
71
|
+
// actor at the app's container refuses another rather than growing a duplicate.
|
|
72
|
+
export async function resourceExists(fetchImpl, url, { timeoutMs = OWNER_LOOKUP_MS } = {}) {
|
|
73
|
+
try {
|
|
74
|
+
const res = await fetchImpl(url,
|
|
75
|
+
{ headers: { accept: 'application/activity+json' }, signal: AbortSignal.timeout(timeoutMs) });
|
|
76
|
+
return !!res && res.status === 200;
|
|
77
|
+
} catch { return false; }
|
|
78
|
+
}
|
|
79
|
+
|
|
69
80
|
export async function probeAnswers(podUrl, fetchImpl = fetch) {
|
|
70
81
|
try {
|
|
71
82
|
const res = await fetchImpl(podUrl, { method: 'HEAD' });
|
package/lib/server/embed.mjs
CHANGED
|
@@ -47,7 +47,8 @@ const mintSecret = () => crypto.randomBytes(32).toString('base64');
|
|
|
47
47
|
*/
|
|
48
48
|
export async function ensureDoorSecret(session, podBase, { rotate = false, dataDir = null, handle = null, log = () => {} } = {}) {
|
|
49
49
|
const base = podBase.endsWith('/') ? podBase : podBase + '/';
|
|
50
|
-
|
|
50
|
+
// Under the identity's own tree (fedipod/), where the gate reads it back.
|
|
51
|
+
const url = apUrls(base, 'fedipod/').state + 'door-secret.json';
|
|
51
52
|
const onHost = dataDir && handle ? path.join(dataDir, handle, 'door-secret.json') : null;
|
|
52
53
|
|
|
53
54
|
if (!rotate) {
|
|
@@ -136,7 +137,7 @@ function ensureCredential(home, { podBase, webId }) {
|
|
|
136
137
|
const rec = {
|
|
137
138
|
webId,
|
|
138
139
|
remotePod: podBase.endsWith('/') ? podBase : podBase + '/',
|
|
139
|
-
root: '
|
|
140
|
+
root: 'fedipod/',
|
|
140
141
|
keysMode: 'pod',
|
|
141
142
|
};
|
|
142
143
|
writeJsonAtomic(file, rec, { mode: 0o600 });
|
|
@@ -275,7 +276,7 @@ export async function startEmbeddedAgent({
|
|
|
275
276
|
pollSeconds = null,
|
|
276
277
|
autoAcceptFollows = true,
|
|
277
278
|
gateToken = null,
|
|
278
|
-
uiPath = '/
|
|
279
|
+
uiPath = '/fp/',
|
|
279
280
|
}) {
|
|
280
281
|
const base = podBase.endsWith('/') ? podBase : podBase + '/';
|
|
281
282
|
const handle = handleFor(base);
|
|
@@ -323,7 +324,7 @@ export async function startEmbeddedAgent({
|
|
|
323
324
|
throw new Error(`no pod at ${base} yet — its owner profile is not there`);
|
|
324
325
|
}
|
|
325
326
|
log(`no identity on ${base} yet — provisioning @${handle}`);
|
|
326
|
-
await agent.bootstrap({ handle, name: handle, kind: 'person' });
|
|
327
|
+
await agent.bootstrap({ handle, name: handle, kind: 'person', root: cred.root });
|
|
327
328
|
if (autoAcceptFollows) {
|
|
328
329
|
agent.store.setConfig({ ...agent.store.getConfig(), autoAcceptFollows: true });
|
|
329
330
|
await agent.store.flush();
|
|
@@ -342,15 +343,31 @@ export async function startEmbeddedAgent({
|
|
|
342
343
|
// speaks, the write API, nodeinfo, and behind the door the admin routes and
|
|
343
344
|
// the web client. Same code the standalone agent serves, minus the routes
|
|
344
345
|
// that only mean something to a process of one's own.
|
|
346
|
+
//
|
|
347
|
+
// A pod that lives on a PATH of its host (a suffix pod, e.g.
|
|
348
|
+
// https://server.example/aisha/) shares its origin with the front and with
|
|
349
|
+
// every other suffix pod, so its whole surface answers UNDER that path: the
|
|
350
|
+
// mount is the pod's own pathname, and it is stripped before a route is
|
|
351
|
+
// matched and folded back into every self-URL. A host-root or subdomain pod
|
|
352
|
+
// has an empty mount and everything is exactly as it was.
|
|
345
353
|
const authorities = new FixedAuthorities(base);
|
|
346
354
|
agent.authorities = authorities;
|
|
355
|
+
const mount = new URL(base).pathname.replace(/\/+$/u, '');
|
|
347
356
|
const surface = buildAdminSurface({
|
|
348
357
|
agent,
|
|
349
358
|
log,
|
|
350
|
-
|
|
359
|
+
// The door cookie is named the same for every identity; on a shared origin
|
|
360
|
+
// (suffix pods) it has to be scoped to this identity's own door path so two
|
|
361
|
+
// co-tenants do not overwrite each other's. A host-root/subdomain pod keeps
|
|
362
|
+
// the whole-origin cookie it always had.
|
|
363
|
+
gate: makeGate(gateToken, {
|
|
364
|
+
secureCookie: authorities.secure,
|
|
365
|
+
cookiePath: mount ? mount + uiPath : '/',
|
|
366
|
+
}),
|
|
351
367
|
allowed: authorities,
|
|
352
368
|
embedded: true,
|
|
353
369
|
basePath: uiPath,
|
|
370
|
+
mount,
|
|
354
371
|
publicOrigin: base,
|
|
355
372
|
scheme: new URL(base).protocol,
|
|
356
373
|
});
|
|
@@ -399,7 +416,7 @@ export async function startEmbeddedAgent({
|
|
|
399
416
|
// podHome and actorUrl are the identity's own locations on the pod. They are
|
|
400
417
|
// returned rather than rebuilt by the caller so the root name lives here.
|
|
401
418
|
return {
|
|
402
|
-
agent, handle, home, surface, host: authorities.host,
|
|
419
|
+
agent, handle, home, surface, host: authorities.host, mount,
|
|
403
420
|
podHome: urls.home, actorUrl: urls.actor, inboxUrl: urls.inbox, stop,
|
|
404
421
|
};
|
|
405
422
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"cli.md",
|
|
28
28
|
"groups.md",
|
|
29
29
|
"architecture.svg",
|
|
30
|
-
"
|
|
30
|
+
"device-agent.md",
|
|
31
31
|
"architecture.md",
|
|
32
32
|
"gateway.md",
|
|
33
33
|
"browser.svg"
|
package/run-agent.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// run-agent.mjs — fedipod: a standalone single-actor ActivityPub
|
|
2
2
|
// agent. The remote pod is a RELAY: it serves the public wire face
|
|
3
|
-
// (/
|
|
3
|
+
// (/fedipod/ap/) and buffers inbound mail in a public-append inbox
|
|
4
4
|
// while this process is off, and it keeps one private document, the lease,
|
|
5
5
|
// because a lock only one machine can reach coordinates nothing.
|
|
6
6
|
//
|
package/vendor/gate.cjs
CHANGED
|
@@ -85,7 +85,7 @@ function cookieValue(header, name) {
|
|
|
85
85
|
// AP_ALLOWED_HOSTS has nothing but this token, so the gate has to be total.
|
|
86
86
|
// `token` may be a function, resolved per request: an identity's secret can
|
|
87
87
|
// rotate while the server runs, and the very next request sees the new one.
|
|
88
|
-
function makeGate(token, { allowOrigins = [], publicEndpoints = false, secureCookie = false } = {}) {
|
|
88
|
+
function makeGate(token, { allowOrigins = [], publicEndpoints = false, secureCookie = false, cookiePath = '/' } = {}) {
|
|
89
89
|
const tokenNow = () => (typeof token === 'function' ? token() : token);
|
|
90
90
|
// gate(req, res) → true when the gate handled the response (caller stops).
|
|
91
91
|
function gate(req, res) {
|
|
@@ -102,7 +102,10 @@ function makeGate(token, { allowOrigins = [], publicEndpoints = false, secureCoo
|
|
|
102
102
|
url.searchParams.delete(COOKIE);
|
|
103
103
|
url.searchParams.delete('dk-bless');
|
|
104
104
|
res.writeHead(302, {
|
|
105
|
-
|
|
105
|
+
// Path scopes the cookie to this identity's own door: on a shared
|
|
106
|
+
// origin (suffix pods) two co-tenants must not clobber each other's,
|
|
107
|
+
// and a whole-origin cookie would. Defaults to '/'.
|
|
108
|
+
'set-cookie': `${COOKIE}=${t}; Path=${cookiePath}; HttpOnly; SameSite=Strict; Max-Age=31536000`
|
|
106
109
|
+ (secureCookie ? '; Secure' : ''),
|
|
107
110
|
'location': url.pathname + url.search,
|
|
108
111
|
});
|
|
@@ -130,9 +130,20 @@ button:disabled { opacity: .5; cursor: default; }
|
|
|
130
130
|
<label for="issuer-new">Solid pod provider</label>
|
|
131
131
|
<select id="issuer-new" name="issuerNew" aria-describedby="issuer-new-hint">
|
|
132
132
|
<option value="https://solidcommunity.net">solidcommunity.net</option>
|
|
133
|
+
<option value="https://privatedatapod.com">privatedatapod.com</option>
|
|
134
|
+
<option value="https://solid.redpencil.io">redpencil.io</option>
|
|
135
|
+
<option value="https://pods.solidcommunity.au">solidcommunity.au</option>
|
|
136
|
+
<option value="https://solidweb.me">solidweb.me</option>
|
|
137
|
+
<option value="https://teamid.live">teamid.live</option>
|
|
138
|
+
<option value="">Other…</option>
|
|
133
139
|
</select>
|
|
134
|
-
<
|
|
135
|
-
|
|
140
|
+
<div id="row-issuer-other" hidden>
|
|
141
|
+
<label for="issuerOther">Provider address</label>
|
|
142
|
+
<input type="url" id="issuerOther" name="issuerOther" autocomplete="url" placeholder="https://pods.example.org">
|
|
143
|
+
</div>
|
|
144
|
+
<p class="hint" id="issuer-new-hint">The Community Solid Server providers from
|
|
145
|
+
solidproject.org. A provider that puts pods on paths gives your address at a
|
|
146
|
+
gateway instead.</p>
|
|
136
147
|
</div>
|
|
137
148
|
|
|
138
149
|
<div id="row-issuer-existing" hidden>
|
|
@@ -170,6 +181,18 @@ button:disabled { opacity: .5; cursor: default; }
|
|
|
170
181
|
Wrong account or pod?
|
|
171
182
|
<button type="button" id="form-reenter" title=" Discard the credential and enter the account and pod again">Re-enter credentials</button></p>
|
|
172
183
|
|
|
184
|
+
<fieldset id="fs-shape" hidden>
|
|
185
|
+
<legend>Where your address lives</legend>
|
|
186
|
+
<label class="choice"><input type="radio" name="shape" value="pod" checked> On your pod</label>
|
|
187
|
+
<label class="choice"><input type="radio" name="shape" value="front"> At a gateway</label>
|
|
188
|
+
<div id="row-gateway" hidden>
|
|
189
|
+
<label for="gatewayOrigin">Gateway</label>
|
|
190
|
+
<input type="url" id="gatewayOrigin" name="gatewayOrigin" value="https://fedipod.net" autocomplete="url">
|
|
191
|
+
<p class="hint">The gateway answers your address and forwards your mail to your pod.</p>
|
|
192
|
+
</div>
|
|
193
|
+
<p class="hint" id="shape-note" hidden></p>
|
|
194
|
+
</fieldset>
|
|
195
|
+
|
|
173
196
|
<h2>You will be</h2>
|
|
174
197
|
<p class="address" id="preview">…</p>
|
|
175
198
|
<div id="preview-notes"></div>
|
package/web/admin/setup/setup.js
CHANGED
|
@@ -93,12 +93,18 @@ function answers() {
|
|
|
93
93
|
const f = $('form').elements;
|
|
94
94
|
const kind = f.kind.value;
|
|
95
95
|
const mode = state.resumable ? 'existing' : f.mode.value;
|
|
96
|
+
const issuer = mode === 'new'
|
|
97
|
+
? (f.issuerNew.value || (f.issuerOther ? f.issuerOther.value : '')).trim()
|
|
98
|
+
: f.issuer.value.trim();
|
|
96
99
|
const a = {
|
|
97
100
|
kind,
|
|
98
101
|
mode,
|
|
99
102
|
handle: f.handle.value.trim(),
|
|
100
|
-
issuer
|
|
103
|
+
issuer,
|
|
101
104
|
email: f.email.value.trim(),
|
|
105
|
+
// Where the address lives; a group cannot front (yet), so it stays 'pod'.
|
|
106
|
+
shape: (kind === 'group') ? 'pod' : (f.shape ? f.shape.value : 'pod'),
|
|
107
|
+
gatewayOrigin: (f.gatewayOrigin ? f.gatewayOrigin.value.trim() : '') || 'https://fedipod.net',
|
|
102
108
|
};
|
|
103
109
|
if (mode === 'new') a.podName = f.podName.value.trim() || a.handle;
|
|
104
110
|
else a.pod = f.pod.value.trim();
|
|
@@ -128,6 +134,9 @@ function onEdit() {
|
|
|
128
134
|
$('row-pod').hidden = mode === 'new';
|
|
129
135
|
$('row-issuer-new').hidden = mode !== 'new';
|
|
130
136
|
$('row-issuer-existing').hidden = mode === 'new';
|
|
137
|
+
const f = $('form').elements;
|
|
138
|
+
if ($('row-issuer-other')) $('row-issuer-other').hidden = !(mode === 'new' && f.issuerNew.value === '');
|
|
139
|
+
if ($('row-gateway')) $('row-gateway').hidden = !(f.shape && f.shape.value === 'front');
|
|
131
140
|
clearTimeout(editTimer);
|
|
132
141
|
editTimer = setTimeout(preview, 150);
|
|
133
142
|
}
|
|
@@ -140,6 +149,26 @@ async function preview() {
|
|
|
140
149
|
const { json } = await postJson('/setup/check', a);
|
|
141
150
|
if (!json) return;
|
|
142
151
|
$('preview').textContent = json.address || '…';
|
|
152
|
+
// The address-shape choice: hidden for a group (a group cannot front yet),
|
|
153
|
+
// locked to the gateway for a pod on a suffix-based host, an open choice
|
|
154
|
+
// for a pod at its own host.
|
|
155
|
+
const f = $('form').elements;
|
|
156
|
+
const shapeFs = $('fs-shape');
|
|
157
|
+
if (shapeFs) {
|
|
158
|
+
const isGroup = a.kind === 'group';
|
|
159
|
+
shapeFs.hidden = isGroup || (a.mode === 'existing' && !a.pod);
|
|
160
|
+
const forced = !!json.forced;
|
|
161
|
+
for (const r of f.shape) r.disabled = forced;
|
|
162
|
+
if (forced) { for (const r of f.shape) r.checked = r.value === 'front'; }
|
|
163
|
+
if ($('row-gateway')) $('row-gateway').hidden = !(f.shape.value === 'front');
|
|
164
|
+
const note = $('shape-note');
|
|
165
|
+
if (note) {
|
|
166
|
+
note.hidden = !forced;
|
|
167
|
+
note.textContent = forced
|
|
168
|
+
? 'Your pod is on a suffix-based host, so its address lives at the gateway. Your posts, key and data stay on your pod.'
|
|
169
|
+
: '';
|
|
170
|
+
}
|
|
171
|
+
}
|
|
143
172
|
const notes = $('preview-notes');
|
|
144
173
|
for (const w of json.warnings || []) {
|
|
145
174
|
const p = document.createElement('p');
|
package/web/app/README.md
CHANGED
|
@@ -72,6 +72,6 @@ See the `MastoApi` options in `agent.mjs`.
|
|
|
72
72
|
| **Streaming** | a service worker answers fetches, not sockets. No streaming URL is advertised, so clients poll. |
|
|
73
73
|
| **Web push** | `shims/web-push.mjs` is a no-op. `vapid` is omitted, and a client that subscribes anyway gets a 422 rather than a subscription nothing will push to. |
|
|
74
74
|
| **Scheduled posts** | nothing runs between now and the scheduled time. A `scheduled_at` is refused with a 422 that says so — accepting one was silent loss. |
|
|
75
|
-
| **Groups** | sign-up makes personal identities only (`signup.mjs`), and the moderation surface is not here. Joining a group works; hosting one needs the
|
|
76
|
-
| **Changing where the address lives** | the shape is chosen at sign-up (`signup.mjs`): on the pod, `@you@yourpod` with the gateway as a mail door, or at the gateway, `@you@front` — and a pod on a
|
|
75
|
+
| **Groups** | sign-up makes personal identities only (`signup.mjs`), and the moderation surface is not here. Joining a group works; hosting one needs the DeviceAgent. See `groups.md`. |
|
|
76
|
+
| **Changing where the address lives** | the shape is chosen at sign-up (`signup.mjs`): on the pod, `@you@yourpod` with the gateway as a mail door, or at the gateway, `@you@front` — and a pod on a suffix-based host is always fronted. `admin-facade.mjs` refuses changing it afterwards, because a rename needs a restart a browser does not have. |
|
|
77
77
|
| **Moving the private half** | `/state-move` is about filesystem paths and `credential.json`. A browser has neither; its private half is always on the pod. |
|
package/web/app/admin-facade.mjs
CHANGED
|
@@ -317,7 +317,7 @@ export class AdminFacade {
|
|
|
317
317
|
const aliases = [...(cfg.aliases || [])];
|
|
318
318
|
if (body.add) {
|
|
319
319
|
if (!webfingerHost(urls.base) && !cfg.gateway?.frontActor) {
|
|
320
|
-
return json(400, { error: 'this pod is a
|
|
320
|
+
return json(400, { error: 'this pod is a suffix-based host, so other servers could never resolve it as a Move target' });
|
|
321
321
|
}
|
|
322
322
|
const id = await this.resolveActor(body.add);
|
|
323
323
|
if (!id) return json(400, { error: `could not fetch the old account (${body.add}) — enter its URL or @user@host, and it must answer` });
|
package/web/app/agent.mjs
CHANGED
|
@@ -160,7 +160,7 @@ export class BrowserAgent {
|
|
|
160
160
|
if (oidc) {
|
|
161
161
|
session = { fetch: (u, i) => oidc.fetch(u, i) };
|
|
162
162
|
webId = oidc.webId;
|
|
163
|
-
remotePod = podBaseOfWebId(webId); // the pod, which on a
|
|
163
|
+
remotePod = podBaseOfWebId(webId); // the pod, which on a suffix-based host is a path
|
|
164
164
|
} else {
|
|
165
165
|
const dpop = await makeDpopSession(credential);
|
|
166
166
|
session = { fetch: (u, i) => dpop.fetch(u, i) };
|
|
@@ -189,7 +189,7 @@ export class BrowserAgent {
|
|
|
189
189
|
// builds its own urls from `config.root` (publisher.mjs), and a config
|
|
190
190
|
// without one falls to the Node default — so an account set up elsewhere
|
|
191
191
|
// and signed into here would keep its state under `fedipod/` while every
|
|
192
|
-
// document it published landed under
|
|
192
|
+
// document it published landed under a different root. One root, decided
|
|
193
193
|
// once, carried by the config everything downstream reads.
|
|
194
194
|
this.store.setConfig({ ...(this.store.getConfig() || {}), ...cfg, root });
|
|
195
195
|
config = this.store.getConfig();
|
|
@@ -224,7 +224,7 @@ export class BrowserAgent {
|
|
|
224
224
|
this.publisher = new Publisher({
|
|
225
225
|
config: this.store.getConfig(), remote: this.remote, store: this.store,
|
|
226
226
|
deliverer: this.deliverer, publicKeyPem: keys.rsaPublicPem, assertionKey: null, log: this.log,
|
|
227
|
-
// Who a post names, resolved — the same lookup the
|
|
227
|
+
// Who a post names, resolved — the same lookup the DeviceAgent
|
|
228
228
|
// gives its publisher. Without it no mention from the browser ever
|
|
229
229
|
// resolved: a direct message went to nobody, a mention notified no one.
|
|
230
230
|
resolveMention: (h) => resolveHandle(this, h),
|
package/web/app/boot.mjs
CHANGED
|
@@ -70,7 +70,7 @@ window.fedipodUnlock = async (password) => {
|
|
|
70
70
|
if (!session) throw new Error('Sign in first.');
|
|
71
71
|
// The config on the pod says where this account's state lives; the key sits
|
|
72
72
|
// beside it. Both are read with the session, as the owner.
|
|
73
|
-
const podFromWebId = podBaseOfWebId(session.webId); // a
|
|
73
|
+
const podFromWebId = podBaseOfWebId(session.webId); // a suffix-based host, or its own host
|
|
74
74
|
const state = `${podFromWebId}${AP_ROOT}ap-state/`;
|
|
75
75
|
// Through the transport rather than the bare session: this is a pod read
|
|
76
76
|
// like any other, and going round it skipped the retry ladder that exists
|
|
@@ -288,7 +288,7 @@ if (typeof document !== 'undefined') (async () => {
|
|
|
288
288
|
// brought by its address, which may be its own host or a path on a shared one.
|
|
289
289
|
const podHostOf = () => { const sub = f().podName.value.trim().toLowerCase(); const ph = providerHost(); return (sub && ph) ? `${sub}.${ph}` : ''; };
|
|
290
290
|
const podUrl = () => { let v = f().pod.value.trim(); if (!v) return ''; if (!/^https?:\/\//i.test(v)) v = 'https://' + v; if (!v.endsWith('/')) v += '/'; try { return new URL(v).href; } catch { return ''; } };
|
|
291
|
-
// A pod on a
|
|
291
|
+
// A pod on a suffix-based host cannot answer WebFinger, so its address
|
|
292
292
|
// lives at this site; a pod at its own host root gets the choice.
|
|
293
293
|
const isPathPod = (u) => { try { return new URL(u).pathname !== '/'; } catch { return false; } };
|
|
294
294
|
// Where the chosen provider puts new pods, asked of the provider itself
|