fedipod-server 0.28.0 → 0.29.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 +27 -28
- package/config/server.json +22 -0
- package/dist/claims.js +3 -2
- package/dist/directory.d.ts +2 -0
- package/dist/handler.d.ts +4 -1
- package/dist/handler.js +32 -3
- package/dist/handler.jsonld +4 -0
- package/dist/store-pod.js +37 -0
- package/lib/client/c2s.mjs +97 -43
- package/lib/client/masto/accounts.mjs +1 -0
- package/lib/connections/bskyfeed.mjs +6 -0
- package/lib/core/deliver.mjs +68 -22
- package/lib/core/intake/activities.mjs +18 -2
- package/lib/core/intake/group.mjs +3 -1
- package/lib/core/intake/index.mjs +50 -6
- package/lib/core/intake/notes.mjs +12 -5
- package/lib/core/lease.mjs +7 -7
- package/lib/core/place.mjs +82 -0
- package/lib/core/publisher/collections.mjs +37 -2
- package/lib/core/publisher/index.mjs +23 -1
- package/lib/core/publisher/notes.mjs +73 -7
- package/lib/core/publisher/own.mjs +143 -0
- package/lib/core/publisher/questions.mjs +5 -3
- package/lib/core/social.mjs +62 -32
- package/lib/core/store.mjs +36 -6
- package/lib/core/wire.mjs +51 -15
- package/lib/device/admin/routes/lifecycle.mjs +1 -1
- package/lib/device/admin/routes/setup.mjs +17 -1
- package/lib/device/cli/commands/setup.mjs +50 -8
- package/lib/device/cli/context.mjs +1 -1
- package/lib/device/migrate.mjs +1 -1
- package/lib/device/setup.mjs +45 -8
- package/lib/gateway/caches.mjs +36 -0
- package/lib/gateway/front-core.mjs +80 -111
- package/lib/gateway/gateway-core.mjs +91 -9
- package/lib/gateway/headers.mjs +49 -0
- package/lib/gateway/quiet.mjs +7 -2
- package/lib/gateway/relay-extras.mjs +89 -0
- package/lib/gateway/token-claims.mjs +16 -0
- package/lib/pod/containers.mjs +5 -1
- package/lib/pod/inbox.mjs +22 -3
- package/lib/pod/location.mjs +52 -0
- package/lib/pod/notes.mjs +2 -4
- package/lib/pod/transport.mjs +207 -25
- package/lib/pod/type-index.mjs +101 -0
- package/lib/pod/urls.mjs +6 -0
- package/lib/server/embed.mjs +7 -6
- package/lib/session/README.md +14 -0
- package/lib/session/fedi-account.mjs +73 -2
- package/lib/session/package.json +10 -2
- package/package.json +1 -1
- package/run-agent.mjs +18 -0
- package/web/admin/actors.js +2 -0
- package/web/admin/index.html +9 -0
- package/web/admin/record.js +4 -1
- package/web/admin/setup/index.html +17 -1
- package/web/admin/setup/setup.js +18 -5
- package/web/app/README.md +1 -1
- package/web/app/admin-facade.mjs +1 -1
- package/web/app/agent.mjs +24 -10
- package/web/app/boot.mjs +72 -32
- package/web/app/deliver-relay.mjs +47 -7
- package/web/app/dist/boot.js +466 -89
- package/web/app/dist/boot.js.map +4 -4
- package/web/app/dist/sw.js +2792 -1996
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/index.html +16 -0
- package/web/app/signup.mjs +65 -26
- package/web/app/update.js +4 -0
- package/web/front/run.html +7 -1
- package/web/front/run.js +30 -4
package/run-agent.mjs
CHANGED
|
@@ -53,6 +53,7 @@ import { exposureProblem, hostLabel } from './lib/shared/guard.mjs';
|
|
|
53
53
|
import { pendingSteps } from './lib/device/migrate.mjs';
|
|
54
54
|
import { apUrls, assertionKeyId , publicHandle } from './lib/core/wire.mjs';
|
|
55
55
|
import { followActor, unfollowActor, resolveHandle } from './lib/core/social.mjs';
|
|
56
|
+
import * as podInbox from './lib/pod/inbox.mjs';
|
|
56
57
|
|
|
57
58
|
export class Agent {
|
|
58
59
|
constructor({ home, log }) {
|
|
@@ -510,6 +511,19 @@ export class Agent {
|
|
|
510
511
|
return true;
|
|
511
512
|
}
|
|
512
513
|
|
|
514
|
+
// The inbox's door must be open to the world (or to the gateway alone, in
|
|
515
|
+
// locked mode) or nothing anyone sends ever lands. The permission is written
|
|
516
|
+
// at setup, and a container made again later — a root move, a pod rebuilt —
|
|
517
|
+
// came back without it: the group's inbox on fp2 refused every delivery for
|
|
518
|
+
// nine days while its agent ran on, healthy-looking. One write per start,
|
|
519
|
+
// and it is idempotent.
|
|
520
|
+
async ensureInboxOpen() {
|
|
521
|
+
const g = this.store.getConfig()?.gateway;
|
|
522
|
+
const posture = g?.mode === 'locked' && g.webId ? { gatewayWebId: g.webId } : 'open';
|
|
523
|
+
await podInbox.setPosture(this.remote, this.urls, posture);
|
|
524
|
+
return posture;
|
|
525
|
+
}
|
|
526
|
+
|
|
513
527
|
// Read-only mode: refresh the state cache periodically, and take over the
|
|
514
528
|
// moment the active agent's lease frees.
|
|
515
529
|
startViewer() {
|
|
@@ -538,6 +552,8 @@ export class Agent {
|
|
|
538
552
|
// Own posts the outbox names and the timeline index lacks come back here,
|
|
539
553
|
// before anything acts on the index.
|
|
540
554
|
await this.publisher.healStatuses().catch(e => this.log(`healing the timeline index: ${e.message}`));
|
|
555
|
+
// Likes made before the liked list existed become its first entries.
|
|
556
|
+
try { this.publisher.backfillLiked(); } catch (e) { this.log(`liked list: ${e.message}`); }
|
|
541
557
|
this.lease.onLost = () => this.demote();
|
|
542
558
|
this.lease.startRenewal();
|
|
543
559
|
this.deliverer.startQueue();
|
|
@@ -607,6 +623,8 @@ export class Agent {
|
|
|
607
623
|
.catch(e => this.log(`actor check failed: ${e.message}`));
|
|
608
624
|
this.ensureFeaturedPublished()
|
|
609
625
|
.catch(e => this.log(`featured check failed: ${e.message}`));
|
|
626
|
+
this.ensureInboxOpen()
|
|
627
|
+
.catch(e => this.log(`inbox door check failed: ${e.message}`));
|
|
610
628
|
// The human page, rewritten only when what it shows has changed: one
|
|
611
629
|
// local digest compare, and a write the first time after an upgrade.
|
|
612
630
|
this.publisher.publishProfilePage()
|
package/web/admin/actors.js
CHANGED
|
@@ -136,6 +136,8 @@ $('new-actor-form').addEventListener('submit', async (ev) => {
|
|
|
136
136
|
};
|
|
137
137
|
if (answers.mode === 'new') answers.podName = $('new-podname').value.trim() || handle;
|
|
138
138
|
else answers.pod = $('new-pod').value.trim();
|
|
139
|
+
answers.container = $('new-container').value.trim();
|
|
140
|
+
answers.createIndex = $('new-createIndex').checked;
|
|
139
141
|
$('new-actor-go').disabled = true;
|
|
140
142
|
say(`setting up ${handle || 'the new actor'} — this takes a while`);
|
|
141
143
|
const r = await write('/new-actor', answers, `setting up ${handle}`);
|
package/web/admin/index.html
CHANGED
|
@@ -272,6 +272,15 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
|
|
|
272
272
|
<label for="new-pod">Pod address</label>
|
|
273
273
|
<input type="url" id="new-pod" autocomplete="url" placeholder="https://you.solidcommunity.net/">
|
|
274
274
|
</div>
|
|
275
|
+
<label for="new-container">Store your data in a container named fedipod, inside this container</label>
|
|
276
|
+
<input type="text" id="new-container" autocomplete="off" spellcheck="false" placeholder="/"
|
|
277
|
+
aria-describedby="new-container-hint">
|
|
278
|
+
<p class="hint" id="new-container-hint">Leave it empty for your pod's root. This is permanent.</p>
|
|
279
|
+
<p id="new-typeindex-text">FediPod records where the account lives in your pod's public type index,
|
|
280
|
+
the list Solid apps use to find your things. A new pod has none; setup stops if yours has none
|
|
281
|
+
and you have not said yes here. It will be readable by anyone, like your profile.</p>
|
|
282
|
+
<label class="choice"><input type="checkbox" id="new-createIndex" aria-describedby="new-typeindex-text">
|
|
283
|
+
If my pod has no public type index, create one</label>
|
|
275
284
|
</fieldset>
|
|
276
285
|
|
|
277
286
|
<p>
|
package/web/admin/record.js
CHANGED
|
@@ -269,6 +269,9 @@ function render() {
|
|
|
269
269
|
['Fediverse identity', config.address || `@${config.handle} — no resolvable address`,
|
|
270
270
|
config.accountId && config.address ? { href: `/admin/client/#/a/${config.accountId}` } : null],
|
|
271
271
|
['Solid identity', config.webId, config.remotePod ? { href: config.remotePod, blank: true } : null],
|
|
272
|
+
// Where on the pod the account's data lives: the container its owner chose.
|
|
273
|
+
['stored at', config.remotePod && config.root ? config.remotePod + config.root : null,
|
|
274
|
+
config.remotePod && config.root ? { href: config.remotePod + config.root, blank: true } : null],
|
|
272
275
|
['Other identities', 'ctl'],
|
|
273
276
|
['local store', config.home],
|
|
274
277
|
// The address you actually open, not the bare number — the named origin when
|
|
@@ -362,7 +365,7 @@ function render() {
|
|
|
362
365
|
if (!config.address) {
|
|
363
366
|
const p = document.createElement('p');
|
|
364
367
|
p.className = 'warn';
|
|
365
|
-
p.textContent = `${config.remotePod} is
|
|
368
|
+
p.textContent = `${config.remotePod} is a suffixed pod, so this actor cannot be `
|
|
366
369
|
+ 'discovered as a handle by other servers. Posting and reading still work.';
|
|
367
370
|
$('pane-identity').appendChild(p);
|
|
368
371
|
}
|
|
@@ -151,7 +151,7 @@ button:disabled { opacity: .5; cursor: default; }
|
|
|
151
151
|
<input type="url" id="issuerOther" name="issuerOther" autocomplete="url" placeholder="https://pods.example.org">
|
|
152
152
|
</div>
|
|
153
153
|
<p class="hint" id="issuer-new-hint">The Community Solid Server providers from
|
|
154
|
-
solidproject.org. A provider that
|
|
154
|
+
solidproject.org. A provider that makes suffixed pods gives your address at a
|
|
155
155
|
gateway instead.</p>
|
|
156
156
|
</div>
|
|
157
157
|
|
|
@@ -184,6 +184,22 @@ button:disabled { opacity: .5; cursor: default; }
|
|
|
184
184
|
<label for="pod">Pod address</label>
|
|
185
185
|
<input type="url" id="pod" name="pod" autocomplete="url" placeholder="https://you.solidcommunity.net/">
|
|
186
186
|
</div>
|
|
187
|
+
|
|
188
|
+
<div id="row-container">
|
|
189
|
+
<label for="container">Store your data in a container named fedipod, inside this container</label>
|
|
190
|
+
<input type="text" id="container" name="container" autocomplete="off" spellcheck="false"
|
|
191
|
+
placeholder="/" aria-describedby="container-hint">
|
|
192
|
+
<p class="hint" id="container-hint">Leave it empty for your pod's root. This is permanent.
|
|
193
|
+
<span id="place"></span></p>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
<div id="row-typeindex" hidden>
|
|
197
|
+
<p id="typeindex-text">Your pod has no public type index, the list Solid apps use to find your
|
|
198
|
+
things. FediPod needs one to record where your account lives. It will be readable by anyone,
|
|
199
|
+
like your profile.</p>
|
|
200
|
+
<label class="choice"><input type="checkbox" id="createIndex" name="createIndex"
|
|
201
|
+
aria-describedby="typeindex-text"> Create a public type index on my pod</label>
|
|
202
|
+
</div>
|
|
187
203
|
</fieldset>
|
|
188
204
|
|
|
189
205
|
<p id="row-reenter" class="warn" hidden>Finishing a setup that already has an account credential.
|
package/web/admin/setup/setup.js
CHANGED
|
@@ -108,6 +108,9 @@ function answers() {
|
|
|
108
108
|
};
|
|
109
109
|
if (mode === 'new') a.podName = f.podName.value.trim() || a.handle;
|
|
110
110
|
else a.pod = f.pod.value.trim();
|
|
111
|
+
// The container that holds fedipod/, and the yes to a new public type index.
|
|
112
|
+
a.container = f.container.value.trim();
|
|
113
|
+
a.createIndex = f.createIndex.checked;
|
|
111
114
|
if (!state.resumable && !state.passwordSupplied) a.password = f.password.value;
|
|
112
115
|
return a;
|
|
113
116
|
}
|
|
@@ -116,13 +119,13 @@ function answers() {
|
|
|
116
119
|
// account?", in the same words, as you type.
|
|
117
120
|
const NOTES = {
|
|
118
121
|
'pod-is-a-path': (a) =>
|
|
119
|
-
`This pod is
|
|
120
|
-
+ 'answered only
|
|
122
|
+
`This pod is suffixed (on ${host(a.pod)}), not subdomained. WebFinger is `
|
|
123
|
+
+ 'answered only for a subdomained pod, so other servers '
|
|
121
124
|
+ 'will not find this address. Posting and reading still work; being discovered does not.',
|
|
122
125
|
};
|
|
123
126
|
const REFUSALS = {
|
|
124
127
|
'group-needs-host-root': () =>
|
|
125
|
-
'A group needs a pod
|
|
128
|
+
'A group needs a subdomained pod, or nobody could ever find it. '
|
|
126
129
|
+ 'Give the group a pod of its own, or set this up as a person.',
|
|
127
130
|
};
|
|
128
131
|
const host = (u) => { try { return new URL(u).host; } catch { return u; } };
|
|
@@ -149,6 +152,11 @@ async function preview() {
|
|
|
149
152
|
const { json } = await postJson('/setup/check', a);
|
|
150
153
|
if (!json) return;
|
|
151
154
|
$('preview').textContent = json.address || '…';
|
|
155
|
+
// Where the data will go, and the type index question when the pod has
|
|
156
|
+
// none (a new pod never does).
|
|
157
|
+
$('place').textContent = json.placeProblem ? json.placeProblem : (json.place ? `Your data will be at ${json.place}.` : '');
|
|
158
|
+
$('row-typeindex').hidden = json.typeIndex === true;
|
|
159
|
+
try { $('container').placeholder = a.mode === 'existing' && a.pod ? new URL(a.pod).pathname : '/'; } catch { /* not a URL yet */ }
|
|
152
160
|
// The address-shape choice: hidden for a group (a group cannot front yet),
|
|
153
161
|
// locked to the gateway for a pod on a suffix-based host, an open choice
|
|
154
162
|
// for a pod at its own host.
|
|
@@ -165,7 +173,7 @@ async function preview() {
|
|
|
165
173
|
if (note) {
|
|
166
174
|
note.hidden = !forced;
|
|
167
175
|
note.textContent = forced
|
|
168
|
-
? 'Your pod is
|
|
176
|
+
? 'Your pod is suffixed, so its address lives at the gateway. Your posts, key and data stay on your pod.'
|
|
169
177
|
: '';
|
|
170
178
|
}
|
|
171
179
|
}
|
|
@@ -188,6 +196,11 @@ async function preview() {
|
|
|
188
196
|
async function onSubmit(ev) {
|
|
189
197
|
ev.preventDefault();
|
|
190
198
|
$('form-error').textContent = '';
|
|
199
|
+
// No type index and no yes: stop here, before anything is sent.
|
|
200
|
+
if (!$('row-typeindex').hidden && !$('createIndex').checked) {
|
|
201
|
+
$('form-error').textContent = 'Setup stopped. Nothing was written to your pod. FediPod needs a public type index to record where your account lives.';
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
191
204
|
$('submit').disabled = true;
|
|
192
205
|
const { status, json } = await postJson('/setup', answers());
|
|
193
206
|
if (status !== 202) {
|
|
@@ -300,7 +313,7 @@ function paneDone(result) {
|
|
|
300
313
|
$('done-address').textContent = result?.address || `@${result?.handle} — no resolvable address`;
|
|
301
314
|
const notes = [];
|
|
302
315
|
if (!result?.resolvable) {
|
|
303
|
-
notes.push(`${result?.pod} is
|
|
316
|
+
notes.push(`${result?.pod} is a suffixed pod, so this address cannot be `
|
|
304
317
|
+ 'discovered by other servers. Posting and reading still work.');
|
|
305
318
|
}
|
|
306
319
|
if (result?.unreachable?.length) {
|
package/web/app/README.md
CHANGED
|
@@ -77,5 +77,5 @@ See the `MastoApi` options in `agent.mjs`.
|
|
|
77
77
|
| **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. |
|
|
78
78
|
| **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. |
|
|
79
79
|
| **Groups** | sign-up makes personal identities only (`signup.mjs`), and the moderation surface is not here. Joining a group works; hosting one needs the DeviceAgent. See `groups.md`. |
|
|
80
|
-
| **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
|
|
80
|
+
| **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 suffixed pod is always fronted. `admin-facade.mjs` refuses changing it afterwards, because a rename needs a restart a browser does not have. |
|
|
81
81
|
| **Moving the private half** | `/state-move` is about filesystem paths and `credential.json`. A browser has neither; its private half is always on the pod. |
|
package/web/app/admin-facade.mjs
CHANGED
|
@@ -356,7 +356,7 @@ export class AdminFacade {
|
|
|
356
356
|
const aliases = [...(cfg.aliases || [])];
|
|
357
357
|
if (body.add) {
|
|
358
358
|
if (!webfingerHost(urls.base) && !cfg.gateway?.frontActor) {
|
|
359
|
-
return json(400, { error: 'this pod is
|
|
359
|
+
return json(400, { error: 'this pod is suffixed, so other servers could never resolve it as a Move target' });
|
|
360
360
|
}
|
|
361
361
|
const id = await this.resolveActor(body.add);
|
|
362
362
|
if (!id) return json(400, { error: `could not fetch the old account (${body.add}) — enter its URL or @user@host, and it must answer` });
|
package/web/app/agent.mjs
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// the relay (deliver-relay). Everything between — wire, the store, the
|
|
7
7
|
// publisher, intake, the Mastodon facade — is lib/, unchanged.
|
|
8
8
|
import { kvGet, kvPut } from './idb-kv.mjs';
|
|
9
|
-
import { apUrls } from '../../lib/core/wire.mjs';
|
|
9
|
+
import { apUrls, DEFAULT_ROOT } from '../../lib/core/wire.mjs';
|
|
10
|
+
import { findAccount } from '../../lib/core/place.mjs';
|
|
10
11
|
import * as containers from '../../lib/pod/containers.mjs';
|
|
11
12
|
import * as podState from '../../lib/pod/state.mjs';
|
|
12
13
|
import { PodStore } from '../../lib/core/store.mjs';
|
|
@@ -169,15 +170,19 @@ export class BrowserAgent {
|
|
|
169
170
|
this.lease.onLost = () => this.demote();
|
|
170
171
|
this.lease.startRenewal();
|
|
171
172
|
try {
|
|
172
|
-
// Forced, not revalidated
|
|
173
|
-
// writing
|
|
174
|
-
// the store is write-through — so the first write from here
|
|
175
|
-
// whole document back over newer state.
|
|
176
|
-
//
|
|
177
|
-
|
|
173
|
+
// Forced, not revalidated, when this device WATCHED first: the active
|
|
174
|
+
// one was writing, our cache is however stale the last viewer poll left
|
|
175
|
+
// it, and the store is write-through — so the first write from here
|
|
176
|
+
// would push a whole document back over newer state. A device that
|
|
177
|
+
// booted straight into acting read everything a moment ago; asking
|
|
178
|
+
// again is one revalidation, not a second download of every document.
|
|
179
|
+
await this.store.load({ force: !!this._watched }).catch((e) => this.log(`re-reading state: ${e.message}`));
|
|
180
|
+
this._watched = false;
|
|
178
181
|
// Own posts the outbox names and the timeline index lacks come back
|
|
179
182
|
// here, before anything acts on the index.
|
|
180
183
|
await this.publisher.healStatuses().catch((e) => this.log(`healing the timeline index: ${e.message}`));
|
|
184
|
+
// Likes made before the liked list existed become its first entries.
|
|
185
|
+
try { this.publisher.backfillLiked(); } catch (e) { this.log(`liked list: ${e.message}`); }
|
|
181
186
|
await this.publisher.publishProfilePage().catch((e) => this.log(`profile page: ${e.message}`));
|
|
182
187
|
// And start delivering again, since demote() stopped it. startQueue() is
|
|
183
188
|
// idempotent, so a goActive() that was already active costs nothing.
|
|
@@ -202,6 +207,7 @@ export class BrowserAgent {
|
|
|
202
207
|
demote() {
|
|
203
208
|
if (this.viewer) return;
|
|
204
209
|
this.viewer = true;
|
|
210
|
+
this._watched = true;
|
|
205
211
|
this.log('another device took over — read-only here');
|
|
206
212
|
this.lease.stopRenewal();
|
|
207
213
|
clearInterval(this._openTimer); this._openTimer = null;
|
|
@@ -286,8 +292,14 @@ export class BrowserAgent {
|
|
|
286
292
|
// The pod session's own fetch, for the gateway APIs that take it as
|
|
287
293
|
// proof of the pod (attach, move) — the transport below is for the pod.
|
|
288
294
|
this.sessionFetch = session.fetch;
|
|
289
|
-
const root = (config && config.root) || 'fedipod/';
|
|
290
295
|
this.remote = new BrowserRemotePod(session, { webId, log: this.log });
|
|
296
|
+
// Where the account lives: handed in on sign-up, else what its owner's type
|
|
297
|
+
// index records, else `fedipod/` for an account older than the choice.
|
|
298
|
+
const root = (config && config.root)
|
|
299
|
+
|| (await findAccount(this.remote, remotePod,
|
|
300
|
+
(r) => podState.readConfig(this.remote, { state: `${remotePod}${r}ap-state/` }),
|
|
301
|
+
(c) => (c.kind || 'person') === 'person').catch(() => null))?.root
|
|
302
|
+
|| DEFAULT_ROOT;
|
|
291
303
|
// Pod-native for now: the state store below is read with these, and only
|
|
292
304
|
// the config it holds says whether this identity is fronted.
|
|
293
305
|
this.urls = apUrls(remotePod, root);
|
|
@@ -355,8 +367,9 @@ export class BrowserAgent {
|
|
|
355
367
|
// somebody reads from counting as a quiet one. Only a closed address
|
|
356
368
|
// stops the boot; a gateway that cannot be reached is no reason to
|
|
357
369
|
// refuse a sign-in.
|
|
358
|
-
|
|
359
|
-
|
|
370
|
+
// At the page's own origin, as the relay is: a page on the test alias
|
|
371
|
+
// asked the real site instead and could not get past its preflight.
|
|
372
|
+
this.gatewayApi = config.gateway?.url ? `${frontOrigin.replace(/\/$/, '')}/api` : null;
|
|
360
373
|
this.doorKey = doorKeyOf(config.gateway?.url) || config.handle;
|
|
361
374
|
this.gatewayStanding = null;
|
|
362
375
|
const standing = await this.openAtGateway();
|
|
@@ -476,6 +489,7 @@ export class BrowserAgent {
|
|
|
476
489
|
// The lease decides: this device ACTS on the pod, or reads it read-only.
|
|
477
490
|
this.viewer = !(await this.lease.acquire());
|
|
478
491
|
if (this.viewer) {
|
|
492
|
+
this._watched = true;
|
|
479
493
|
this.log(`read-only viewer: another device is active on @${config.handle}`);
|
|
480
494
|
this.startViewerPoll(); // reload the feed, and promote if the lease frees
|
|
481
495
|
return;
|
package/web/app/boot.mjs
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
// fedipodMoveIn(answers) brings the address here.
|
|
15
15
|
// Returning: fedipodSignin({ address }) — redirect to the pod's login.
|
|
16
16
|
// On every load: fedipodOnLoad() — finish a redirect, or restore, then boot.
|
|
17
|
-
import { signUp, moveIn, readAccount, handleProblem
|
|
17
|
+
import { signUp, moveIn, readAccount, handleProblem } from './signup.mjs';
|
|
18
|
+
import { podRootPath, chosenRoot } from '../../lib/core/place.mjs';
|
|
18
19
|
import * as podActor from '../../lib/pod/actor.mjs';
|
|
19
20
|
import * as podState from '../../lib/pod/state.mjs';
|
|
20
21
|
import { podBaseOfWebId } from '../../lib/pod/urls.mjs';
|
|
@@ -76,16 +77,15 @@ async function bootWorker({ reset = false } = {}) {
|
|
|
76
77
|
async function readAccountState() {
|
|
77
78
|
const session = await getSession();
|
|
78
79
|
if (!session) throw new Error('Sign in first.');
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
// Wherever its owner put it: the type index says, or it is an older account
|
|
81
|
+
// at `fedipod/` (signup.mjs readAccount).
|
|
82
|
+
const here = await readAccount(session);
|
|
83
|
+
if (!here) throw new Error(`could not find a FediPod account on ${podBaseOfWebId(session.webId)}`);
|
|
81
84
|
const remote = new BrowserRemotePod(session, { webId: session.webId, role: 'signup', log: () => {} });
|
|
82
|
-
const urls = { state };
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (!cfg) throw new Error(`could not read this account's config under ${state}`);
|
|
87
|
-
const actorUrl = `${cfg.remotePod}${cfg.root || AP_ROOT}ap/actor`;
|
|
88
|
-
return { remote, urls, cfg, doc, actorUrl };
|
|
85
|
+
const urls = { state: `${here.pod}${here.root}ap-state/` };
|
|
86
|
+
const doc = await podState.readKeys(remote, urls);
|
|
87
|
+
const actorUrl = `${here.config.remotePod || here.pod}${here.config.root || here.root}ap/actor`;
|
|
88
|
+
return { remote, urls, cfg: here.config, doc, actorUrl };
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
window.fedipodUnlock = async (password) => {
|
|
@@ -161,22 +161,33 @@ export function parseAddress(input) {
|
|
|
161
161
|
if (!handle || !host || !host.includes('.')) return null;
|
|
162
162
|
return { handle, host };
|
|
163
163
|
}
|
|
164
|
-
async function
|
|
164
|
+
async function issuerForActor(actorUrl) {
|
|
165
165
|
// The pod's actor says where a client signs in (oauthAuthorizationEndpoint's
|
|
166
|
-
// origin)
|
|
167
|
-
//
|
|
166
|
+
// origin). Failing that: a host that answers OpenID discovery itself is the
|
|
167
|
+
// provider (a suffixed pod), and otherwise the provider is
|
|
168
|
+
// the pod host's parent domain (a subdomain pod).
|
|
168
169
|
try {
|
|
169
|
-
const authz = await podActor.readIssuer(
|
|
170
|
+
const authz = await podActor.readIssuer(actorUrl);
|
|
170
171
|
if (authz) return new URL(authz).origin;
|
|
171
172
|
} catch { /* fall through */ }
|
|
172
|
-
const u = new URL(
|
|
173
|
-
|
|
173
|
+
const u = new URL(actorUrl);
|
|
174
|
+
const own = await fetch(`${u.origin}/.well-known/openid-configuration`, { headers: { accept: 'application/json' } }).catch(() => null);
|
|
175
|
+
if (own?.ok) return u.origin;
|
|
174
176
|
const parent = u.host.split('.').slice(1).join('.');
|
|
175
177
|
return `https://${parent || u.host}`;
|
|
176
178
|
}
|
|
179
|
+
// An address on a pod names its actor through the pod host's own WebFinger;
|
|
180
|
+
// an older pod that does not answer is taken to keep it at `fedipod/`.
|
|
181
|
+
async function actorForPodAddress(handle, host) {
|
|
182
|
+
const res = await fetch(`https://${host}/.well-known/webfinger?resource=${encodeURIComponent(`acct:${handle}@${host}`)}`,
|
|
183
|
+
{ headers: { accept: 'application/jrd+json, application/json' } }).catch(() => null);
|
|
184
|
+
const doc = res?.ok ? await res.json().catch(() => ({})) : {};
|
|
185
|
+
const self = (doc.links || []).find((l) => l.rel === 'self' && /activity\+json|ld\+json/u.test(l.type || ''));
|
|
186
|
+
return self?.href || `https://${host}/fedipod/ap/actor`;
|
|
187
|
+
}
|
|
177
188
|
// An address at this site names a fronted identity. Its documents live on a
|
|
178
189
|
// pod this site's WebFinger names as an alias — the pod's own actor id.
|
|
179
|
-
async function
|
|
190
|
+
async function actorForFrontedAddress(handle) {
|
|
180
191
|
const res = await fetch(`/.well-known/webfinger?resource=${encodeURIComponent(`acct:${handle}@${location.host}`)}`,
|
|
181
192
|
{ headers: { accept: 'application/jrd+json, application/json' } }).catch(() => null);
|
|
182
193
|
if (res?.status === 410) throw new Error(`@${handle}@${location.host} is closed: nothing on the pod behind it was touched, but the address is gone for good.`);
|
|
@@ -184,19 +195,17 @@ async function podForFrontedAddress(handle) {
|
|
|
184
195
|
const doc = await res.json().catch(() => ({}));
|
|
185
196
|
const podActorId = (doc.aliases || []).find((a) => /\/ap\/actor$/u.test(String(a)));
|
|
186
197
|
if (!podActorId) throw new Error(`@${handle}@${location.host} lives here but names no pod to sign in to`);
|
|
187
|
-
|
|
188
|
-
if (!podActorId.endsWith(tail)) throw new Error(`the pod actor ${podActorId} is not where a FediPod pod keeps one`);
|
|
189
|
-
return podActorId.slice(0, -tail.length);
|
|
198
|
+
return podActorId;
|
|
190
199
|
}
|
|
191
200
|
window.fedipodSignin = async ({ address }) => {
|
|
192
201
|
const parsed = parseAddress(address);
|
|
193
202
|
if (!parsed) throw new Error('Enter your address as @you@yourpod (for example @alice@alice.solidcommunity.net).');
|
|
194
203
|
const bad = handleProblem(parsed.handle);
|
|
195
204
|
if (bad) throw new Error(bad);
|
|
196
|
-
const
|
|
197
|
-
? await
|
|
198
|
-
:
|
|
199
|
-
const issuer = await
|
|
205
|
+
const actor = parsed.host === location.host.toLowerCase()
|
|
206
|
+
? await actorForFrontedAddress(parsed.handle)
|
|
207
|
+
: await actorForPodAddress(parsed.handle, parsed.host);
|
|
208
|
+
const issuer = await issuerForActor(actor);
|
|
200
209
|
const { authorizationUrl } = await beginLogin({ issuer, redirectUri: REDIRECT });
|
|
201
210
|
location.href = authorizationUrl;
|
|
202
211
|
};
|
|
@@ -445,6 +454,10 @@ if (typeof document !== 'undefined') (async () => {
|
|
|
445
454
|
$('loading').hidden = true; $('hero').hidden = true; $('landing').hidden = true; $('running').hidden = true;
|
|
446
455
|
$('brand').hidden = false; $('pane-form').hidden = false;
|
|
447
456
|
$('signed-pod').textContent = signedPod;
|
|
457
|
+
// The container that holds `fedipod/`: the pod's own root unless they say
|
|
458
|
+
// otherwise. A move keeps the place the account already has.
|
|
459
|
+
$('container').placeholder = podRootPath(signedPod);
|
|
460
|
+
$('container-field').hidden = !!moveFrom;
|
|
448
461
|
$('movein-note').hidden = !moveFrom;
|
|
449
462
|
if (moveFrom) {
|
|
450
463
|
$('movein-from').textContent = moveFrom.address;
|
|
@@ -484,7 +497,13 @@ if (typeof document !== 'undefined') (async () => {
|
|
|
484
497
|
const pathPod = () => { try { return new URL(signedPod).pathname !== '/'; } catch { return false; } };
|
|
485
498
|
// A move between gateways is an address at a gateway: no choice either.
|
|
486
499
|
const shape = () => ((pathPod() || moveFrom) ? 'front' : f().shape.value);
|
|
487
|
-
const answers = () => ({ handle: f().handle.value.trim().toLowerCase(), shape: shape() });
|
|
500
|
+
const answers = () => ({ handle: f().handle.value.trim().toLowerCase(), shape: shape(), container: f().container.value.trim() });
|
|
501
|
+
// Where the data will be, in full, as the person types.
|
|
502
|
+
const previewPlace = () => {
|
|
503
|
+
if (!signedPod) return;
|
|
504
|
+
const r = chosenRoot(signedPod, f().container.value);
|
|
505
|
+
$('place').textContent = r.problem ? '…' : signedPod + r.root;
|
|
506
|
+
};
|
|
488
507
|
const previewAddr = () => {
|
|
489
508
|
const handle = f().handle.value.trim().toLowerCase();
|
|
490
509
|
let host = '';
|
|
@@ -507,7 +526,7 @@ if (typeof document !== 'undefined') (async () => {
|
|
|
507
526
|
$('register-link').href = url || '#';
|
|
508
527
|
$('register-link').textContent = providerHost() ? `Create your pod at ${providerHost()}` : 'Create your pod at your provider';
|
|
509
528
|
};
|
|
510
|
-
for (const el of $('form').elements) for (const evt of ['input', 'change']) el.addEventListener(evt, () => { applyMode(); applyShape(); previewAddr(); });
|
|
529
|
+
for (const el of $('form').elements) for (const evt of ['input', 'change']) el.addEventListener(evt, () => { applyMode(); applyShape(); previewAddr(); previewPlace(); });
|
|
511
530
|
applyMode();
|
|
512
531
|
|
|
513
532
|
// Step machine: one screen at a time, each gated by its own validation.
|
|
@@ -516,7 +535,7 @@ if (typeof document !== 'undefined') (async () => {
|
|
|
516
535
|
const goStep = (n) => {
|
|
517
536
|
STEP_IDS.forEach((id, i) => { $(id).hidden = i !== n - 1; });
|
|
518
537
|
$('err-1').textContent = ''; $('form-error').textContent = '';
|
|
519
|
-
if (n === 2) { applyShape(); previewAddr(); }
|
|
538
|
+
if (n === 2) { applyShape(); previewAddr(); previewPlace(); $('index-ask').hidden = true; }
|
|
520
539
|
if (FOCUS[n]) $(FOCUS[n]).focus();
|
|
521
540
|
};
|
|
522
541
|
const validateStep1 = () => {
|
|
@@ -526,6 +545,10 @@ if (typeof document !== 'undefined') (async () => {
|
|
|
526
545
|
const validateStep2 = () => {
|
|
527
546
|
const hp = window.fedipodHandleProblem(f().handle.value.trim().toLowerCase());
|
|
528
547
|
if (hp) return `Fediverse handle: ${hp}`;
|
|
548
|
+
if (!moveFrom) {
|
|
549
|
+
const where = chosenRoot(signedPod, f().container.value).problem;
|
|
550
|
+
if (where) return `Where to store it: ${where}`;
|
|
551
|
+
}
|
|
529
552
|
return null;
|
|
530
553
|
};
|
|
531
554
|
$('create').addEventListener('click', showForm);
|
|
@@ -549,12 +572,12 @@ if (typeof document !== 'undefined') (async () => {
|
|
|
549
572
|
$('run-retry').addEventListener('click', () => backToForm($('run-error').textContent));
|
|
550
573
|
$('run-back').addEventListener('click', () => { showLanding(); goStep(1); });
|
|
551
574
|
|
|
552
|
-
const LABELS = { pod: 'Checking your pod', keys: 'Making your signing key', gateway: 'Connecting your mail door'
|
|
575
|
+
const LABELS = { pod: 'Checking your pod', keys: 'Making your signing key', gateway: 'Connecting your mail door',
|
|
576
|
+
place: 'Recording where your account lives' };
|
|
553
577
|
const MOVE_LABELS = { pod: 'Reading your account on your pod', gateway: 'Taking your address here', keys: 'Moving your key and account record' };
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
const
|
|
557
|
-
const a = answers();
|
|
578
|
+
// Run the setup. `createIndex` is the person's yes to a new public type index.
|
|
579
|
+
const run = async (createIndex = false) => {
|
|
580
|
+
const a = { ...answers(), ...(createIndex ? { createIndex: true } : {}) };
|
|
558
581
|
$('pane-form').hidden = true; $('running').hidden = false;
|
|
559
582
|
$('running-title').textContent = 'Setting up…'; $('run-error').textContent = ''; $('run-actions').hidden = true;
|
|
560
583
|
const steps = $('steps'); steps.textContent = ''; const mark = {};
|
|
@@ -566,10 +589,27 @@ if (typeof document !== 'undefined') (async () => {
|
|
|
566
589
|
location.href = '/admin/client/';
|
|
567
590
|
}
|
|
568
591
|
catch (err) {
|
|
592
|
+
// No type index: nothing has been written. Ask, on the form.
|
|
593
|
+
if (err?.code === 'needs-index') {
|
|
594
|
+
backToForm();
|
|
595
|
+
$('index-ask').hidden = false;
|
|
596
|
+
$('index-yes').focus();
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
569
599
|
$('running-title').textContent = 'Setup did not finish';
|
|
570
600
|
$('run-error').textContent = err.message || String(err);
|
|
571
601
|
$('run-actions').hidden = false; // the way out of the failure screen
|
|
572
602
|
}
|
|
603
|
+
};
|
|
604
|
+
$('form').addEventListener('submit', async (e) => {
|
|
605
|
+
e.preventDefault(); $('form-error').textContent = '';
|
|
606
|
+
const e2 = validateStep2(); if (e2) { $('form-error').textContent = e2; goStep(2); return; }
|
|
607
|
+
await run();
|
|
608
|
+
});
|
|
609
|
+
$('index-yes').addEventListener('click', () => { $('index-ask').hidden = true; run(true); });
|
|
610
|
+
$('index-no').addEventListener('click', () => {
|
|
611
|
+
$('index-ask').hidden = true;
|
|
612
|
+
$('form-error').textContent = 'Sign-up stopped. Nothing was written to your pod.';
|
|
573
613
|
});
|
|
574
614
|
|
|
575
615
|
if (pendingIdentity) await showIdentity();
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
import { Deliverer } from '../../lib/core/deliver.mjs';
|
|
11
11
|
import { sign } from './shims/fedify-sig.mjs';
|
|
12
12
|
|
|
13
|
+
// What one relay call may carry (lib/gateway/front-core.mjs RELAY_MAX_REQUESTS).
|
|
14
|
+
const RELAY_MAX_REQUESTS = 20;
|
|
15
|
+
|
|
13
16
|
/**
|
|
14
17
|
* The name the front keys this account's row by, read off its door inbox:
|
|
15
18
|
* `<front>/u/<key>/ap/inbox/`. A mail-door account is keyed by its full
|
|
@@ -30,6 +33,7 @@ export class RelayDeliverer extends Deliverer {
|
|
|
30
33
|
this.relayUrl = opts.relayUrl; // <front>/api/relay
|
|
31
34
|
this.handle = opts.handle;
|
|
32
35
|
this.sessionFetch = opts.sessionFetch; // the DPoP session's fetch, to authenticate to the relay
|
|
36
|
+
this.batchSize = RELAY_MAX_REQUESTS;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
// Same contract as Deliverer.signedFetch, DEFAULT INCLUDED: an init with no
|
|
@@ -40,32 +44,68 @@ export class RelayDeliverer extends Deliverer {
|
|
|
40
44
|
// never saw the document it asked for: a Follow from anyone new was rejected
|
|
41
45
|
// with "actor fetch failed", and nothing needing a lookup could be ingested.
|
|
42
46
|
async signedFetch(url, init = {}) {
|
|
47
|
+
const req = await this._signedRequest(url, init);
|
|
48
|
+
const [r0] = await this._relay([req]);
|
|
49
|
+
return this._outcome(r0, url, init.method || 'GET');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A fan-out in one call: the relay takes a list, so a post to twenty
|
|
53
|
+
// followers is one call, not twenty (Deliverer.deliverToAll, batchSize).
|
|
54
|
+
async deliverManyNow(targets) {
|
|
55
|
+
const reqs = await Promise.all(targets.map((t) => this._signedRequest(t.inbox, {
|
|
56
|
+
method: 'POST', headers: { 'content-type': 'application/activity+json' }, body: JSON.stringify(t.activity),
|
|
57
|
+
})));
|
|
58
|
+
let results;
|
|
59
|
+
try { results = await this._relay(reqs); } catch (error) { return targets.map(() => ({ error })); }
|
|
60
|
+
return targets.map((t, i) => {
|
|
61
|
+
try { this._outcome(results[i] || {}, t.inbox, 'POST'); return { ok: true }; }
|
|
62
|
+
catch (error) { return { error }; }
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Signed here, sent verbatim by the relay. Every signed header goes along,
|
|
67
|
+
// `accept` included: the signature covers it, so a relay request missing it
|
|
68
|
+
// carries an invalid signature — and a read without it gets the HTML page
|
|
69
|
+
// instead of the document.
|
|
70
|
+
async _signedRequest(url, init = {}) {
|
|
43
71
|
const body = typeof init.body === 'string' ? init.body : (init.body ? new TextDecoder().decode(init.body) : '');
|
|
44
72
|
const s = await sign({ url, method: init.method || 'GET', headers: init.headers || {}, body }, this.rsaPrivate, this.keyId);
|
|
45
|
-
|
|
46
|
-
// covers it, so a relay request missing it carries an invalid signature —
|
|
47
|
-
// and a read without it gets the HTML page instead of the document.
|
|
48
|
-
const relayReq = {
|
|
73
|
+
return {
|
|
49
74
|
url: s.url, method: s.method, body,
|
|
50
75
|
headers: {
|
|
51
76
|
date: s.headers.date, digest: s.headers.digest, accept: s.headers.accept,
|
|
52
77
|
'content-type': s.headers['content-type'], signature: s.headers.signature,
|
|
53
78
|
},
|
|
54
79
|
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// One relay call for a list of requests; the results in the same order.
|
|
83
|
+
// The relay's OWN answer, apart from the recipients': unreachable and a
|
|
84
|
+
// refusal are hiccups the queue retries. Its 404 is not — it says this
|
|
85
|
+
// account has no row here, and no retry changes that. It used to be read as
|
|
86
|
+
// a hiccup too, and a tab whose account the site did not know retried its
|
|
87
|
+
// deliveries every minute for three days.
|
|
88
|
+
async _relay(requests) {
|
|
55
89
|
let res;
|
|
56
90
|
try {
|
|
57
91
|
res = await this.sessionFetch(this.relayUrl, {
|
|
58
92
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
59
|
-
body: JSON.stringify({ handle: this.handle, requests
|
|
93
|
+
body: JSON.stringify({ handle: this.handle, requests }),
|
|
60
94
|
});
|
|
61
95
|
} catch (e) { const err = new Error(`relay unreachable: ${e.message}`); err.status = 0; throw err; }
|
|
96
|
+
if (res.status === 404) { const err = new Error('relay: no such account here'); err.status = 404; throw err; }
|
|
62
97
|
if (res.status >= 400) { const err = new Error(`relay ${res.status}`); err.status = 502; throw err; }
|
|
63
98
|
const out = await res.json().catch(() => ({}));
|
|
64
|
-
|
|
99
|
+
return Array.isArray(out.results) ? out.results : [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// What the far server answered, as the Node deliverer would have seen it:
|
|
103
|
+
// a Response for a read, a thrown error carrying the status for a refusal.
|
|
104
|
+
_outcome(r0, url, method) {
|
|
65
105
|
const status = r0.status || 0;
|
|
66
106
|
if (status === 0) { const err = new Error(r0.error || 'relay could not send'); err.status = 502; throw err; }
|
|
67
107
|
if (status >= 400) {
|
|
68
|
-
const err = new Error(`${
|
|
108
|
+
const err = new Error(`${method} ${url} → ${status}`); err.status = status;
|
|
69
109
|
// The receiving server's own answer to "when should I try again", carried
|
|
70
110
|
// through the relay (lib/front-core.mjs). Spelled `retryAfterMs`, which is
|
|
71
111
|
// what the delivery queue reads (lib/deliver.mjs) — the queue's ladder is
|