fedipod 1.4.1 → 1.6.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 +10 -3
- package/architecture.md +4 -4
- package/cli.md +7 -0
- package/gateway.md +6 -0
- package/groups.md +1 -1
- package/gui.md +1 -1
- package/lib/client/masto/index.mjs +6 -1
- package/lib/core/publisher/index.mjs +1 -1
- package/lib/core/wire.mjs +2 -1
- package/lib/device/admin/routes/lifecycle.mjs +1 -1
- package/lib/device/admin/routes/setup.mjs +20 -1
- package/lib/device/cli/commands/setup.mjs +44 -20
- package/lib/device/setup.mjs +99 -25
- package/lib/gateway/front-core.mjs +14 -2
- 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/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 +17 -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 +31 -13
- 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/admin/setup/index.html +25 -2
- package/web/app/site/admin/setup/setup.js +30 -1
- package/web/app/site/boot.js +187 -49
- package/web/app/site/index.html +46 -8
- package/web/app/site/sw.js +31 -13
- package/installed-agent.md +0 -97
package/README.md
CHANGED
|
@@ -16,8 +16,11 @@ There are also a number of [other ways to run FediPod](#other-ways-to-run-fedipo
|
|
|
16
16
|
- A Solid pod with a host name of its own, such as
|
|
17
17
|
`https://alice.solidcommunity.net/`. Sign-up can create one for you at
|
|
18
18
|
solidcommunity.net or another provider, or use a pod you already have. A
|
|
19
|
-
pod that lives on a
|
|
19
|
+
pod that lives on a suffix-based host, like `https://server.example/alice/`,
|
|
20
20
|
cannot be a Fediverse address.
|
|
21
|
+
A pod on a suffix-based host, like `https://server.example/alice/`,
|
|
22
|
+
works too. Its address is then `@handle@fedipod.net`, because the shared
|
|
23
|
+
host cannot answer for the handle; your posts, key and data stay on your pod.
|
|
21
24
|
- Followers-only and direct posts need a pod that enforces WAC access control.
|
|
22
25
|
On one that does not, the composer refuses those two and says why. Public
|
|
23
26
|
and unlisted posts work on any pod.
|
|
@@ -28,6 +31,10 @@ There are also a number of [other ways to run FediPod](#other-ways-to-run-fedipo
|
|
|
28
31
|
2. Choose a pod: a new one at the provider you name, or a pod you already have.
|
|
29
32
|
3. Choose your handle. Your address is `@handle@yourpod`. Both parts are
|
|
30
33
|
permanent; display name, bio and pictures are set later in the client.
|
|
34
|
+
With a pod at its own host you also choose where the address lives: on
|
|
35
|
+
your pod, `@handle@yourpod`, or at this site, `@handle@fedipod.net`. A pod
|
|
36
|
+
on a suffix-based host gets the fedipod.net address. The choice is
|
|
37
|
+
permanent.
|
|
31
38
|
4. Enter your pod password once. It creates the account and locks your
|
|
32
39
|
signing key. The password is not stored.
|
|
33
40
|
|
|
@@ -66,7 +73,7 @@ an alias, so a Move from it lands here.
|
|
|
66
73
|
**The manage page.** `manage account` in the bar opens it: your profile,
|
|
67
74
|
aliases, the gateway, key rotation, recovering posts, parking, moving to
|
|
68
75
|
another server, retiring, and clearing a backlog. It is the same interface
|
|
69
|
-
the
|
|
76
|
+
the DeviceAgent has, described in [the admin interface](gui.md).
|
|
70
77
|
|
|
71
78
|
**More than one browser.** One browser runs your account at a time. Opening
|
|
72
79
|
it in a second browser shows your timeline read-only, and the moment you act
|
|
@@ -96,7 +103,7 @@ to a gateway of your own. Your address and your data do not change.
|
|
|
96
103
|
|
|
97
104
|
## Other ways to run FediPod
|
|
98
105
|
|
|
99
|
-
- [The
|
|
106
|
+
- [The DeviceAgent](device-agent.md) runs on your own machine and
|
|
100
107
|
adds scheduled posts, push notifications, live updates, group hosting and
|
|
101
108
|
the use of any Mastodon client.
|
|
102
109
|
- [Groups](groups.md): hosting a discussion group of Fediverse and Bluesky
|
package/architecture.md
CHANGED
|
@@ -5,7 +5,7 @@ discovery and stores the public record: the actor, its outbox, followers and
|
|
|
5
5
|
posts, and the inbox that receives deliveries. The agent provides the
|
|
6
6
|
ActivityPub actions: it drains the inbox, builds the timeline, signs and
|
|
7
7
|
delivers, and answers the Mastodon client. The agent runs either in your
|
|
8
|
-
browser, served by fedipod.net, or as [the
|
|
8
|
+
browser, served by fedipod.net, or as [the DeviceAgent](device-agent.md)
|
|
9
9
|
on your own machine. Private direct messages, followers-only posts and the
|
|
10
10
|
pending-follow and blocked collections live on the pod in an area protected by
|
|
11
11
|
access control.
|
|
@@ -13,7 +13,7 @@ access control.
|
|
|
13
13
|
A [gateway](gateway.md) can stand in front of the pod: an always-on door that
|
|
14
14
|
verifies each delivery where the signature can still be checked, drops spam,
|
|
15
15
|
and forwards the rest to the pod inbox with a receipt. It holds no key. The
|
|
16
|
-
browser version always has one; the
|
|
16
|
+
browser version always has one; the DeviceAgent may use one. Any
|
|
17
17
|
lightweight host will do, Netlify included.
|
|
18
18
|
|
|
19
19
|
[FediPod Server](packages/fedipod-server/README.md) puts the agent inside a
|
|
@@ -22,13 +22,13 @@ Fediverse account fed by the server itself.
|
|
|
22
22
|
|
|
23
23
|

|
|
24
24
|
|
|
25
|
-

|
|
26
26
|
|
|
27
27
|
## Protocol conformance
|
|
28
28
|
|
|
29
29
|
FediPod is a full ActivityPub server, on both of the spec's profiles:
|
|
30
30
|
server-to-server (§7) and client-to-server (§6). `POST /ap/outbox` on the
|
|
31
|
-
|
|
31
|
+
DeviceAgent takes an activity, or a bare Note, and does the id-minting,
|
|
32
32
|
side-effects and delivery, authenticated by a Solid-OIDC token whose WebID is
|
|
33
33
|
the owner's. The Mastodon REST API is the everyday client interface; C2S is
|
|
34
34
|
the spec's own.
|
package/cli.md
CHANGED
|
@@ -29,6 +29,13 @@ pod account as part of it, `--keys pod` puts the signing key in pod state for
|
|
|
29
29
|
multi-device use, and `AP_PASSWORD` supplies the pod password without a
|
|
30
30
|
prompt. `--profile NAME` names the new identity when you have more than one.
|
|
31
31
|
|
|
32
|
+
`--address pod` (the default) puts your address on your pod, `@you@yourpod`;
|
|
33
|
+
`--address front` puts it at a gateway, `@you@the-gateway`, with your posts,
|
|
34
|
+
key and data still on your pod. `--gateway <url>` names the gateway
|
|
35
|
+
(`https://fedipod.net` by default). A pod on a suffix-based host cannot
|
|
36
|
+
answer WebFinger for a handle, so it always takes a gateway address, whichever
|
|
37
|
+
`--address` you gave.
|
|
38
|
+
|
|
32
39
|
## Which identity a command acts on
|
|
33
40
|
|
|
34
41
|
`--profile NAME` works on every command, not just `up`; `AP_PROFILE` is the
|
package/gateway.md
CHANGED
|
@@ -28,6 +28,12 @@ password is typed anywhere:
|
|
|
28
28
|
way. A pod-based attach applies immediately; taking a gateway-based name
|
|
29
29
|
restarts the agent itself to publish under it.
|
|
30
30
|
|
|
31
|
+
In the BrowserAgent the same choice is made once, at sign-up, and cannot be
|
|
32
|
+
changed afterwards. A pod on a suffix-based host, like
|
|
33
|
+
`https://server.example/alice/`, always takes the gateway-based name: nothing
|
|
34
|
+
at that host answers for the handle, so the gateway does. Its posts, key and
|
|
35
|
+
data stay on the pod.
|
|
36
|
+
|
|
31
37
|
The same attach from the command line, against the running agent:
|
|
32
38
|
|
|
33
39
|
```
|
package/groups.md
CHANGED
|
@@ -14,7 +14,7 @@ and pick group rather than person, or take the group path on a signup page.
|
|
|
14
14
|
You can turn on join review as you create it. The group's pod must be the root
|
|
15
15
|
of its own host, so its handle resolves.
|
|
16
16
|
|
|
17
|
-
**Running a group needs the
|
|
17
|
+
**Running a group needs the DeviceAgent.** The in-browser build makes
|
|
18
18
|
personal identities only — the sign-up wizard has no group option, and the
|
|
19
19
|
moderation surface (join review, members, muting, the moderation queue) is not
|
|
20
20
|
part of that build. You can *join* a group from the browser exactly as from
|
package/gui.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
In the browser version at fedipod.net, `manage account` in the bar opens this
|
|
4
4
|
same page for your account. The rest of this page describes it as the
|
|
5
|
-
|
|
5
|
+
DeviceAgent serves it; the controls are the same, minus the manual inbox
|
|
6
6
|
drain and the local log, which a browser does not have.
|
|
7
7
|
|
|
8
8
|
Open `https://localhost:8030/` while any agent is running — it forwards you to the agent — then choose `manage account` and select the actor you want from the local actors dropdown.
|
|
@@ -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
|
|
@@ -201,7 +201,7 @@ export class Publisher {
|
|
|
201
201
|
this.log(this.config.gateway?.frontActor || wire.webfingerHost(urls.base)
|
|
202
202
|
? `profile published: @${pubName}@${pubHost} → ${urls.actor}`
|
|
203
203
|
: `profile published → ${urls.actor} — NOT discoverable as @${pubName}@${pubHost}: `
|
|
204
|
-
+ 'this pod is a
|
|
204
|
+
+ 'this pod is a suffix-based host, and WebFinger is only answered at a host root');
|
|
205
205
|
return { unreachable, updated };
|
|
206
206
|
}
|
|
207
207
|
|
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
|
}
|
|
@@ -102,7 +102,7 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
|
|
|
102
102
|
// A Move target nobody can resolve is a landing pad nobody lands on.
|
|
103
103
|
if (!webfingerHost(urls.base) && !cfg.gateway?.frontActor) {
|
|
104
104
|
return json(res, 400, {
|
|
105
|
-
error: 'this pod is a
|
|
105
|
+
error: 'this pod is a suffix-based host, so WebFinger cannot answer for it '
|
|
106
106
|
+ '— other servers could never resolve this account as a Move target',
|
|
107
107
|
});
|
|
108
108
|
}
|
|
@@ -12,6 +12,7 @@ import { identityHomes, rootOf, tildify, writeJsonAtomic } from '../../home.mjs'
|
|
|
12
12
|
import { copyPrivateHalf, isCurrent, CURRENT_LAYOUT } from '../../migrate.mjs';
|
|
13
13
|
import { insecureUrlReason } from '../../../shared/safefetch.mjs';
|
|
14
14
|
import { newRun, preflight, runSetup, setupInputError, hasCredential, credentialPath } from '../../setup.mjs';
|
|
15
|
+
import { podLayout } from '../../../pod/root.mjs';
|
|
15
16
|
import { portFree, freePortFrom } from '../../ports.mjs';
|
|
16
17
|
import { yieldDirectory } from '../../../gateway/directory.mjs';
|
|
17
18
|
import { localFetch } from '../../../client/localapi.mjs';
|
|
@@ -113,7 +114,25 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
|
|
|
113
114
|
return true;
|
|
114
115
|
}
|
|
115
116
|
// ---- setup, driven by the page at /admin/setup/ ----
|
|
116
|
-
case '/setup/check':
|
|
117
|
+
case '/setup/check': {
|
|
118
|
+
const pre = preflight(body);
|
|
119
|
+
// For a new pod, ask the provider where it puts pods, so the page can
|
|
120
|
+
// show the Gateway address before the pod exists. A provider on paths
|
|
121
|
+
// means the address must live at the Gateway; the run decides for real.
|
|
122
|
+
if (pre.ok && body.mode === 'new' && body.shape !== 'front') {
|
|
123
|
+
const layout = await podLayout(fetch, body.issuer || '').catch(() => null);
|
|
124
|
+
if (layout === 'path') {
|
|
125
|
+
if ((body.kind || 'person') === 'group') {
|
|
126
|
+
return json(res, 200, { ...pre, ok: false, layout, refusal: 'group-needs-host-root' });
|
|
127
|
+
}
|
|
128
|
+
let gwHost; try { gwHost = new URL(body.gatewayOrigin || 'https://fedipod.net').host; } catch { gwHost = 'fedipod.net'; }
|
|
129
|
+
return json(res, 200, { ...pre, layout, fronted: true, forced: true, shape: 'front', gatewayHost: gwHost,
|
|
130
|
+
address: `@${body.handle}@${gwHost}`, resolvable: true });
|
|
131
|
+
}
|
|
132
|
+
return json(res, 200, { ...pre, layout });
|
|
133
|
+
}
|
|
134
|
+
return json(res, 200, pre);
|
|
135
|
+
}
|
|
117
136
|
// Discard a credential that never finished setup, so the account and
|
|
118
137
|
// pod can be entered again. The credential a CSS server mints is shown
|
|
119
138
|
// once, so a setup that stops after the mint (a wrong pod answers 401
|
|
@@ -79,9 +79,26 @@ const name = flag('name') || await ask('display name (shown above your address)'
|
|
|
79
79
|
// A handle resolves through <host>/.well-known/webfinger, so it only works
|
|
80
80
|
// when the pod owns the root of its host. Whether a NEW pod gets its own
|
|
81
81
|
// subdomain is the server's call, so promise nothing here we cannot keep.
|
|
82
|
-
const { webfingerHost } = await import(new URL('../../../../lib/core/wire.mjs', import.meta.url));
|
|
82
|
+
const { webfingerHost, apUrls, DEFAULT_ROOT } = await import(new URL('../../../../lib/core/wire.mjs', import.meta.url));
|
|
83
83
|
const issuerHost = new URL(issuer).host;
|
|
84
84
|
const wfHost = newAccount ? null : webfingerHost(pod);
|
|
85
|
+
|
|
86
|
+
// Where the address lives: on the pod (default), or at a gateway. A pod on a
|
|
87
|
+
// suffix-based host cannot answer WebFinger for a handle, so its address
|
|
88
|
+
// is at the gateway whatever was asked. `--address front` takes a gateway
|
|
89
|
+
// address for a host-root pod too; `--gateway <origin>` names the gateway.
|
|
90
|
+
const addressShape = String(flag('address') || 'pod').toLowerCase();
|
|
91
|
+
if (addressShape !== 'pod' && addressShape !== 'front') { console.error('--address must be pod or front'); process.exit(2); }
|
|
92
|
+
const gatewayOrigin = flag('gateway') || 'https://fedipod.net';
|
|
93
|
+
{ const badGw = insecureUrlReason(gatewayOrigin, 'gateway address'); if (badGw) { console.error(badGw); process.exit(2); } }
|
|
94
|
+
const gatewayHost = new URL(gatewayOrigin).host;
|
|
95
|
+
// For an EXISTING pod the shape is known now; for a NEW pod the path case is
|
|
96
|
+
// decided after the pod is made, below.
|
|
97
|
+
let fronted = (!newAccount && !wfHost) || addressShape === 'front';
|
|
98
|
+
if (fronted && kind === 'group') {
|
|
99
|
+
console.error('a group needs a pod at its own host — a gateway address for a group is not supported yet.');
|
|
100
|
+
process.exit(2);
|
|
101
|
+
}
|
|
85
102
|
// A person warned about an unresolvable handle is the one who suffers, so a
|
|
86
103
|
// warning is their call to accept. Nobody could ever find this group, and the
|
|
87
104
|
// people it would fail are not the operator reading the warning.
|
|
@@ -93,20 +110,16 @@ if (kind === 'group' && !newAccount && !wfHost) {
|
|
|
93
110
|
}
|
|
94
111
|
console.log(kind === 'group' ? '\nThe group will be:\n' : '\nYou will be:\n');
|
|
95
112
|
console.log(` ${name}`);
|
|
96
|
-
if (
|
|
113
|
+
if (fronted) {
|
|
114
|
+
console.log(` @${handle}@${gatewayHost}\n`);
|
|
115
|
+
console.log(`— your address lives at ${gatewayHost}; your posts, key and data stay on your pod.\n`);
|
|
116
|
+
} else if (wfHost) {
|
|
97
117
|
console.log(` @${handle}@${wfHost}\n`);
|
|
98
118
|
} else if (newAccount) {
|
|
99
119
|
console.log(` @${handle}@${podName}.${issuerHost}\n`);
|
|
100
|
-
console.log(`— provided ${issuerHost} gives each pod its own subdomain.
|
|
101
|
-
console.log(`pods at ${issuerHost}/${podName}/ instead
|
|
102
|
-
console.log(
|
|
103
|
-
console.log('before publishing anything.\n');
|
|
104
|
-
} else {
|
|
105
|
-
console.log(` @${handle}@${new URL(pod).host} — WILL NOT RESOLVE\n`);
|
|
106
|
-
console.log(`This pod is ${pod} — a path on ${new URL(pod).host}, not the root of its own`);
|
|
107
|
-
console.log('host. WebFinger is answered only at a host root, which this pod cannot');
|
|
108
|
-
console.log('write to, so other servers will not find you. Posting and reading still');
|
|
109
|
-
console.log('work; being discovered does not.\n');
|
|
120
|
+
console.log(`— provided ${issuerHost} gives each pod its own subdomain. A server that puts`);
|
|
121
|
+
console.log(`pods at ${issuerHost}/${podName}/ instead cannot answer WebFinger for an address,`);
|
|
122
|
+
console.log(`so setup takes an address at ${gatewayHost} for you and says so.\n`);
|
|
110
123
|
}
|
|
111
124
|
console.log('The display name can be changed later; the handle and pod cannot.');
|
|
112
125
|
const go = await ask(newAccount
|
|
@@ -123,14 +136,15 @@ if (newAccount) {
|
|
|
123
136
|
pod = made.pod;
|
|
124
137
|
console.log(`account + pod created: ${pod}`);
|
|
125
138
|
if (!webfingerHost(pod)) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (!/^y/i.test(cont)) {
|
|
131
|
-
console.log('stopping \u2014 the pod exists, but no actor was published');
|
|
139
|
+
if (kind === 'group') {
|
|
140
|
+
console.log(`\n${issuerHost} created the pod at ${pod} — a suffix-based host.`);
|
|
141
|
+
console.log('WebFinger is answered only at a host root, so nobody could find this group.');
|
|
142
|
+
console.log('The pod exists; no actor was published.');
|
|
132
143
|
process.exit(0);
|
|
133
144
|
}
|
|
145
|
+
// A path pod's address lives at the gateway; nothing to warn about.
|
|
146
|
+
fronted = true;
|
|
147
|
+
console.log(`${issuerHost} puts pods on paths, so your address will be @${handle}@${gatewayHost}.`);
|
|
134
148
|
}
|
|
135
149
|
}
|
|
136
150
|
|
|
@@ -164,13 +178,23 @@ if (rootOf(HOME) === AP_ROOT) recordLastUsed(AP_ROOT, path.basename(HOME));
|
|
|
164
178
|
recordAgent({ port: PORT, handle }); // later commands need no --port
|
|
165
179
|
console.log(`credential minted and saved to ${path.join(HOME, 'credential.json')}`);
|
|
166
180
|
|
|
181
|
+
let gatewayCfg = null;
|
|
182
|
+
if (fronted) {
|
|
183
|
+
const { takeGatewayAddress } = await import(new URL('../../../../lib/device/setup.mjs', import.meta.url));
|
|
184
|
+
const urls = apUrls(pod, root || DEFAULT_ROOT);
|
|
185
|
+
console.log(`taking a gateway address at ${gatewayHost}`);
|
|
186
|
+
gatewayCfg = await takeGatewayAddress({
|
|
187
|
+
home: HOME, credential: rec, gatewayOrigin, handle,
|
|
188
|
+
podHome: urls.home, actorUrl: urls.actor, kind, log: (...a) => console.log('[setup]', ...a),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
167
191
|
const { Agent } = await import(new URL('../../../../run-agent.mjs', import.meta.url));
|
|
168
192
|
const agent = new Agent({ home: HOME, log: (...a) => console.log('[setup]', ...a) });
|
|
169
|
-
await agent.bootstrap({ handle, name, root, kind, approveJoins, summary, icon });
|
|
193
|
+
await agent.bootstrap({ handle, name, root, kind, approveJoins, summary, icon, gateway: gatewayCfg });
|
|
170
194
|
await agent.connect({ repair: false }); // publishProfile below is the publish
|
|
171
195
|
await agent.publisher.publishProfile();
|
|
172
196
|
await agent.store.flush();
|
|
173
|
-
const finalHost = webfingerHost(rec.remotePod);
|
|
197
|
+
const finalHost = gatewayCfg ? gatewayHost : webfingerHost(rec.remotePod);
|
|
174
198
|
const what = kind === 'group' ? 'group' : 'actor';
|
|
175
199
|
console.log(finalHost
|
|
176
200
|
? `${what} published: @${handle}@${finalHost}`
|
package/lib/device/setup.mjs
CHANGED
|
@@ -14,9 +14,9 @@ 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
22
|
import { CURRENT_LAYOUT, isCurrent } from './migrate.mjs';
|
|
@@ -96,6 +96,11 @@ export function setupInputError(a, resuming = false) {
|
|
|
96
96
|
const badGw = insecureUrlReason(a.gateway.url, 'gateway address');
|
|
97
97
|
if (badGw) return badGw;
|
|
98
98
|
}
|
|
99
|
+
if (a.shape && a.shape !== 'pod' && a.shape !== 'front') return 'shape must be "pod" or "front"';
|
|
100
|
+
if (a.gatewayOrigin) {
|
|
101
|
+
const badO = insecureUrlReason(a.gatewayOrigin, 'gateway address');
|
|
102
|
+
if (badO) return badO;
|
|
103
|
+
}
|
|
99
104
|
if (resuming) return null;
|
|
100
105
|
if (a.mode !== 'new' && a.mode !== 'existing') return 'mode must be "new" or "existing"';
|
|
101
106
|
if (!a.issuer) return 'an identity provider is required';
|
|
@@ -111,23 +116,61 @@ export function setupInputError(a, resuming = false) {
|
|
|
111
116
|
return null;
|
|
112
117
|
}
|
|
113
118
|
|
|
119
|
+
// Where an identity's address lives. A pod on a suffix-based host cannot
|
|
120
|
+
// answer WebFinger for a handle, so its address must live at the Gateway
|
|
121
|
+
// whatever was asked; a pod at its own host root takes the shape chosen.
|
|
122
|
+
export function frontedAddress({ pod, shape }) {
|
|
123
|
+
const pathPod = pod ? !webfingerHost(pod) : false;
|
|
124
|
+
return pathPod || shape === 'front';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Take an address at a Gateway: attach the pod to it, fronted, and return the
|
|
128
|
+
// gateway config bootstrap writes and connect reads. The pod session proves
|
|
129
|
+
// the pod — no password reaches the Gateway. Injected for tests.
|
|
130
|
+
export async function takeGatewayAddress({ home, credential, gatewayOrigin, handle, podHome, actorUrl, kind, log = () => {} }) {
|
|
131
|
+
const remote = new RemotePod(credential, { home, log });
|
|
132
|
+
await remote.warmup?.();
|
|
133
|
+
const origin = String(gatewayOrigin).replace(/\/$/, '');
|
|
134
|
+
const res = await remote.session.fetch(`${origin}/api/attach`, {
|
|
135
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
136
|
+
body: JSON.stringify({ handle, podHome, actorUrl, kind: kind === 'group' ? 'group' : 'person', fronted: true }),
|
|
137
|
+
});
|
|
138
|
+
const d = await res.json().catch(() => ({}));
|
|
139
|
+
if (res.status !== 201 || !d.hmacSecret) {
|
|
140
|
+
throw new Error(`could not take a gateway address at ${new URL(origin).host} (HTTP ${res.status})${d.error ? ': ' + d.error : ''}`);
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
url: `${origin}/u/${handle}/ap/inbox/`,
|
|
144
|
+
frontActor: String(d.frontActor || `${origin}/u/${handle}/ap/actor`),
|
|
145
|
+
hmacSecret: String(d.hmacSecret), mode: 'trust',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
114
149
|
// What the CLI printed before asking "create pod and fediverse account?"
|
|
115
150
|
// (bin/fedipod.mjs, the address preview) — as data, so the page can show
|
|
116
151
|
// the same warnings. Pure: no network, so it can answer while you type.
|
|
117
|
-
export function preflight({ mode, pod, issuer, podName, handle, kind }) {
|
|
152
|
+
export function preflight({ mode, pod, issuer, podName, handle, kind, shape = 'pod', gatewayOrigin = 'https://fedipod.net' }) {
|
|
118
153
|
const warnings = [];
|
|
119
154
|
if (!handle) return { ok: false, error: 'a handle is required' };
|
|
120
155
|
let issuerHost;
|
|
121
156
|
try { issuerHost = new URL(issuer || 'https://solidcommunity.net').host; }
|
|
122
157
|
catch { return { ok: false, error: `"${issuer}" is not a URL` }; }
|
|
123
158
|
|
|
159
|
+
let gwHost; try { gwHost = new URL(gatewayOrigin).host; } catch { gwHost = 'fedipod.net'; }
|
|
160
|
+
|
|
124
161
|
if (mode === 'new') {
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
162
|
+
// Whether the provider puts the pod on its own subdomain or on a path is
|
|
163
|
+
// the provider's call, learned only once the pod is made. If the address
|
|
164
|
+
// was asked to live at the Gateway, it does. Otherwise the pod's own host
|
|
165
|
+
// is the address, and the run moves it to the Gateway if the provider
|
|
166
|
+
// turned out to use paths.
|
|
167
|
+
if (shape === 'front') {
|
|
168
|
+
if (kind === 'group') return { ok: false, mode, handle, kind, error: 'group-needs-host-root', refusal: 'group-needs-host-root', warnings };
|
|
169
|
+
return { ok: true, mode, handle, kind, fronted: true, forced: false, gatewayHost: gwHost, shape: 'front',
|
|
170
|
+
address: `@${handle}@${gwHost}`, webfingerHost: null, resolvable: true, warnings, refusal: null };
|
|
171
|
+
}
|
|
129
172
|
return {
|
|
130
|
-
ok: true, mode, handle, kind,
|
|
173
|
+
ok: true, mode, handle, kind, fronted: false, shape: 'pod',
|
|
131
174
|
address: `@${handle}@${podName || handle}.${issuerHost}`,
|
|
132
175
|
webfingerHost: null, resolvable: null, warnings, refusal: null,
|
|
133
176
|
};
|
|
@@ -137,20 +180,26 @@ export function preflight({ mode, pod, issuer, podName, handle, kind }) {
|
|
|
137
180
|
try { podUrl = new URL(pod); }
|
|
138
181
|
catch { return { ok: false, error: `"${pod}" is not a pod address` }; }
|
|
139
182
|
// A handle resolves through <host>/.well-known/webfinger, so it only works
|
|
140
|
-
// when the pod owns the root of its host.
|
|
183
|
+
// when the pod owns the root of its host. A pod that does not can still be
|
|
184
|
+
// an account, with its address at the Gateway.
|
|
141
185
|
const wfHost = webfingerHost(podUrl.href);
|
|
142
|
-
|
|
143
|
-
if (
|
|
144
|
-
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
186
|
+
const fronted = !wfHost || shape === 'front';
|
|
187
|
+
if (fronted) {
|
|
188
|
+
// A person's address moves to the Gateway; a group cannot, yet — a group
|
|
189
|
+
// needs a pod at its own host until fronted groups are proven.
|
|
190
|
+
if (kind === 'group') {
|
|
191
|
+
return { ok: false, mode: 'existing', handle, kind, refusal: 'group-needs-host-root', warnings,
|
|
192
|
+
address: `@${handle}@${podUrl.host}`, webfingerHost: null, resolvable: false };
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
ok: true, mode: 'existing', handle, kind, fronted: true, forced: !wfHost, gatewayHost: gwHost, shape: 'front',
|
|
196
|
+
address: `@${handle}@${gwHost}`, webfingerHost: null, resolvable: true, warnings, refusal: null,
|
|
197
|
+
};
|
|
149
198
|
}
|
|
150
199
|
return {
|
|
151
|
-
ok:
|
|
152
|
-
address: `@${handle}@${wfHost
|
|
153
|
-
webfingerHost: wfHost, resolvable:
|
|
200
|
+
ok: true, mode: 'existing', handle, kind, fronted: false, shape: 'pod',
|
|
201
|
+
address: `@${handle}@${wfHost}`,
|
|
202
|
+
webfingerHost: wfHost, resolvable: true, warnings, refusal: null,
|
|
154
203
|
};
|
|
155
204
|
}
|
|
156
205
|
|
|
@@ -160,6 +209,7 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
160
209
|
const createAccount = deps.createAccountWithPod || realCreateAccount;
|
|
161
210
|
const mint = deps.mintCredential || realMint;
|
|
162
211
|
const checkPod = deps.checkPodUsable || checkPodUsable;
|
|
212
|
+
const attachGateway = deps.attachGateway || takeGatewayAddress;
|
|
163
213
|
|
|
164
214
|
const at = (key) => run.steps.find(s => s.key === key);
|
|
165
215
|
const begin = (key) => { at(key).state = 'running'; };
|
|
@@ -169,7 +219,7 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
169
219
|
const {
|
|
170
220
|
mode, issuer, email, password, handle, name, podName,
|
|
171
221
|
kind = 'person', approveJoins = false, summary, icon, keys, uiPassword,
|
|
172
|
-
gateway = null,
|
|
222
|
+
gateway = null, shape = 'pod', gatewayOrigin = 'https://fedipod.net',
|
|
173
223
|
} = answers;
|
|
174
224
|
let { pod, root } = answers;
|
|
175
225
|
let accountWebId = null; // what createAccountWithPod reported, when it ran
|
|
@@ -202,7 +252,7 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
202
252
|
// nobody could ever find this group. A person was warned before we got
|
|
203
253
|
// here and chose to continue; a group cannot.
|
|
204
254
|
if (kind === 'group' && !webfingerHost(pod)) {
|
|
205
|
-
throw new Error(`${issuer} created the pod at ${pod} — a
|
|
255
|
+
throw new Error(`${issuer} created the pod at ${pod} — a suffix-based host, `
|
|
206
256
|
+ 'not a host root. WebFinger is only answered at a host root, so nobody '
|
|
207
257
|
+ 'could find this group. The pod exists; no actor was published.');
|
|
208
258
|
}
|
|
@@ -259,6 +309,26 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
259
309
|
done('credential', credPath);
|
|
260
310
|
}
|
|
261
311
|
|
|
312
|
+
// --- take an address at the Gateway, if this pod needs one or asked for
|
|
313
|
+
// one --- before bootstrap, so the config it writes carries the Gateway
|
|
314
|
+
// ids and the key connect mints below is stamped to the Gateway actor from
|
|
315
|
+
// the start. A signup-arranged gateway (the installer carry-over) wins.
|
|
316
|
+
let gatewayCfg = gateway;
|
|
317
|
+
if (!gatewayCfg && frontedAddress({ pod, shape })) {
|
|
318
|
+
if (kind === 'group') {
|
|
319
|
+
throw new Error(`a group needs a pod at its own host — a Gateway address for a group is not supported yet`
|
|
320
|
+
+ (webfingerHost(pod) ? '' : `; ${pod} is a suffix-based host`) + '.');
|
|
321
|
+
}
|
|
322
|
+
const urls = apUrls(pod, root || DEFAULT_ROOT);
|
|
323
|
+
const cred = JSON.parse(fs.readFileSync(credPath, 'utf8'));
|
|
324
|
+
log(`taking a gateway address at ${new URL(gatewayOrigin).host}`);
|
|
325
|
+
gatewayCfg = await attachGateway({
|
|
326
|
+
home, credential: cred, gatewayOrigin, handle,
|
|
327
|
+
podHome: urls.home, actorUrl: urls.actor, kind, log,
|
|
328
|
+
});
|
|
329
|
+
log(`gateway address @${handle}@${new URL(gatewayOrigin).host}`);
|
|
330
|
+
}
|
|
331
|
+
|
|
262
332
|
// --- provision the pod and bring federation up ---
|
|
263
333
|
begin('bootstrap');
|
|
264
334
|
// Resuming skipped the checks the fresh paths ran, and the credential it
|
|
@@ -268,7 +338,7 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
268
338
|
const ready = await checkPod(pod, { webId: resumeWebId || undefined });
|
|
269
339
|
if (!ready.ok) throw new Error(ready.error);
|
|
270
340
|
}
|
|
271
|
-
await agent.bootstrap({ handle, name: name || handle, root, kind, approveJoins, summary, icon, gateway });
|
|
341
|
+
await agent.bootstrap({ handle, name: name || handle, root, kind, approveJoins, summary, icon, gateway: gatewayCfg });
|
|
272
342
|
done('bootstrap');
|
|
273
343
|
|
|
274
344
|
begin('connect');
|
|
@@ -293,14 +363,18 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
293
363
|
? `not readable without credentials: ${unreachable.join(', ')}`
|
|
294
364
|
: 'the public surface is reachable');
|
|
295
365
|
|
|
366
|
+
// A fronted identity resolves at the Gateway, not the pod host.
|
|
367
|
+
const frontActor = gatewayCfg?.frontActor || agent.store.getConfig()?.gateway?.frontActor || null;
|
|
368
|
+
const gwHost = frontActor ? new URL(frontActor).host : null;
|
|
296
369
|
run.result = {
|
|
297
370
|
kind,
|
|
298
371
|
pod,
|
|
299
372
|
handle,
|
|
300
373
|
actor: agent.urls?.actor || null,
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
374
|
+
fronted: !!frontActor,
|
|
375
|
+
webfingerHost: gwHost || wfHost,
|
|
376
|
+
resolvable: !!(gwHost || wfHost),
|
|
377
|
+
address: gwHost ? `@${handle}@${gwHost}` : (wfHost ? `@${handle}@${wfHost}` : null),
|
|
304
378
|
unreachable,
|
|
305
379
|
};
|
|
306
380
|
run.phase = 'done';
|
|
@@ -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
|
|
|
@@ -580,7 +581,11 @@ async function route(request, ctx) {
|
|
|
580
581
|
if (!m || m[2] !== ctx.host) return notFound();
|
|
581
582
|
const rec = await ctx.lookup(m[1]);
|
|
582
583
|
if (!rec) return notFound();
|
|
583
|
-
|
|
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 }),
|
|
584
589
|
'application/jrd+json');
|
|
585
590
|
}
|
|
586
591
|
|
|
@@ -607,6 +612,13 @@ async function route(request, ctx) {
|
|
|
607
612
|
// fixed to the front so a consumer cross-checks it consistently.
|
|
608
613
|
if (request.method !== 'GET' && request.method !== 'HEAD') return { status: 405, headers: {}, body: '' };
|
|
609
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
|
+
}
|
|
610
622
|
// The pod this read belongs to travels with it: an adapter reading a store
|
|
611
623
|
// directly (the CSS server component) has no access control of its own and
|
|
612
624
|
// needs to be told what it may reach. See podHomeProblem above for the other
|
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
|
+
* suffix-based 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).
|