fedipod-server 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -15
- package/dist/claims.d.ts +10 -11
- package/dist/claims.js +19 -17
- package/dist/directory.d.ts +11 -3
- package/dist/directory.js +28 -14
- package/dist/handler.d.ts +30 -7
- package/dist/handler.js +133 -42
- package/dist/handler.jsonld +14 -6
- package/dist/streaming-handler.js +7 -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/wire.mjs +4 -1
- package/lib/device/admin/routes/gateway.mjs +1 -1
- package/lib/device/admin/surface.mjs +25 -17
- package/lib/device/cli/commands/setup.mjs +9 -1
- package/lib/device/setup.mjs +6 -0
- package/lib/gateway/front-core.mjs +23 -11
- package/lib/pod/root.mjs +11 -0
- package/lib/server/embed.mjs +23 -6
- package/package.json +2 -1
- package/run-agent.mjs +1 -1
- package/vendor/gate.cjs +5 -2
- package/web/app/agent.mjs +1 -1
- package/web/app/dist/boot.js +42 -30
- package/web/app/dist/boot.js.map +3 -3
- package/web/app/dist/sw.js +1 -1
- package/web/app/dist/sw.js.map +2 -2
- package/web/app/signup.mjs +3 -1
package/dist/handler.js
CHANGED
|
@@ -17,6 +17,14 @@ const adapt_1 = require("./adapt");
|
|
|
17
17
|
const store_css_1 = require("./store-css");
|
|
18
18
|
const store_pod_1 = require("./store-pod");
|
|
19
19
|
const directory_1 = require("./directory");
|
|
20
|
+
/** The pod's path as a mount prefix: `''` for a host root, else `/aisha`. */
|
|
21
|
+
function mountOf(podBase) {
|
|
22
|
+
return new URL(podBase).pathname.replace(/\/+$/u, '');
|
|
23
|
+
}
|
|
24
|
+
/** Whether `a` is `b` or an ancestor path of `b` (both mount-shaped, no trailing slash). */
|
|
25
|
+
function pathContains(a, b) {
|
|
26
|
+
return a === b || b.startsWith(a + '/') || a === '';
|
|
27
|
+
}
|
|
20
28
|
// The JS front-core is FediPod's own ESM tree, reached at runtime. A real
|
|
21
29
|
// dynamic import() built via Function keeps tsc from downleveling it to
|
|
22
30
|
// require() — which cannot load an ESM module with top-level await under a
|
|
@@ -54,7 +62,7 @@ function deriveHandle(podBase) {
|
|
|
54
62
|
function normalizeUiPath(raw) {
|
|
55
63
|
if (raw === '')
|
|
56
64
|
return '';
|
|
57
|
-
const path = raw ?? '/
|
|
65
|
+
const path = raw ?? '/fp/';
|
|
58
66
|
return `/${path.replace(/^\/+|\/+$/gu, '')}/`;
|
|
59
67
|
}
|
|
60
68
|
class FediPodServerHandler extends community_server_1.HttpHandler {
|
|
@@ -63,12 +71,17 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
|
|
|
63
71
|
dir;
|
|
64
72
|
podPut;
|
|
65
73
|
logger = (0, community_server_1.getLoggerFor)(this);
|
|
66
|
-
|
|
74
|
+
// Every pod whose routes this server claims, keyed by pod base. A claim
|
|
75
|
+
// records the host it answers on and the mount — the pod's own path, `''` for
|
|
76
|
+
// a host-root or subdomain pod, `/aisha` for a suffix pod on `server/aisha/`.
|
|
77
|
+
// A suffix pod shares its host (often the front's own) with others, so a
|
|
78
|
+
// request is matched to an identity by host AND mount, never host alone.
|
|
79
|
+
claimed = new Map(); // pod base → claim
|
|
67
80
|
agentHandles = new Map(); // handle → pod base
|
|
68
81
|
frontHost;
|
|
69
82
|
uiPath;
|
|
70
83
|
identities = new Map();
|
|
71
|
-
surfaces = new Map();
|
|
84
|
+
surfaces = new Map(); // pod base → running identity
|
|
72
85
|
registry;
|
|
73
86
|
doorSecrets = new Map(); // pod base → its door secret
|
|
74
87
|
starting = new Set();
|
|
@@ -100,32 +113,81 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
|
|
|
100
113
|
: null;
|
|
101
114
|
}
|
|
102
115
|
/**
|
|
103
|
-
* Whether this pod may become an identity here
|
|
104
|
-
*
|
|
105
|
-
* two pods must never share <agentDataDir>/<handle>/.
|
|
116
|
+
* Whether this pod may become an identity here, and the claim it earns: a
|
|
117
|
+
* real URL, a place no other identity already sits, and a handle no other
|
|
118
|
+
* identity uses — two pods must never share <agentDataDir>/<handle>/.
|
|
119
|
+
*
|
|
120
|
+
* A HOST-ROOT or subdomain pod needs an origin of its own, and it may not be
|
|
121
|
+
* the front's host: the whole surface answers at the origin root, so two of
|
|
122
|
+
* them, or one sharing the front, would collide. A SUFFIX pod lives on a path
|
|
123
|
+
* (`server/aisha/`), so it may share its host — with the front and with other
|
|
124
|
+
* suffix pods — provided no claim already contains or nests under its path.
|
|
106
125
|
*/
|
|
107
|
-
|
|
126
|
+
validateAgentPod(podBase) {
|
|
108
127
|
let host;
|
|
128
|
+
let mount;
|
|
109
129
|
try {
|
|
110
|
-
|
|
130
|
+
const u = new URL(podBase);
|
|
131
|
+
host = u.host.toLowerCase();
|
|
132
|
+
mount = mountOf(podBase);
|
|
111
133
|
}
|
|
112
134
|
catch {
|
|
113
135
|
throw new Error(`not a pod URL: ${podBase}`);
|
|
114
136
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
137
|
+
if (mount === '') {
|
|
138
|
+
// A pod at the root of its host: it owns the whole origin, so it cannot
|
|
139
|
+
// share it with the front or with any other claim.
|
|
140
|
+
if (host.split(':')[0] === String(this.frontHost).toLowerCase()) {
|
|
141
|
+
throw new Error(`${podBase} is on the front's own host — give the identity its own origin, or host it on a path`);
|
|
142
|
+
}
|
|
143
|
+
for (const c of this.claimed.values()) {
|
|
144
|
+
if (c.host === host) {
|
|
145
|
+
throw new Error(`the host ${host} already carries an identity — a host-root identity needs an origin of its own`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
119
148
|
}
|
|
120
|
-
|
|
121
|
-
|
|
149
|
+
else {
|
|
150
|
+
// A pod on a path: it owns only its subtree. Refuse anything that already
|
|
151
|
+
// contains it or that it would contain, so no identity can answer under
|
|
152
|
+
// another's path (and none straddles the front's own routes underneath).
|
|
153
|
+
for (const c of this.claimed.values()) {
|
|
154
|
+
if (c.host !== host)
|
|
155
|
+
continue;
|
|
156
|
+
if (pathContains(c.mount, mount) || pathContains(mount, c.mount)) {
|
|
157
|
+
throw new Error(`${podBase} nests with the identity already at ${c.podBase} — a path pod owns only its own subtree`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
122
160
|
}
|
|
123
161
|
const handle = deriveHandle(podBase);
|
|
124
162
|
const holder = this.agentHandles.get(handle);
|
|
125
163
|
if (holder && holder !== podBase) {
|
|
126
164
|
throw new Error(`the name ${handle} already belongs to ${holder} — two identities cannot share it`);
|
|
127
165
|
}
|
|
128
|
-
return host;
|
|
166
|
+
return { host, mount, podBase, handle };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The claim a request belongs to, or null. A request matches when its host is
|
|
170
|
+
* the claim's host and its path is at or under the claim's mount; the deepest
|
|
171
|
+
* mount wins, so a suffix pod's own routes are never swallowed by a shallower
|
|
172
|
+
* claim on the same host.
|
|
173
|
+
*/
|
|
174
|
+
resolveClaim(host, pathname) {
|
|
175
|
+
let best = null;
|
|
176
|
+
for (const c of this.claimed.values()) {
|
|
177
|
+
if (c.host !== host)
|
|
178
|
+
continue;
|
|
179
|
+
if (c.mount === '' || pathname === c.mount || pathname.startsWith(c.mount + '/')) {
|
|
180
|
+
if (!best || c.mount.length > best.mount.length)
|
|
181
|
+
best = c;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return best;
|
|
185
|
+
}
|
|
186
|
+
/** A request path relative to a mount: `/aisha/ap/actor` under `/aisha` → `/ap/actor`. */
|
|
187
|
+
stripMount(pathname, mount) {
|
|
188
|
+
if (!mount)
|
|
189
|
+
return pathname;
|
|
190
|
+
return pathname.slice(mount.length) || '/';
|
|
129
191
|
}
|
|
130
192
|
/**
|
|
131
193
|
* Start an agent for each opted-in pod. Runs before the server listens, so
|
|
@@ -164,21 +226,22 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
|
|
|
164
226
|
// persist, and the next start recovers them.
|
|
165
227
|
const pods = [];
|
|
166
228
|
try {
|
|
167
|
-
const
|
|
168
|
-
for (const
|
|
169
|
-
const row = await this.registry.get(
|
|
229
|
+
const keys = await this.registry.listKeys();
|
|
230
|
+
for (const key of keys) {
|
|
231
|
+
const row = await this.registry.get(key);
|
|
170
232
|
if (!row)
|
|
171
233
|
continue;
|
|
172
|
-
if (this.
|
|
234
|
+
if (this.claimed.has(row.podBase) || this.identities.has(row.podBase))
|
|
173
235
|
continue; // already claimed
|
|
236
|
+
let claim;
|
|
174
237
|
try {
|
|
175
|
-
this.
|
|
238
|
+
claim = this.validateAgentPod(row.podBase);
|
|
176
239
|
}
|
|
177
240
|
catch (e) {
|
|
178
241
|
this.logger.error(`opted-in pod ${row.podBase} no longer valid: ${e.message}`);
|
|
179
242
|
continue;
|
|
180
243
|
}
|
|
181
|
-
this.
|
|
244
|
+
this.claimed.set(row.podBase, claim);
|
|
182
245
|
this.agentHandles.set(row.handle, row.podBase);
|
|
183
246
|
pods.push(row.podBase);
|
|
184
247
|
}
|
|
@@ -266,10 +329,15 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
|
|
|
266
329
|
return;
|
|
267
330
|
}
|
|
268
331
|
this.identities.set(podBase, identity);
|
|
269
|
-
this.surfaces.set(
|
|
332
|
+
this.surfaces.set(podBase, identity);
|
|
270
333
|
this.logger.info(`FediPod agent @${identity.handle} running on ${podBase}`);
|
|
271
|
-
|
|
334
|
+
// A suffix pod cannot answer WebFinger for itself — its host root is
|
|
335
|
+
// the front's — so it is followable ONLY through the door's apex
|
|
336
|
+
// dispatch. Front it whether or not auto-front is on: the row is the
|
|
337
|
+
// whole of what makes @handle@host resolve to it.
|
|
338
|
+
if (this.args.agentAutoFront || this.claimed.get(podBase)?.mount) {
|
|
272
339
|
await this.frontIdentity(podBase, identity);
|
|
340
|
+
}
|
|
273
341
|
return;
|
|
274
342
|
}
|
|
275
343
|
catch (e) {
|
|
@@ -318,14 +386,20 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
|
|
|
318
386
|
}
|
|
319
387
|
}
|
|
320
388
|
async canHandle({ request }) {
|
|
321
|
-
const host = request.headers.host;
|
|
389
|
+
const host = String(request.headers.host ?? '').toLowerCase();
|
|
322
390
|
const pathname = new URL(request.url ?? '/', `https://${host}`).pathname;
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
391
|
+
// The front's own routes win first, so a suffix pod can never shadow the
|
|
392
|
+
// door's dispatch, its WebFinger or its API even where its mount would
|
|
393
|
+
// otherwise contain that path.
|
|
326
394
|
if ((0, claims_1.claims)({ host, pathname }, this.frontHost))
|
|
327
395
|
return;
|
|
328
|
-
|
|
396
|
+
// Claimed from the opt-in roster, never from what is running: a pod resource
|
|
397
|
+
// must not be served by CSS for the seconds before an identity finishes
|
|
398
|
+
// starting, and then stop being served once it has. The path is matched
|
|
399
|
+
// relative to the claim's mount, so a suffix pod's `/aisha/ap/actor` is
|
|
400
|
+
// judged as `/ap/actor`.
|
|
401
|
+
const c = this.resolveClaim(host, pathname);
|
|
402
|
+
if (c && (0, claims_1.agentClaims)({ pathname: this.stripMount(pathname, c.mount), method: request.method }, this.uiPath))
|
|
329
403
|
return;
|
|
330
404
|
throw new Error('not a gateway route'); // reject → CSS's LDP handler takes it
|
|
331
405
|
}
|
|
@@ -340,9 +414,17 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
|
|
|
340
414
|
const cluster = this.args.clusterManager;
|
|
341
415
|
return !cluster || cluster.isSingleThreaded() || cluster.isPrimary();
|
|
342
416
|
}
|
|
343
|
-
/**
|
|
344
|
-
|
|
345
|
-
|
|
417
|
+
/**
|
|
418
|
+
* The identity a request belongs to (matched by host and mount) and the
|
|
419
|
+
* request path relative to that identity's mount, or null. The identity is
|
|
420
|
+
* undefined when the pod is claimed but still starting. Used by the streaming
|
|
421
|
+
* upgrade, which has only the request to go on.
|
|
422
|
+
*/
|
|
423
|
+
matchIdentity(host, pathname = '/') {
|
|
424
|
+
const c = this.resolveClaim(String(host ?? '').toLowerCase(), pathname);
|
|
425
|
+
if (!c)
|
|
426
|
+
return null;
|
|
427
|
+
return { identity: this.surfaces.get(c.podBase), rel: this.stripMount(pathname, c.mount) };
|
|
346
428
|
}
|
|
347
429
|
/**
|
|
348
430
|
* A pod owner, already proven to control podBase, asks this server to run
|
|
@@ -366,13 +448,14 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
|
|
|
366
448
|
return { httpStatus: 201, ok: true, handle, host: new URL(base).host.toLowerCase(),
|
|
367
449
|
doorSecret: door.secret, doorPath: this.uiPath, status: 'rotated' };
|
|
368
450
|
}
|
|
369
|
-
let
|
|
451
|
+
let claim;
|
|
370
452
|
try {
|
|
371
|
-
|
|
453
|
+
claim = this.validateAgentPod(base);
|
|
372
454
|
}
|
|
373
455
|
catch (e) {
|
|
374
456
|
return { httpStatus: 409, error: e.message };
|
|
375
457
|
}
|
|
458
|
+
const { host } = claim;
|
|
376
459
|
try {
|
|
377
460
|
await this.registry.add({ podBase: base, handle, host, webId, optedInAt: new Date().toISOString() });
|
|
378
461
|
}
|
|
@@ -382,7 +465,7 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
|
|
|
382
465
|
}
|
|
383
466
|
// From this instant the pod's identity routes answer 503 instead of LDP,
|
|
384
467
|
// until the agent registers its surface.
|
|
385
|
-
this.
|
|
468
|
+
this.claimed.set(base, claim);
|
|
386
469
|
this.agentHandles.set(handle, base);
|
|
387
470
|
const door = await this.doorSecretFor(base, undefined, { rotate: true });
|
|
388
471
|
this.doorSecrets.set(base, door.secret);
|
|
@@ -397,19 +480,20 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
|
|
|
397
480
|
return { httpStatus: 501, error: 'this server does not offer runtime opt-in' };
|
|
398
481
|
const base = podBase.endsWith('/') ? podBase : `${podBase}/`;
|
|
399
482
|
const host = new URL(base).host.toLowerCase();
|
|
400
|
-
const
|
|
483
|
+
const key = (0, directory_1.agentKey)(host, base);
|
|
484
|
+
const row = await this.registry.get(key);
|
|
401
485
|
if (!row || row.podBase !== base)
|
|
402
486
|
return { httpStatus: 404, error: 'this pod has not opted in' };
|
|
403
|
-
this.
|
|
487
|
+
this.claimed.delete(base); // routes fall to LDP now
|
|
404
488
|
this.startCancelled.add(base); // a pending start stands down
|
|
405
489
|
const identity = this.identities.get(base);
|
|
406
490
|
this.identities.delete(base);
|
|
407
|
-
this.surfaces.delete(
|
|
491
|
+
this.surfaces.delete(base);
|
|
408
492
|
this.agentHandles.delete(row.handle);
|
|
409
493
|
this.doorSecrets.delete(base);
|
|
410
494
|
if (identity)
|
|
411
495
|
await identity.stop();
|
|
412
|
-
await this.registry.remove(
|
|
496
|
+
await this.registry.remove(key);
|
|
413
497
|
this.logger.info(`runtime opt-out: @${row.handle} on ${base} — the pod serves plain LDP again`);
|
|
414
498
|
return { httpStatus: 200, ok: true, stopped: Boolean(identity) };
|
|
415
499
|
}
|
|
@@ -442,8 +526,13 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
|
|
|
442
526
|
}
|
|
443
527
|
async handle({ request, response }) {
|
|
444
528
|
const host = String(request.headers.host ?? '').toLowerCase();
|
|
445
|
-
|
|
446
|
-
|
|
529
|
+
const pathname = new URL(request.url ?? '/', `https://${host}`).pathname;
|
|
530
|
+
// The front's own routes win first — its dispatch, WebFinger and API sit at
|
|
531
|
+
// the apex above every suffix pod — so a claim is consulted only where the
|
|
532
|
+
// front does not answer.
|
|
533
|
+
const claimed = (0, claims_1.claims)({ host, pathname }, this.frontHost) ? null : this.resolveClaim(host, pathname);
|
|
534
|
+
if (claimed) {
|
|
535
|
+
const identity = this.surfaces.get(claimed.podBase);
|
|
447
536
|
if (!identity) {
|
|
448
537
|
// Claimed, but nothing here can answer for it. Saying which of the two
|
|
449
538
|
// reasons it is beats letting the pod answer for a route that is not
|
|
@@ -459,8 +548,10 @@ class FediPodServerHandler extends community_server_1.HttpHandler {
|
|
|
459
548
|
response.end(JSON.stringify({ error: 'this identity is still starting' }));
|
|
460
549
|
return;
|
|
461
550
|
}
|
|
462
|
-
|
|
463
|
-
|
|
551
|
+
// This identity's own inbox path — <mount>/<root>/ap/inbox/, from its
|
|
552
|
+
// actor, so it already carries the mount for a suffix pod.
|
|
553
|
+
const inboxPath = new URL(identity.actorUrl).pathname.replace(/ap\/actor$/u, 'ap/inbox/');
|
|
554
|
+
if (pathname === inboxPath && String(request.method).toUpperCase() === 'POST') {
|
|
464
555
|
await this.deliverAtDoor(identity, request, response);
|
|
465
556
|
return;
|
|
466
557
|
}
|
package/dist/handler.jsonld
CHANGED
|
@@ -249,8 +249,8 @@
|
|
|
249
249
|
"memberFieldName": "logger"
|
|
250
250
|
},
|
|
251
251
|
{
|
|
252
|
-
"@id": "fps:dist/handler.jsonld#
|
|
253
|
-
"memberFieldName": "
|
|
252
|
+
"@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_claimed",
|
|
253
|
+
"memberFieldName": "claimed"
|
|
254
254
|
},
|
|
255
255
|
{
|
|
256
256
|
"@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_agentHandles",
|
|
@@ -301,8 +301,16 @@
|
|
|
301
301
|
"memberFieldName": "constructor"
|
|
302
302
|
},
|
|
303
303
|
{
|
|
304
|
-
"@id": "fps:dist/handler.jsonld#
|
|
305
|
-
"memberFieldName": "
|
|
304
|
+
"@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_validateAgentPod",
|
|
305
|
+
"memberFieldName": "validateAgentPod"
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
"@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_resolveClaim",
|
|
309
|
+
"memberFieldName": "resolveClaim"
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
"@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_stripMount",
|
|
313
|
+
"memberFieldName": "stripMount"
|
|
306
314
|
},
|
|
307
315
|
{
|
|
308
316
|
"@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_initialize",
|
|
@@ -333,8 +341,8 @@
|
|
|
333
341
|
"memberFieldName": "runsIdentities"
|
|
334
342
|
},
|
|
335
343
|
{
|
|
336
|
-
"@id": "fps:dist/handler.jsonld#
|
|
337
|
-
"memberFieldName": "
|
|
344
|
+
"@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_matchIdentity",
|
|
345
|
+
"memberFieldName": "matchIdentity"
|
|
338
346
|
},
|
|
339
347
|
{
|
|
340
348
|
"@id": "fps:dist/handler.jsonld#FediPodServerHandler__member_optInPod",
|
|
@@ -23,12 +23,17 @@ class FediPodStreamingHandler extends community_server_1.WebSocketHandler {
|
|
|
23
23
|
async canHandle({ upgradeRequest }) {
|
|
24
24
|
const host = upgradeRequest.headers.host;
|
|
25
25
|
const { pathname } = new URL(upgradeRequest.url ?? '/', `http://${host}`);
|
|
26
|
-
|
|
26
|
+
// Matched relative to the identity's mount, so a suffix pod's own streaming
|
|
27
|
+
// socket at `/aisha/api/v1/streaming` is recognised as `/api/v1/streaming`.
|
|
28
|
+
const match = this.server.matchIdentity(host, pathname);
|
|
29
|
+
if (!match || !match.rel.startsWith(STREAMING_PATH)) {
|
|
27
30
|
throw new Error('not a FediPod streaming socket');
|
|
28
31
|
}
|
|
29
32
|
}
|
|
30
33
|
async handle({ webSocket, upgradeRequest }) {
|
|
31
|
-
const
|
|
34
|
+
const host = upgradeRequest.headers.host;
|
|
35
|
+
const { pathname } = new URL(upgradeRequest.url ?? '/', `http://${host}`);
|
|
36
|
+
const identity = this.server.matchIdentity(host, pathname)?.identity;
|
|
32
37
|
const streaming = identity?.surface?.streaming;
|
|
33
38
|
if (!streaming) {
|
|
34
39
|
webSocket.close(1011, 'identity not running');
|
|
@@ -32,8 +32,14 @@ export { attachmentType, extensionFor } from './media.mjs';
|
|
|
32
32
|
|
|
33
33
|
export class MastoApi {
|
|
34
34
|
constructor({ agent, log = console.log, allowed = null, scheme = null, embedded = false,
|
|
35
|
-
streaming = true, webPush = true, scheduling = true }) {
|
|
35
|
+
mount = '', streaming = true, webPush = true, scheduling = true }) {
|
|
36
36
|
this.agent = agent;
|
|
37
|
+
// The path this identity's surface answers under, when it shares its origin
|
|
38
|
+
// with others (a suffix pod, e.g. `/aisha`). Empty for a host-root or
|
|
39
|
+
// subdomain pod. Folded into the self-URLs the client is handed —
|
|
40
|
+
// pagination links, the OAuth issuer — so they name the address the client
|
|
41
|
+
// actually reached.
|
|
42
|
+
this.mount = mount;
|
|
37
43
|
// A server-hosted identity has no CLI of its own, so the advice this gives
|
|
38
44
|
// when it refuses has to name the route that identity really has.
|
|
39
45
|
this.embedded = embedded;
|
|
@@ -254,7 +254,7 @@ export async function handle(api, ctx) {
|
|
|
254
254
|
|
|
255
255
|
// A client pages by following these rather than by guessing ids.
|
|
256
256
|
if (page.length) {
|
|
257
|
-
const base = `${api.scheme || (req.socket?.encrypted ? 'https' : 'http')}://${req.headers.host}${pathname}`;
|
|
257
|
+
const base = `${api.scheme || (req.socket?.encrypted ? 'https' : 'http')}://${req.headers.host}${api.mount || ''}${pathname}`;
|
|
258
258
|
const link = (params) => {
|
|
259
259
|
const u = new URL(base);
|
|
260
260
|
for (const [k, v] of q) if (k !== 'max_id' && k !== 'since_id' && k !== 'min_id') u.searchParams.append(k, v);
|
package/lib/client/oidc-auth.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// The verifier is injected so offline tests stub it, and wrapped so the
|
|
9
9
|
// library (CJS, older jose) can be replaced without touching any caller.
|
|
10
10
|
|
|
11
|
-
export function makeC2sAuth({ agent, masto = null, verifier = null, log = () => {}, scheme = null }) {
|
|
11
|
+
export function makeC2sAuth({ agent, masto = null, verifier = null, log = () => {}, scheme = null, mount = '' }) {
|
|
12
12
|
let verify = verifier;
|
|
13
13
|
const loadVerifier = async () => {
|
|
14
14
|
if (!verify) {
|
|
@@ -31,9 +31,11 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
|
|
|
31
31
|
// The URL the client signed its proof over. The Host header already
|
|
32
32
|
// passed the Authorities firewall, so whichever alias the client used
|
|
33
33
|
// (localhost, 127.0.0.1, the named origin) is one this agent answers on;
|
|
34
|
-
// the scheme is whichever listener the request arrived on.
|
|
34
|
+
// the scheme is whichever listener the request arrived on. `pathname` is
|
|
35
|
+
// relative to this identity's mount, so a suffix pod folds the mount back
|
|
36
|
+
// in — the client signed over the full path it actually requested.
|
|
35
37
|
const htu = `${scheme ? scheme.replace(/:$/u, '') : req.socket?.encrypted ? 'https' : 'http'
|
|
36
|
-
}://${req.headers.host}${pathname}`;
|
|
38
|
+
}://${req.headers.host}${mount}${pathname}`;
|
|
37
39
|
({ webid } = await v(
|
|
38
40
|
req.headers.authorization,
|
|
39
41
|
req.headers.dpop ? { header: req.headers.dpop, method: req.method, url: htu } : undefined,
|
package/lib/core/wire.mjs
CHANGED
|
@@ -20,7 +20,10 @@ export { webfingerHost } from '../pod/urls.mjs';
|
|
|
20
20
|
// contains; the browser build states `fedipod/` on its own configs. A config
|
|
21
21
|
// that names a root is always believed — this is only the answer for one that
|
|
22
22
|
// does not.
|
|
23
|
-
|
|
23
|
+
// Every build writes its data into a `fedipod/` container in the pod. This is
|
|
24
|
+
// the answer for a config that names no root; a config that names one is always
|
|
25
|
+
// believed. (`activitypods-js/` was an earlier name, now abandoned.)
|
|
26
|
+
export const DEFAULT_ROOT = 'fedipod/';
|
|
24
27
|
|
|
25
28
|
// The handle the fediverse sees: a fronted identity's name is the front's.
|
|
26
29
|
export function publicHandle(config) {
|
|
@@ -47,7 +47,7 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
|
|
|
47
47
|
};
|
|
48
48
|
const podActorId = () => {
|
|
49
49
|
const base = cfg.remotePod.endsWith('/') ? cfg.remotePod : `${cfg.remotePod}/`;
|
|
50
|
-
const root = cfg.root ? (cfg.root.endsWith('/') ? cfg.root : `${cfg.root}/`) : '
|
|
50
|
+
const root = cfg.root ? (cfg.root.endsWith('/') ? cfg.root : `${cfg.root}/`) : 'fedipod/';
|
|
51
51
|
return `${base}${root}ap/actor`;
|
|
52
52
|
};
|
|
53
53
|
// The reply first, the restart a beat later — same shape as /update.
|
|
@@ -76,14 +76,14 @@ const ROUTES = [owner, setup, lifecycle, gateway, social, connections];
|
|
|
76
76
|
// a fediverse instance must let strangers reach /api and /oauth, so the gate
|
|
77
77
|
// guards the operator's door (basePath) instead of the whole surface.
|
|
78
78
|
export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
79
|
-
port = null, handle = null, embedded = false, basePath = '/',
|
|
79
|
+
port = null, handle = null, embedded = false, basePath = '/', mount = '',
|
|
80
80
|
publicOrigin = null, scheme = null,
|
|
81
81
|
versionOnDisk = () => localVersion(projectRoot) }) {
|
|
82
82
|
const json = (res, status, obj) => sendJson(res, status, obj, allowed);
|
|
83
|
-
const masto = new MastoApi({ agent, log, allowed, scheme, embedded });
|
|
83
|
+
const masto = new MastoApi({ agent, log, allowed, scheme, embedded, mount });
|
|
84
84
|
// The spec's own write API (§6), beside the facade. Its bearer fallback is
|
|
85
85
|
// the facade's token, so the two surfaces share one notion of the operator.
|
|
86
|
-
const c2s = new C2S({ agent, log, auth: makeC2sAuth({ agent, masto, log, scheme }) });
|
|
86
|
+
const c2s = new C2S({ agent, log, auth: makeC2sAuth({ agent, masto, log, scheme, mount }) });
|
|
87
87
|
const streaming = new Streaming({ masto, log, allowed, gate, gateOptional: embedded });
|
|
88
88
|
// Asked per request, not once here: startAdmin runs before connect, so the
|
|
89
89
|
// kind is not known yet at mount time.
|
|
@@ -104,21 +104,29 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
104
104
|
} catch (e) { log(`streaming broadcast: ${e.message}`); }
|
|
105
105
|
};
|
|
106
106
|
|
|
107
|
-
// A path as the browser must ask for it:
|
|
108
|
-
|
|
107
|
+
// A path as the browser must ask for it: under this identity's mount (a
|
|
108
|
+
// suffix pod's own path, or nothing) and behind the door, prefixed with it.
|
|
109
|
+
const atPath = (p_) => mount + (basePath === '/' ? p_ : basePath.slice(0, -1) + p_);
|
|
109
110
|
|
|
110
111
|
// What every route may reach: the agent and the deployment's facts.
|
|
111
112
|
const ctx = { agent, log, allowed, embedded, port, handle, publicOrigin, versionOnDisk, isGroup, json, setup: setup_ };
|
|
112
113
|
|
|
113
114
|
const handler = async (req, res) => {
|
|
114
115
|
const url = new URL(req.url, 'http://localhost');
|
|
116
|
+
// A suffix pod's surface answers under its mount (its own path on a shared
|
|
117
|
+
// host). Strip it once, here, so every route below is matched relative to
|
|
118
|
+
// the mount and a host-root/subdomain pod (empty mount) is unchanged. The
|
|
119
|
+
// full path stays on `url`/`req.url` for self-URLs that fold the mount back
|
|
120
|
+
// in themselves (the pagination base, the DPoP htu).
|
|
121
|
+
let p = url.pathname;
|
|
122
|
+
if (mount && (p === mount || p.startsWith(mount + '/'))) p = p.slice(mount.length) || '/';
|
|
115
123
|
// Mastodon-style: the bearer-gated client API and the OAuth + nodeinfo
|
|
116
124
|
// routes answer any origin — a browser client is served the way any
|
|
117
125
|
// instance serves it. CORS headers and the preflight make that work; the
|
|
118
126
|
// bearer stays the only credential, and the Host check below (which is
|
|
119
127
|
// what stops DNS rebinding) still runs.
|
|
120
|
-
const apiPath =
|
|
121
|
-
||
|
|
128
|
+
const apiPath = p.startsWith('/api/') || p.startsWith('/oauth/')
|
|
129
|
+
|| p === '/.well-known/nodeinfo' || p === '/nodeinfo/2.0';
|
|
122
130
|
if (apiPath) {
|
|
123
131
|
res.setHeader('access-control-allow-origin', '*');
|
|
124
132
|
res.setHeader('access-control-expose-headers', 'Link');
|
|
@@ -142,11 +150,11 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
142
150
|
res.end('forbidden\n');
|
|
143
151
|
return;
|
|
144
152
|
}
|
|
145
|
-
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
153
|
+
// Embedded, the operator's door is one path on the pod's origin (under the
|
|
154
|
+
// mount, when there is one). Behind it is everything that was the admin
|
|
155
|
+
// server; in front of it are the protocol routes, which have to answer
|
|
156
|
+
// strangers because that is what makes the pod an instance other software
|
|
157
|
+
// can talk to.
|
|
150
158
|
let atDoor = !embedded;
|
|
151
159
|
if (embedded && basePath !== '/'
|
|
152
160
|
&& (p === basePath.slice(0, -1) || p.startsWith(basePath))) {
|
|
@@ -171,7 +179,7 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
171
179
|
// above still decides who gets this far.
|
|
172
180
|
if (p === '/.well-known/oauth-authorization-server') {
|
|
173
181
|
const scheme = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
|
|
174
|
-
return json(res, 200, masto.authorizationServerMetadata(`${scheme}://${req.headers.host}`));
|
|
182
|
+
return json(res, 200, masto.authorizationServerMetadata(`${scheme}://${req.headers.host}${mount}`));
|
|
175
183
|
}
|
|
176
184
|
if (atDoor && gate(req, res)) return;
|
|
177
185
|
if (p === '/api/v1/streaming/health') {
|
|
@@ -180,7 +188,7 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
180
188
|
// NodeInfo on the agent origin — clients probe it at login.
|
|
181
189
|
if (p === '/.well-known/nodeinfo') {
|
|
182
190
|
return json(res, 200, nodeinfoPointer(
|
|
183
|
-
`${req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'}://${req.headers.host}/nodeinfo/2.0`));
|
|
191
|
+
`${req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'}://${req.headers.host}${mount}/nodeinfo/2.0`));
|
|
184
192
|
}
|
|
185
193
|
if (p === '/nodeinfo/2.0') {
|
|
186
194
|
return json(res, 200, nodeinfoDoc({
|
|
@@ -206,8 +214,8 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
206
214
|
// Our own pages come before the group check: a group is set up in the
|
|
207
215
|
// browser like anything else, and it has a record to edit. It still
|
|
208
216
|
// serves no fediverse client — see the 404 two lines down.
|
|
209
|
-
const
|
|
210
|
-
if (
|
|
217
|
+
const wmount = webMount(p);
|
|
218
|
+
if (wmount) {
|
|
211
219
|
// Without the slash a page's own relative <script src> resolves one
|
|
212
220
|
// level up and 404s — and that is true at any depth, so ask the
|
|
213
221
|
// filesystem rather than only special-casing the mount itself.
|
|
@@ -217,7 +225,7 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
217
225
|
res.end();
|
|
218
226
|
return;
|
|
219
227
|
}
|
|
220
|
-
return serveWeb(res, p,
|
|
228
|
+
return serveWeb(res, p, wmount, allowed);
|
|
221
229
|
}
|
|
222
230
|
// The bare URL means "show me what this agent wants from me now".
|
|
223
231
|
// Keyed on the credential FILE, never on configured(): a healthy
|
|
@@ -18,7 +18,7 @@ export async function setup() {
|
|
|
18
18
|
if (process.stdin.isTTY && !has('cli') && !IDENTITY_FLAGS.some(f => args.includes('--' + f))) {
|
|
19
19
|
return runBrowserSetup();
|
|
20
20
|
}
|
|
21
|
-
const root = flag('root');
|
|
21
|
+
const root = flag('root') || 'fedipod/'; // new installs default to the fedipod/ container
|
|
22
22
|
const kind = has('group') ? 'group' : 'person';
|
|
23
23
|
const approveJoins = has('group') && has('approve-joins');
|
|
24
24
|
const summary = flag('summary');
|
|
@@ -48,6 +48,14 @@ if (!newAccount && !pod) {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
if (!newAccount && !pod) { console.error('no pod given'); process.exit(2); }
|
|
51
|
+
if (!newAccount) {
|
|
52
|
+
const { resourceExists } = await import(new URL('../../../../lib/pod/root.mjs', import.meta.url));
|
|
53
|
+
const { apUrls, DEFAULT_ROOT: DR } = await import(new URL('../../../../lib/core/wire.mjs', import.meta.url));
|
|
54
|
+
if (await resourceExists(fetch, apUrls(pod, DR).actor)) {
|
|
55
|
+
console.error('The pod already hosts a FediPod account. If you want a second account, put it on a different pod.');
|
|
56
|
+
process.exit(2);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
51
59
|
|
|
52
60
|
const issuer = flag('issuer') || await ask('Solid identity provider', 'https://solidcommunity.net');
|
|
53
61
|
// Before the password is asked for, let alone sent. The issuer is where it
|
package/lib/device/setup.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { hashPassword } from '../client/masto/index.mjs';
|
|
|
19
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#');
|
|
@@ -222,6 +223,8 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
222
223
|
gateway = null, shape = 'pod', gatewayOrigin = 'https://fedipod.net',
|
|
223
224
|
} = answers;
|
|
224
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.
|
|
225
228
|
let accountWebId = null; // what createAccountWithPod reported, when it ran
|
|
226
229
|
// The private half always starts here, beside the credential and the keys —
|
|
227
230
|
// not on the pod. Every activity you receive would otherwise cost the pod
|
|
@@ -263,6 +266,9 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
263
266
|
// reachable, and no silent 401 later on a pod whose profile is empty.
|
|
264
267
|
const usable = await checkPod(pod);
|
|
265
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
|
+
}
|
|
266
272
|
skip('account', 'using the pod you already have');
|
|
267
273
|
}
|
|
268
274
|
|