fedipod-server 0.14.0 → 0.15.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/client/masto/index.mjs +6 -1
- package/lib/core/intake/activities.mjs +10 -2
- package/lib/core/intake/index.mjs +1 -1
- package/lib/core/intake/notes.mjs +4 -2
- package/lib/core/social.mjs +17 -6
- package/lib/core/wire.mjs +2 -1
- package/lib/gateway/front-core.mjs +22 -3
- package/lib/pod/inbox.mjs +4 -1
- package/lib/pod/root.mjs +27 -0
- package/lib/pod/transport.mjs +7 -2
- package/lib/pod/urls.mjs +14 -0
- package/package.json +1 -1
- package/web/app/README.md +1 -1
- package/web/app/agent.mjs +21 -5
- package/web/app/boot.mjs +80 -20
- package/web/app/dist/boot.js +187 -49
- package/web/app/dist/boot.js.map +3 -3
- package/web/app/dist/sw.js +46 -22
- package/web/app/dist/sw.js.map +2 -2
- package/web/app/index.html +46 -8
- package/web/app/keys-browser.mjs +8 -3
- package/web/app/signup.mjs +68 -40
- package/web/app/site/boot.js +187 -49
- package/web/app/site/index.html +46 -8
- package/web/app/site/sw.js +46 -22
|
@@ -78,7 +78,12 @@ export class MastoApi {
|
|
|
78
78
|
});
|
|
79
79
|
return this._push;
|
|
80
80
|
}
|
|
81
|
-
|
|
81
|
+
// The host in the owner's own address: the gateway's when the identity is
|
|
82
|
+
// fronted (its documents still live on the pod, but its name does not).
|
|
83
|
+
get host() {
|
|
84
|
+
if (!this.urls) return 'unconfigured.invalid';
|
|
85
|
+
return new URL(this.urls.publicHome || this.urls.base).host;
|
|
86
|
+
}
|
|
82
87
|
|
|
83
88
|
// Where the live feed is, as the CLIENT must address it: this agent's own
|
|
84
89
|
// origin, taken from the request, not the pod's host. An instance document
|
|
@@ -197,7 +197,7 @@ export async function onUndo(intake, activity, actor, { trusted = false } = {})
|
|
|
197
197
|
intake.log(`unfollowed by ${actor}`);
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
-
export async function onCreate(intake, activity, actor) {
|
|
200
|
+
export async function onCreate(intake, activity, actor, { trusted = false } = {}) {
|
|
201
201
|
const objectId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
|
|
202
202
|
if (!objectId) return 'Create without object id';
|
|
203
203
|
if (intake.store.isBlocked(objectId)) return `blocked domain (${objectId})`;
|
|
@@ -219,7 +219,15 @@ export async function onCreate(intake, activity, actor) {
|
|
|
219
219
|
const ingested = intake.store.getStatuses()
|
|
220
220
|
.some(x => x.noteId === objectId && (x.kind === 'timeline' || x.kind === 'mention'));
|
|
221
221
|
if (!ingested) {
|
|
222
|
-
|
|
222
|
+
// A verified delivery that carries the note inline is read from that
|
|
223
|
+
// copy: the door checked the sender's signature over these very bytes,
|
|
224
|
+
// and the receipt vouches for this actor. That is the only way a direct
|
|
225
|
+
// or followers-only post can ever be read — it lives in the sender's
|
|
226
|
+
// owner-only container, and fetching it from there answers 401 to
|
|
227
|
+
// everyone. An unverified delivery still fetches, as it always did.
|
|
228
|
+
const inline = trusted && typeof activity.object === 'object' && activity.object?.id === objectId
|
|
229
|
+
? activity.object : null;
|
|
230
|
+
const rejected = await intake.ingestNote(objectId, actor, { inline });
|
|
223
231
|
if (rejected) return rejected;
|
|
224
232
|
}
|
|
225
233
|
// A group carries its members' posts onward. Only reached from Create, so an
|
|
@@ -539,7 +539,7 @@ export class Intake {
|
|
|
539
539
|
switch (activity.type) {
|
|
540
540
|
case 'Follow': return this.onFollow(activity, actor, { trusted });
|
|
541
541
|
case 'Undo': return this.onUndo(activity, actor, { trusted });
|
|
542
|
-
case 'Create': return this.onCreate(activity, actor);
|
|
542
|
+
case 'Create': return this.onCreate(activity, actor, { trusted });
|
|
543
543
|
case 'Accept': return this.onAccept(activity, actor, { trusted });
|
|
544
544
|
case 'Like': case 'Announce': {
|
|
545
545
|
// FEP-1b12: a group Announces the member's whole Create, not the note.
|
|
@@ -114,8 +114,10 @@ export function referencesOurObject(intake, activity) {
|
|
|
114
114
|
// Shared tail of Create/Announce: deref the note at its origin (never trust
|
|
115
115
|
// the delivered copy), mirror it into pod RDF + statuses, notify on replies
|
|
116
116
|
// to our own notes. Returns a rejection reason string, or undefined.
|
|
117
|
-
export async function ingestNote(intake, objectId, actor, { via } = {}) {
|
|
118
|
-
|
|
117
|
+
export async function ingestNote(intake, objectId, actor, { via, inline = null } = {}) {
|
|
118
|
+
// `inline` is the note as delivered, offered only for a delivery whose
|
|
119
|
+
// signature the door verified (onCreate); otherwise the origin's copy.
|
|
120
|
+
const note = inline || await intake.fetchAP(objectId);
|
|
119
121
|
if (!note) return `object fetch failed (${objectId})`;
|
|
120
122
|
if (note.id !== objectId || !isContentType(note.type)) return `object not verifiable content (${objectId}, ${note.type})`;
|
|
121
123
|
const { attachmentsOf, titledContent } = await import('../wire.mjs');
|
package/lib/core/social.mjs
CHANGED
|
@@ -15,7 +15,14 @@ import { dropFollower } from './store.mjs';
|
|
|
15
15
|
// belongs behind the same SSRF guard as every other outbound request. fedify's
|
|
16
16
|
// lookup did not go through ours. https only, because a handle resolved over
|
|
17
17
|
// plaintext is a handle anyone on the path can answer for.
|
|
18
|
-
|
|
18
|
+
//
|
|
19
|
+
// `fetchImpl`, when given, makes the request instead of safeFetch: the agent's
|
|
20
|
+
// own remote read, which in the browser build is the relay — a server-side
|
|
21
|
+
// fetch, so the lookup does not depend on the far pod host answering a
|
|
22
|
+
// browser's cross-origin request. Every other remote read there goes the
|
|
23
|
+
// same way; WebFinger went direct and was the one lookup that could fail
|
|
24
|
+
// while the rest worked.
|
|
25
|
+
export async function lookupWebFinger(acct, { fetchImpl = null } = {}) {
|
|
19
26
|
const clean = String(acct || '').replace(/^acct:/, '');
|
|
20
27
|
const at = clean.lastIndexOf('@');
|
|
21
28
|
if (at < 1) return null;
|
|
@@ -23,9 +30,8 @@ export async function lookupWebFinger(acct) {
|
|
|
23
30
|
if (!host || /[/\\?#]/.test(host)) return null;
|
|
24
31
|
const { safeFetch, readCapped } = await import('../shared/safefetch.mjs');
|
|
25
32
|
const url = `https://${host}/.well-known/webfinger?resource=${encodeURIComponent(`acct:${clean}`)}`;
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
}).catch(() => null);
|
|
33
|
+
const headers = { accept: 'application/jrd+json, application/json' };
|
|
34
|
+
const res = await (fetchImpl ? fetchImpl(url, { headers }) : safeFetch(url, { headers })).catch(() => null);
|
|
29
35
|
if (!res || res.status >= 400) return null;
|
|
30
36
|
let jrd;
|
|
31
37
|
try { jrd = JSON.parse(await readCapped(res, 256 * 1024)); } catch { return null; }
|
|
@@ -80,13 +86,18 @@ export async function resolveHandle(agent, handle) {
|
|
|
80
86
|
const clean = String(handle || '').replace(/^@/, '');
|
|
81
87
|
if (!/^[^@]+@[^@]+$/.test(clean)) throw new Error('handle must look like user@host');
|
|
82
88
|
if (agent.store.isBlocked('https://' + clean.split('@')[1] + '/')) throw new Error('domain is blocked');
|
|
83
|
-
|
|
89
|
+
// Through the agent's own remote read where it has one — the relay, in the
|
|
90
|
+
// browser build — and directly otherwise.
|
|
91
|
+
const remote = agent.intake?.deliverer?.signedFetch
|
|
92
|
+
? (u, i) => agent.intake.deliverer.signedFetch(u, i) : null;
|
|
93
|
+
const lookup = (a) => lookupWebFinger(a, { fetchImpl: remote });
|
|
94
|
+
const jrd = await lookup('acct:' + clean);
|
|
84
95
|
const self = selfLink(jrd);
|
|
85
96
|
if (!self?.href) throw new Error(`webfinger found no actor for ${clean}`);
|
|
86
97
|
const doc = await agent.intake.fetchAP(self.href);
|
|
87
98
|
if (!doc?.id) throw new Error(`actor document unusable for ${clean}`);
|
|
88
99
|
if (agent.store.isBlocked(doc.id)) throw new Error('actor is blocked');
|
|
89
|
-
await confirmDelegation(clean.slice(clean.lastIndexOf('@') + 1), doc);
|
|
100
|
+
await confirmDelegation(clean.slice(clean.lastIndexOf('@') + 1), doc, lookup);
|
|
90
101
|
// Counts live in the collections, not the actor document, so a client asking
|
|
91
102
|
// "who is this" would otherwise be told everyone has none. Two GETs, and only
|
|
92
103
|
// when someone asked by name — never on the drain path.
|
package/lib/core/wire.mjs
CHANGED
|
@@ -49,9 +49,10 @@ export function hostMeta(base) {
|
|
|
49
49
|
return `<?xml version="1.0" encoding="UTF-8"?>\n<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">\n <Link rel="lrdd" template="${base}.well-known/webfinger?resource={uri}"/>\n</XRD>\n`;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
export function jrd({ handle, host, actor }) {
|
|
52
|
+
export function jrd({ handle, host, actor, aliases = [] }) {
|
|
53
53
|
return {
|
|
54
54
|
subject: `acct:${handle}@${host}`,
|
|
55
|
+
...(aliases.length ? { aliases } : {}),
|
|
55
56
|
links: [{ rel: 'self', type: 'application/activity+json', href: actor }],
|
|
56
57
|
};
|
|
57
58
|
}
|
|
@@ -25,8 +25,9 @@ import * as podPolicy from '../pod/policy.mjs';
|
|
|
25
25
|
// wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
|
|
26
26
|
// friends), which a serverless front must never carry — it crashed the
|
|
27
27
|
// deployed function before it answered its first request.
|
|
28
|
-
const jrd = ({ handle, host, actor }) => ({
|
|
28
|
+
const jrd = ({ handle, host, actor, aliases = [] }) => ({
|
|
29
29
|
subject: `acct:${handle}@${host}`,
|
|
30
|
+
...(aliases.length ? { aliases } : {}),
|
|
30
31
|
links: [{ rel: 'self', type: 'application/activity+json', href: actor }],
|
|
31
32
|
});
|
|
32
33
|
|
|
@@ -429,7 +430,14 @@ async function route(request, ctx) {
|
|
|
429
430
|
// Re-attaching your own pod — a retry, a re-provision, a second browser — is
|
|
430
431
|
// idempotent: it updates the row in place and keeps the same door secret.
|
|
431
432
|
const prior = await ctx.lookup(key);
|
|
432
|
-
|
|
433
|
+
// The same account on the same pod may correct where its tree lives — a
|
|
434
|
+
// row made by an older sign-up named the pod root, and every delivery
|
|
435
|
+
// to it was written where nothing reads. A different pod is still taken.
|
|
436
|
+
const sameOwner = (a, b) => { try { return new URL(a).origin === new URL(b).origin; } catch { return false; } };
|
|
437
|
+
if (prior && prior.podHome !== podHome
|
|
438
|
+
&& !(prior.webId && prior.webId === webid && sameOwner(prior.podHome, podHome))) {
|
|
439
|
+
return j(409, { error: 'that name is taken' });
|
|
440
|
+
}
|
|
433
441
|
// Re-attaching is idempotent — a retry, a re-provision, a second browser —
|
|
434
442
|
// and it hands back the EXISTING receipt secret. So it has to be the same
|
|
435
443
|
// person: matching podHome was enough on a subdomain server and, on a
|
|
@@ -573,7 +581,11 @@ async function route(request, ctx) {
|
|
|
573
581
|
if (!m || m[2] !== ctx.host) return notFound();
|
|
574
582
|
const rec = await ctx.lookup(m[1]);
|
|
575
583
|
if (!rec) return notFound();
|
|
576
|
-
|
|
584
|
+
// A fronted identity's documents live on its pod; the pod's own actor id
|
|
585
|
+
// is the alias, so a client signing in by the fronted address can find
|
|
586
|
+
// the pod (and its login) without a lookup only the host could answer.
|
|
587
|
+
const podActor = rec.inboxOnly ? [] : [rec.podHome + 'ap/actor'];
|
|
588
|
+
return j(200, jrd({ handle: m[1], host: ctx.host, actor: rec.actorUrl, aliases: podActor }),
|
|
577
589
|
'application/jrd+json');
|
|
578
590
|
}
|
|
579
591
|
|
|
@@ -600,6 +612,13 @@ async function route(request, ctx) {
|
|
|
600
612
|
// fixed to the front so a consumer cross-checks it consistently.
|
|
601
613
|
if (request.method !== 'GET' && request.method !== 'HEAD') return { status: 405, headers: {}, body: '' };
|
|
602
614
|
const podTarget = rec.podHome + up.rest;
|
|
615
|
+
// Media stays on the pod (lib/pod/urls.mjs keeps `media` off the front), but
|
|
616
|
+
// the id rewrite below turns media links onto the front like every other
|
|
617
|
+
// pod url in a document. Answer those by pointing at the pod: bytes are not
|
|
618
|
+
// a document to cap and relabel, and remotes follow a redirect for a picture.
|
|
619
|
+
if (up.rest.startsWith('ap/media/')) {
|
|
620
|
+
return { status: 302, headers: { location: podTarget, 'cache-control': 'no-store' }, body: '' };
|
|
621
|
+
}
|
|
603
622
|
// The pod this read belongs to travels with it: an adapter reading a store
|
|
604
623
|
// directly (the CSS server component) has no access control of its own and
|
|
605
624
|
// needs to be told what it may reach. See podHomeProblem above for the other
|
package/lib/pod/inbox.mjs
CHANGED
|
@@ -22,10 +22,13 @@ const RECEIPT_CT = 'application/json';
|
|
|
22
22
|
*
|
|
23
23
|
* @returns {Promise<boolean>} whether it landed
|
|
24
24
|
*/
|
|
25
|
-
export async function appendWithToken(url, body, contentType, { appendToken = null, fetchImpl = fetch } = {}) {
|
|
25
|
+
export async function appendWithToken(url, body, contentType, { appendToken = null, fetchImpl = fetch, report = null } = {}) {
|
|
26
26
|
const headers = { 'content-type': contentType,
|
|
27
27
|
...(appendToken ? { authorization: `Bearer ${appendToken}` } : {}) };
|
|
28
28
|
const r = await fetchImpl(url, { method: 'PUT', headers, body }).catch(() => null);
|
|
29
|
+
// The pod's answer, for a caller that keeps a log: a refused write is
|
|
30
|
+
// otherwise a bare "failed" with the status thrown away.
|
|
31
|
+
report?.(r ? r.status : 0);
|
|
29
32
|
return !!r && r.status < 400;
|
|
30
33
|
}
|
|
31
34
|
|
package/lib/pod/root.mjs
CHANGED
|
@@ -39,6 +39,33 @@ export async function readOwnerLinks(fetchImpl, podBase, { timeoutMs = OWNER_LOO
|
|
|
39
39
|
*
|
|
40
40
|
* ---- asked by: a provisioning client, about a pod the person brought ----
|
|
41
41
|
*/
|
|
42
|
+
/**
|
|
43
|
+
* Where a provider puts its pods: on hosts of their own, or on paths of one
|
|
44
|
+
* shared host. No spec says. What does say is the storage description at the
|
|
45
|
+
* provider's root: a CSS that keeps pods on subdomains answers 501 there,
|
|
46
|
+
* because its root is not a storage; one that keeps them on paths answers 200
|
|
47
|
+
* with the root described as a storage. Anything else is unknown.
|
|
48
|
+
*
|
|
49
|
+
* Decides, at sign-up, whether the address can live on the pod at all: a pod
|
|
50
|
+
* on a path shares its host, so nothing there answers WebFinger for it.
|
|
51
|
+
*
|
|
52
|
+
* @returns 'host' | 'path' | null
|
|
53
|
+
*/
|
|
54
|
+
export async function podLayout(fetchImpl, providerOrigin, { timeoutMs = OWNER_LOOKUP_MS } = {}) {
|
|
55
|
+
let origin;
|
|
56
|
+
try { origin = new URL(providerOrigin).origin; } catch { return null; }
|
|
57
|
+
let res;
|
|
58
|
+
try {
|
|
59
|
+
res = await fetchImpl(`${origin}/.well-known/solid`,
|
|
60
|
+
{ headers: { accept: 'text/turtle' }, signal: AbortSignal.timeout(timeoutMs) });
|
|
61
|
+
} catch { return null; }
|
|
62
|
+
if (res.status === 501) return 'host';
|
|
63
|
+
if (res.status !== 200) return null;
|
|
64
|
+
let body = '';
|
|
65
|
+
try { body = await readCapped(res, 64 * 1024); } catch { return null; }
|
|
66
|
+
return /ns\/pim\/space#Storage|pim:Storage/u.test(body) ? 'path' : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
42
69
|
export async function probeAnswers(podUrl, fetchImpl = fetch) {
|
|
43
70
|
try {
|
|
44
71
|
const res = await fetchImpl(podUrl, { method: 'HEAD' });
|
package/lib/pod/transport.mjs
CHANGED
|
@@ -334,9 +334,14 @@ export class PodTransport {
|
|
|
334
334
|
}
|
|
335
335
|
|
|
336
336
|
async setAcl(targetUrl, publicModes, opts = {}) {
|
|
337
|
-
|
|
337
|
+
// The rule names the resource on the POD. A fronted identity hands in
|
|
338
|
+
// advertised urls; `fetch` maps the request, but a rule whose accessTo
|
|
339
|
+
// named the advertised url would guard a resource the pod does not have,
|
|
340
|
+
// and lock the real one to nobody — the owner included.
|
|
341
|
+
const podTarget = this.toPod ? this.toPod(targetUrl) : targetUrl;
|
|
342
|
+
const url = await this.aclUrlFor(podTarget);
|
|
338
343
|
if (!await this.aclWritable(url)) return null;
|
|
339
|
-
return this.put(url, this.aclDoc(
|
|
344
|
+
return this.put(url, this.aclDoc(podTarget, publicModes, { ...opts, aclUrl: url }), 'text/turtle');
|
|
340
345
|
}
|
|
341
346
|
|
|
342
347
|
// Child documents of an LDP container (URLs under it, excluding aux docs).
|
package/lib/pod/urls.mjs
CHANGED
|
@@ -65,6 +65,20 @@ export function apUrls(remotePod, root, { publicBase = null } = {}) {
|
|
|
65
65
|
// here. @name@host is looked up at https://host/.well-known/webfinger, so only
|
|
66
66
|
// a pod that owns the root of its host can answer for one; a pod living at
|
|
67
67
|
// https://server/name/ may publish the document but nothing will ever ask.
|
|
68
|
+
/**
|
|
69
|
+
* The pod a WebID lives in: the URL up to the profile document's container.
|
|
70
|
+
* `https://alice.pod/profile/card#me` → `https://alice.pod/`, and on a shared
|
|
71
|
+
* host `https://server/alice/profile/card#me` → `https://server/alice/`. Taking
|
|
72
|
+
* the origin alone named the wrong pod for the second, which is every pod on a
|
|
73
|
+
* path.
|
|
74
|
+
*/
|
|
75
|
+
export function podBaseOfWebId(webId) {
|
|
76
|
+
const u = new URL(webId);
|
|
77
|
+
u.hash = ''; u.search = '';
|
|
78
|
+
const dir = u.pathname.replace(/profile\/card$/u, '').replace(/[^/]*$/u, '');
|
|
79
|
+
return `${u.origin}${dir.endsWith('/') ? dir : dir + '/'}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
68
82
|
export function webfingerHost(podUrl) {
|
|
69
83
|
const u = new URL(podUrl);
|
|
70
84
|
return u.pathname === '/' ? u.host : null;
|
package/package.json
CHANGED
package/web/app/README.md
CHANGED
|
@@ -73,5 +73,5 @@ See the `MastoApi` options in `agent.mjs`.
|
|
|
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
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 installed agent. See `groups.md`. |
|
|
76
|
-
| **
|
|
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 path of a shared 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/agent.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { MastoApi } from '../../lib/client/masto/index.mjs';
|
|
|
17
17
|
import { TagFeed } from '../../lib/connections/tagfeed.mjs';
|
|
18
18
|
import { makeDpopSession } from './pod-auth.mjs';
|
|
19
19
|
import { BrowserRemotePod } from './pod-remote.mjs';
|
|
20
|
-
import { importSigningKey, loadKeysFromPod, cacheOpenedKeys } from './keys-browser.mjs';
|
|
20
|
+
import { importSigningKey, loadKeysFromPod, cacheOpenedKeys, podActorOf } from './keys-browser.mjs';
|
|
21
21
|
import { generateKeys, wrapKeys } from './keystore.mjs';
|
|
22
22
|
import { RelayDeliverer, doorKeyOf } from './deliver-relay.mjs';
|
|
23
23
|
import { AdminFacade } from './admin-facade.mjs';
|
|
@@ -25,7 +25,8 @@ import { BrowserAtproto } from './atproto-browser.mjs';
|
|
|
25
25
|
import { BskyFeed } from '../../lib/connections/bskyfeed.mjs';
|
|
26
26
|
import { BrowserFediAccounts } from './fediacct-browser.mjs';
|
|
27
27
|
import { AcctFeed } from '../../lib/connections/acctfeed.mjs';
|
|
28
|
-
import { followActor, unfollowActor } from '../../lib/core/social.mjs';
|
|
28
|
+
import { followActor, unfollowActor, resolveHandle } from '../../lib/core/social.mjs';
|
|
29
|
+
import { podBaseOfWebId } from '../../lib/pod/urls.mjs';
|
|
29
30
|
import { ImportWorker } from '../../lib/connections/import.mjs';
|
|
30
31
|
|
|
31
32
|
// The authorities this identity answers on: exactly one, this origin. The Node
|
|
@@ -159,7 +160,7 @@ export class BrowserAgent {
|
|
|
159
160
|
if (oidc) {
|
|
160
161
|
session = { fetch: (u, i) => oidc.fetch(u, i) };
|
|
161
162
|
webId = oidc.webId;
|
|
162
|
-
remotePod =
|
|
163
|
+
remotePod = podBaseOfWebId(webId); // the pod, which on a shared host is a path
|
|
163
164
|
} else {
|
|
164
165
|
const dpop = await makeDpopSession(credential);
|
|
165
166
|
session = { fetch: (u, i) => dpop.fetch(u, i) };
|
|
@@ -169,6 +170,8 @@ export class BrowserAgent {
|
|
|
169
170
|
this.webId = webId;
|
|
170
171
|
const root = (config && config.root) || 'fedipod/';
|
|
171
172
|
this.remote = new BrowserRemotePod(session, { webId, log: this.log });
|
|
173
|
+
// Pod-native for now: the state store below is read with these, and only
|
|
174
|
+
// the config it holds says whether this identity is fronted.
|
|
172
175
|
this.urls = apUrls(remotePod, root);
|
|
173
176
|
|
|
174
177
|
// State store, on the pod.
|
|
@@ -189,9 +192,18 @@ export class BrowserAgent {
|
|
|
189
192
|
// document it published landed under `activitypods-js/`. One root, decided
|
|
190
193
|
// once, carried by the config everything downstream reads.
|
|
191
194
|
this.store.setConfig({ ...(this.store.getConfig() || {}), ...cfg, root });
|
|
195
|
+
config = this.store.getConfig();
|
|
196
|
+
// A fronted identity (config.gateway.frontActor) advertises its ids at the
|
|
197
|
+
// gateway; the documents stay on the pod. Same three lines as the Node
|
|
198
|
+
// agent (run-agent.mjs connect): the advertised urls, and the map that
|
|
199
|
+
// turns an advertised url back into the pod one at the transport's single
|
|
200
|
+
// write and read choke point. State and media stay pod-native either way.
|
|
201
|
+
const publicBase = config.gateway?.frontActor
|
|
202
|
+
? config.gateway.frontActor.replace(/ap\/actor\/?$/, '') : null;
|
|
203
|
+
this.urls = apUrls(remotePod, root, { publicBase });
|
|
204
|
+
if (this.urls.toPod) this.remote.setUrlMap(this.urls.toPod);
|
|
192
205
|
// Keys: handed in (offline), or the owner-only keys.json read from the pod.
|
|
193
206
|
const keys = keysRecord ? await importSigningKey(keysRecord) : await loadKeysFromPod(this.remote, this.urls);
|
|
194
|
-
config = this.store.getConfig();
|
|
195
207
|
|
|
196
208
|
|
|
197
209
|
// `passive`: no queue-drain timer until this device is the active one.
|
|
@@ -212,6 +224,10 @@ export class BrowserAgent {
|
|
|
212
224
|
this.publisher = new Publisher({
|
|
213
225
|
config: this.store.getConfig(), remote: this.remote, store: this.store,
|
|
214
226
|
deliverer: this.deliverer, publicKeyPem: keys.rsaPublicPem, assertionKey: null, log: this.log,
|
|
227
|
+
// Who a post names, resolved — the same lookup the installed agent
|
|
228
|
+
// gives its publisher. Without it no mention from the browser ever
|
|
229
|
+
// resolved: a direct message went to nobody, a mention notified no one.
|
|
230
|
+
resolveMention: (h) => resolveHandle(this, h),
|
|
215
231
|
});
|
|
216
232
|
|
|
217
233
|
// The Bluesky connection, stamped to this actor. The same client the Node
|
|
@@ -366,7 +382,7 @@ export class BrowserAgent {
|
|
|
366
382
|
const rec = await generateKeys();
|
|
367
383
|
rec.mintedFor = this.urls.actor; // one key, one actor (lib/keys.mjs)
|
|
368
384
|
await podState.writeWrappedKeys(this.remote, this.urls, await wrapKeys(rec, password));
|
|
369
|
-
const keys = await cacheOpenedKeys(this.urls
|
|
385
|
+
const keys = await cacheOpenedKeys(podActorOf(this.urls), rec);
|
|
370
386
|
this.publisher.publicKeyPem = keys.rsaPublicPem;
|
|
371
387
|
this.deliverer.rsaPrivate = keys.rsaPrivate;
|
|
372
388
|
await this.publisher.publishProfile();
|
package/web/app/boot.mjs
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
import { signUp, handleProblem, AP_ROOT } from './signup.mjs';
|
|
13
13
|
import * as podActor from '../../lib/pod/actor.mjs';
|
|
14
14
|
import * as podState from '../../lib/pod/state.mjs';
|
|
15
|
+
import { podBaseOfWebId } from '../../lib/pod/urls.mjs';
|
|
16
|
+
import { podLayout } from '../../lib/pod/root.mjs';
|
|
15
17
|
import { BrowserRemotePod } from './pod-remote.mjs';
|
|
16
18
|
import { beginLogin, completeLogin, getSession, signOut } from './oidc-session.mjs';
|
|
17
19
|
import { unwrapKeys, isKeyEnvelope } from './keystore.mjs';
|
|
@@ -68,7 +70,7 @@ window.fedipodUnlock = async (password) => {
|
|
|
68
70
|
if (!session) throw new Error('Sign in first.');
|
|
69
71
|
// The config on the pod says where this account's state lives; the key sits
|
|
70
72
|
// beside it. Both are read with the session, as the owner.
|
|
71
|
-
const podFromWebId =
|
|
73
|
+
const podFromWebId = podBaseOfWebId(session.webId); // a path on a shared host, or its own host
|
|
72
74
|
const state = `${podFromWebId}${AP_ROOT}ap-state/`;
|
|
73
75
|
// Through the transport rather than the bare session: this is a pod read
|
|
74
76
|
// like any other, and going round it skipped the retry ladder that exists
|
|
@@ -112,21 +114,38 @@ export function parseAddress(input) {
|
|
|
112
114
|
}
|
|
113
115
|
async function issuerForPod(pod) {
|
|
114
116
|
// The pod's actor says where a client signs in (oauthAuthorizationEndpoint's
|
|
115
|
-
// origin); failing that, the account provider is the pod host's parent
|
|
117
|
+
// origin); failing that, the account provider is the pod host's parent
|
|
118
|
+
// domain for a subdomain pod, and the host itself for a pod on a path.
|
|
116
119
|
try {
|
|
117
120
|
const authz = await podActor.readIssuer(`${pod}${AP_ROOT}ap/actor`);
|
|
118
121
|
if (authz) return new URL(authz).origin;
|
|
119
122
|
} catch { /* fall through */ }
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
+
const u = new URL(pod);
|
|
124
|
+
if (u.pathname !== '/') return u.origin;
|
|
125
|
+
const parent = u.host.split('.').slice(1).join('.');
|
|
126
|
+
return `https://${parent || u.host}`;
|
|
127
|
+
}
|
|
128
|
+
// An address at this site names a fronted identity. Its documents live on a
|
|
129
|
+
// pod this site's WebFinger names as an alias — the pod's own actor id.
|
|
130
|
+
async function podForFrontedAddress(handle) {
|
|
131
|
+
const res = await fetch(`/.well-known/webfinger?resource=${encodeURIComponent(`acct:${handle}@${location.host}`)}`,
|
|
132
|
+
{ headers: { accept: 'application/jrd+json, application/json' } }).catch(() => null);
|
|
133
|
+
if (!res || res.status >= 400) throw new Error(`nobody at this site is called @${handle}@${location.host}`);
|
|
134
|
+
const doc = await res.json().catch(() => ({}));
|
|
135
|
+
const podActorId = (doc.aliases || []).find((a) => /\/ap\/actor$/u.test(String(a)));
|
|
136
|
+
if (!podActorId) throw new Error(`@${handle}@${location.host} lives here but names no pod to sign in to`);
|
|
137
|
+
const tail = `${AP_ROOT}ap/actor`;
|
|
138
|
+
if (!podActorId.endsWith(tail)) throw new Error(`the pod actor ${podActorId} is not where a FediPod pod keeps one`);
|
|
139
|
+
return podActorId.slice(0, -tail.length);
|
|
123
140
|
}
|
|
124
141
|
window.fedipodSignin = async ({ address }) => {
|
|
125
142
|
const parsed = parseAddress(address);
|
|
126
143
|
if (!parsed) throw new Error('Enter your address as @you@yourpod (for example @alice@alice.solidcommunity.net).');
|
|
127
144
|
const bad = handleProblem(parsed.handle);
|
|
128
145
|
if (bad) throw new Error(bad);
|
|
129
|
-
const pod =
|
|
146
|
+
const pod = parsed.host === location.host.toLowerCase()
|
|
147
|
+
? await podForFrontedAddress(parsed.handle)
|
|
148
|
+
: `https://${parsed.host}/`;
|
|
130
149
|
const issuer = await issuerForPod(pod);
|
|
131
150
|
const { authorizationUrl } = await beginLogin({ issuer, redirectUri: REDIRECT });
|
|
132
151
|
location.href = authorizationUrl;
|
|
@@ -262,24 +281,61 @@ if (typeof document !== 'undefined') (async () => {
|
|
|
262
281
|
// --- register form: two screens, pod first then Fediverse identity ---
|
|
263
282
|
const f = () => $('form').elements;
|
|
264
283
|
// The pod provider is a free-text URL; default and normalise to a scheme.
|
|
265
|
-
|
|
284
|
+
// The provider is picked from the list, or typed under "Other…".
|
|
285
|
+
const providerUrl = () => { let v = (f().provider.value || f().providerOther.value).trim(); if (!v) return ''; if (!/^https?:\/\//i.test(v)) v = 'https://' + v; return v; };
|
|
266
286
|
const providerHost = () => { try { return new URL(providerUrl()).host; } catch { return ''; } };
|
|
267
|
-
//
|
|
268
|
-
//
|
|
287
|
+
// A new pod is named by its subdomain under the provider; an existing pod is
|
|
288
|
+
// brought by its address, which may be its own host or a path on a shared one.
|
|
269
289
|
const podHostOf = () => { const sub = f().podName.value.trim().toLowerCase(); const ph = providerHost(); return (sub && ph) ? `${sub}.${ph}` : ''; };
|
|
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 path of a shared host cannot answer WebFinger, so its address
|
|
292
|
+
// lives at this site; a pod at its own host root gets the choice.
|
|
293
|
+
const isPathPod = (u) => { try { return new URL(u).pathname !== '/'; } catch { return false; } };
|
|
294
|
+
// Where the chosen provider puts new pods, asked of the provider itself
|
|
295
|
+
// (lib/pod/root.mjs podLayout) and remembered per provider: 'host', 'path',
|
|
296
|
+
// or null when it would not say.
|
|
297
|
+
const layouts = new Map();
|
|
298
|
+
let layout = null;
|
|
299
|
+
const learnLayout = async () => {
|
|
300
|
+
const origin = providerHost() ? new URL(providerUrl()).origin : '';
|
|
301
|
+
if (!origin) { layout = null; return; }
|
|
302
|
+
if (!layouts.has(origin)) layouts.set(origin, podLayout(fetch, origin).catch(() => null));
|
|
303
|
+
const known = await layouts.get(origin);
|
|
304
|
+
if (providerHost() && new URL(providerUrl()).origin === origin) { layout = known; applyShape(); previewAddr(); }
|
|
305
|
+
};
|
|
306
|
+
const pathPod = () => (f().mode.value === 'existing' ? isPathPod(podUrl()) : layout === 'path');
|
|
307
|
+
const shape = () => (pathPod() ? 'front' : f().shape.value);
|
|
270
308
|
const answers = () => {
|
|
271
309
|
const mode = f().mode.value;
|
|
272
310
|
const a = { mode, handle: f().handle.value.trim().toLowerCase(), email: f().email.value.trim(),
|
|
273
|
-
password: f().password.value, issuer: providerUrl() };
|
|
311
|
+
password: f().password.value, issuer: providerUrl(), shape: shape() };
|
|
274
312
|
if (mode === 'new') a.podName = f().podName.value.trim().toLowerCase();
|
|
275
|
-
else a.pod =
|
|
313
|
+
else a.pod = podUrl();
|
|
276
314
|
return a;
|
|
277
315
|
};
|
|
278
316
|
const previewAddr = () => {
|
|
279
|
-
const handle = f().handle.value.trim().toLowerCase();
|
|
280
|
-
|
|
317
|
+
const handle = f().handle.value.trim().toLowerCase();
|
|
318
|
+
const host = shape() === 'front' ? location.host
|
|
319
|
+
: (f().mode.value === 'existing' ? (podUrl() ? new URL(podUrl()).host : '') : podHostOf());
|
|
320
|
+
$('preview').textContent = (handle && host) ? `@${handle}@${host}` : '@…@…';
|
|
321
|
+
};
|
|
322
|
+
// The shape choice is fixed for a path pod, and open for a host-root pod.
|
|
323
|
+
// A path pod has no choice to make: the radios go away and the note says why.
|
|
324
|
+
const applyShape = () => {
|
|
325
|
+
const fixed = pathPod();
|
|
326
|
+
for (const r of f().shape) { if (fixed) r.checked = r.value === 'front'; }
|
|
327
|
+
$('shape-group').hidden = fixed;
|
|
328
|
+
$('shape-hint').hidden = !fixed;
|
|
281
329
|
};
|
|
282
|
-
|
|
330
|
+
const applyMode = () => {
|
|
331
|
+
const existing = f().mode.value === 'existing';
|
|
332
|
+
$('pod-field').hidden = !existing;
|
|
333
|
+
$('podname-field').hidden = existing;
|
|
334
|
+
$('provider-other-field').hidden = f().provider.value !== '';
|
|
335
|
+
};
|
|
336
|
+
for (const el of $('form').elements) for (const evt of ['input', 'change']) el.addEventListener(evt, () => { applyMode(); applyShape(); previewAddr(); });
|
|
337
|
+
for (const evt of ['input', 'change']) { $('provider').addEventListener(evt, learnLayout); $('providerOther').addEventListener(evt, learnLayout); }
|
|
338
|
+
applyMode(); learnLayout();
|
|
283
339
|
|
|
284
340
|
// Step machine: one screen at a time, each gated by its own validation.
|
|
285
341
|
const STEP_IDS = ['step-1', 'step-2'];
|
|
@@ -287,15 +343,19 @@ if (typeof document !== 'undefined') (async () => {
|
|
|
287
343
|
const goStep = (n) => {
|
|
288
344
|
STEP_IDS.forEach((id, i) => { $(id).hidden = i !== n - 1; });
|
|
289
345
|
$('err-1').textContent = ''; $('form-error').textContent = '';
|
|
290
|
-
if (n === 2) previewAddr();
|
|
346
|
+
if (n === 2) { applyShape(); previewAddr(); learnLayout(); }
|
|
291
347
|
if (FOCUS[n]) $(FOCUS[n]).focus();
|
|
292
348
|
};
|
|
293
349
|
const validateStep1 = () => {
|
|
294
|
-
if (!providerHost()) return 'A valid pod provider URL is required.';
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
350
|
+
if (!providerHost()) return f().provider.value === '' ? 'A pod provider address is required under Other….' : 'A valid pod provider URL is required.';
|
|
351
|
+
if (f().mode.value === 'existing') {
|
|
352
|
+
if (!podUrl()) return 'A pod address is required, like https://alice.solidcommunity.net/ or https://server.example/alice/.';
|
|
353
|
+
} else {
|
|
354
|
+
const sub = f().podName.value.trim().toLowerCase();
|
|
355
|
+
if (!sub) return 'A pod username/subdomain is required.';
|
|
356
|
+
const sp = window.fedipodHandleProblem(sub);
|
|
357
|
+
if (sp) return `Pod username: ${sp}`;
|
|
358
|
+
}
|
|
299
359
|
if (!f().email.value.trim()) return 'A pod email is required.';
|
|
300
360
|
if (!f().password.value) return 'A pod password is required.';
|
|
301
361
|
return null;
|