fedipod 1.50.2 → 1.55.2
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 +2 -1
- package/gateway.md +25 -0
- package/gui.md +11 -3
- package/lib/client/masto/timelines.mjs +5 -0
- package/lib/connections/acctfeed.mjs +4 -1
- package/lib/connections/tagfeed.mjs +4 -1
- package/lib/core/intake/index.mjs +35 -2
- package/lib/core/intake/verify.mjs +3 -1
- package/lib/core/pod-only.mjs +4 -2
- package/lib/core/publisher/index.mjs +19 -5
- package/lib/gateway/account-agent.mjs +11 -5
- package/lib/gateway/check-letter.mjs +123 -0
- package/lib/gateway/connected-feeds.mjs +116 -0
- package/lib/gateway/copy.mjs +19 -0
- package/lib/gateway/front-core.mjs +5 -3
- package/lib/gateway/held-mail.mjs +25 -1
- package/lib/gateway/keeper.mjs +2 -1
- package/lib/gateway/masto-gateway.mjs +47 -7
- package/lib/surface/routes/owner.mjs +7 -4
- package/package.json +3 -3
- package/scripts/stage-site.mjs +2 -3
- package/vendor/sanitize-html.cjs +358 -171
- package/web/admin/bar.js +45 -0
- package/web/admin/group.js +17 -0
- package/web/admin/index.html +12 -8
- package/web/admin/record.js +19 -41
- package/web/admin/upkeep.js +3 -0
- package/web/app/README.md +3 -3
- package/web/app/admin-facade.mjs +19 -16
- package/web/app/agent.mjs +65 -2
- package/web/app/atproto-browser.mjs +28 -22
- package/web/app/deliver-relay.mjs +2 -1
- package/web/app/dist/boot.js +1 -0
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +567 -265
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/fediacct-browser.mjs +32 -30
- package/web/app/idb-kv.mjs +4 -4
- package/web/app/shims/prelude.js +1 -0
- package/web/app/sw-src.mjs +44 -3
package/README.md
CHANGED
|
@@ -52,7 +52,8 @@ Akkoma, Pixelfed or Friendica, connect it from the manage page and read it
|
|
|
52
52
|
here. Its home timeline and notifications join the feed you already read,
|
|
53
53
|
interleaved by time, and a post both accounts see appears once. Favouriting,
|
|
54
54
|
boosting and replying act as the account the post came through. The token it
|
|
55
|
-
hands back
|
|
55
|
+
hands back is kept on your pod, so the account follows you to any browser you
|
|
56
|
+
sign in from.
|
|
56
57
|
|
|
57
58
|
**Moving in.** Mastodon-format exports of follows, blocks, mutes, lists and
|
|
58
59
|
domain blocks import from the manage page. Your old account can be listed as
|
package/gateway.md
CHANGED
|
@@ -240,6 +240,19 @@ An app checks for new posts every minute or so while it is open; there are no
|
|
|
240
240
|
live updates. Accounts are made on the fedipod.net front page, with a pod, not
|
|
241
241
|
from an app.
|
|
242
242
|
|
|
243
|
+
**Your connected accounts, with FediPod closed.** FediPod open in a browser
|
|
244
|
+
reads the Mastodon and Bluesky accounts you connected every five minutes.
|
|
245
|
+
While it is closed and an app is in use, fedipod.net reads them instead, at
|
|
246
|
+
most every five minutes, when the app checks your timeline or notifications.
|
|
247
|
+
Their logins are read from your pod for each read and are not kept at
|
|
248
|
+
fedipod.net. An account you have just connected can take up to an hour to be
|
|
249
|
+
read here.
|
|
250
|
+
|
|
251
|
+
**Hashtags.** The hashtags you follow are one list, in FediPod and in an app:
|
|
252
|
+
following or unfollowing one in either changes it in both. FediPod brings in
|
|
253
|
+
the posts for them, while it is open. **hashtag posts in home** on your manage
|
|
254
|
+
page keeps them out of your home timeline, in FediPod and in an app alike.
|
|
255
|
+
|
|
243
256
|
**Notifications on your phone.** An app that offers notifications can sign up
|
|
244
257
|
for them here. A mention, a reply, a direct message, a follow, or a like, boost
|
|
245
258
|
or reaction to your own post then reaches your phone as it arrives, whether
|
|
@@ -281,6 +294,18 @@ removes them at `/notices`, signed in as the admin the way the roster is.
|
|
|
281
294
|
A notice is a title and plain text: a blank line starts a paragraph, and a
|
|
282
295
|
web address becomes a link.
|
|
283
296
|
|
|
297
|
+
## When mail does not get through
|
|
298
|
+
|
|
299
|
+
When FediPod cannot read a piece of your mail, a red line across the top of
|
|
300
|
+
its pages says so until you open Show dead letters on your manage page. Your
|
|
301
|
+
phone hears too, at most once an hour, if a Mastodon app on it is signed in at
|
|
302
|
+
the gateway with notifications on.
|
|
303
|
+
|
|
304
|
+
The gateway's admin also gets a test letter every six hours, sent to their own
|
|
305
|
+
account through the same door every server uses. FediPod takes it in and shows
|
|
306
|
+
it nowhere. If FediPod has been open for an hour and the letter has not
|
|
307
|
+
arrived, the admin's phone is told and the red line goes up.
|
|
308
|
+
|
|
284
309
|
## What the gateway can see
|
|
285
310
|
|
|
286
311
|
It reads only public data to decide what concerns you: your published
|
package/gui.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
In the browser version at fedipod.net, `manage`, in the bar's account group, 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
|
-
drain
|
|
6
|
+
drain, 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` in the bar's account group and select the actor you want from the local actors dropdown.
|
|
9
9
|
Picking an actor marked "(stopped)" starts its agent, then opens its page.
|
|
@@ -48,8 +48,7 @@ its own port.
|
|
|
48
48
|
|
|
49
49
|
Each identity has a page anyone can open, at `ap/profile.html` under its pod
|
|
50
50
|
— for example `https://your-pod.example/fedipod/ap/profile.html`
|
|
51
|
-
(the `fedipod` container is wherever you put it at setup
|
|
52
|
-
"stored at" row says where). It
|
|
51
|
+
(the `fedipod` container is wherever you put it at setup). It
|
|
53
52
|
shows the name, bio and address, and offers a Follow box: a visitor types
|
|
54
53
|
their own server and lands on that server's follow screen. Hand out that
|
|
55
54
|
link, or the `@name@host` address itself, which works in the search box of
|
|
@@ -96,6 +95,15 @@ Disconnecting stops the reading and removes the stored token; nothing already
|
|
|
96
95
|
posted is touched. If the other server stops accepting the token, the account
|
|
97
96
|
is marked **sign in again** rather than quietly dropped.
|
|
98
97
|
|
|
98
|
+
## Hashtag posts in your home timeline
|
|
99
|
+
|
|
100
|
+
The posts brought in for the hashtags you follow appear in your home timeline.
|
|
101
|
+
Set **hashtag posts in home** to *hidden* and they appear only on the public
|
|
102
|
+
timeline and on each hashtag's page; they are still brought in. A hashtag post
|
|
103
|
+
that one of your connected accounts also brought in is from someone you follow
|
|
104
|
+
there, and stays. An app already showing them keeps them on screen until it
|
|
105
|
+
reloads.
|
|
106
|
+
|
|
99
107
|
## Handling follow requests
|
|
100
108
|
|
|
101
109
|
New followers are accepted automatically. Set **new followers** to *wait for
|
|
@@ -18,6 +18,10 @@ export async function handle(api, ctx) {
|
|
|
18
18
|
if (pathname === '/api/v1/timelines/home' || pathname === '/api/v1/timelines/public'
|
|
19
19
|
|| pathname === '/api/v1/trends/statuses') {
|
|
20
20
|
const localOnly = pathname === '/api/v1/timelines/public' && url.searchParams.get('local') === 'true';
|
|
21
|
+
// The owner can keep hashtag posts out of Home (`tagsInHome: false`); they
|
|
22
|
+
// stay on the public timeline, their hashtags' pages and trends. One a
|
|
23
|
+
// connected account also brought in is from someone followed there.
|
|
24
|
+
const hideTags = pathname === '/api/v1/timelines/home' && api.store.getConfig?.()?.tagsInHome === false;
|
|
21
25
|
// The UNFILTERED list, both as the source and as the reply-count corpus:
|
|
22
26
|
// a reply can be a mention, and mentions are filtered out of timelines
|
|
23
27
|
// below — counting against the filtered set would undercount them.
|
|
@@ -31,6 +35,7 @@ export async function handle(api, ctx) {
|
|
|
31
35
|
.filter(s => s.kind !== 'remote' && s.kind !== 'mention')
|
|
32
36
|
.filter(s => !s.direct && s.visibility !== 'direct')
|
|
33
37
|
.filter(s => !muted.has(s.actor) && !muted.has(s.via))
|
|
38
|
+
.filter(s => !hideTags || s.kind !== 'tag' || s.sourceAccts?.length)
|
|
34
39
|
.filter(s => !localOnly || (s.kind === 'post' && s.visibility !== 'private'))
|
|
35
40
|
.sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
|
|
36
41
|
const { items: page, headers } = api.page(items, url);
|
|
@@ -145,8 +145,11 @@ export class AcctFeed {
|
|
|
145
145
|
return map;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
// A server that does not say what is left (not every Mastodon-API server
|
|
149
|
+
// does) is not taken to have nothing left.
|
|
148
150
|
_rateGuard(id, handle, res) {
|
|
149
|
-
const
|
|
151
|
+
const said = res.headers.get('x-ratelimit-remaining');
|
|
152
|
+
const left = said == null || said.trim() === '' ? NaN : Number(said);
|
|
150
153
|
if (!Number.isFinite(left) || left > RATE_FLOOR) return false;
|
|
151
154
|
const reset = Date.parse(res.headers.get('x-ratelimit-reset') || '');
|
|
152
155
|
const wait = Number.isFinite(reset) ? Math.max(0, reset - Date.now()) : BACKOFF_MIN_MS;
|
|
@@ -81,8 +81,11 @@ export class TagFeed {
|
|
|
81
81
|
if (Array.isArray(patch.tags)) clean.tags = patch.tags.map(t => String(t).replace(/^#/, '').trim()).filter(Boolean);
|
|
82
82
|
if (patch.intervalMin) clean.intervalMin = Math.max(5, Number(patch.intervalMin) || DEFAULTS.intervalMin);
|
|
83
83
|
this.store.write('tagfeed.json', { ...this.config(), ...clean });
|
|
84
|
+
// A feed that sweeps starts again on the new list; one that only answers
|
|
85
|
+
// for the list (fedipod.net, for an app) was never started and is not now.
|
|
86
|
+
const sweeping = this.stopped === false;
|
|
84
87
|
this.stop();
|
|
85
|
-
this.start();
|
|
88
|
+
if (sweeping) this.start();
|
|
86
89
|
return this.config();
|
|
87
90
|
}
|
|
88
91
|
|
|
@@ -151,9 +151,17 @@ export class Intake {
|
|
|
151
151
|
rec.last = String(message || '').slice(0, 200);
|
|
152
152
|
all[url] = rec;
|
|
153
153
|
this.store.write(ATTEMPTS_DOC, all);
|
|
154
|
+
if (rec.n === 2) this._trouble('failing', url, message);
|
|
154
155
|
return rec.n;
|
|
155
156
|
}
|
|
156
157
|
|
|
158
|
+
// Mail that could not be read, as against mail refused: a second failed try,
|
|
159
|
+
// or a piece set aside after its last. `onTrouble` tells the owner.
|
|
160
|
+
_trouble(kind, url, reason) {
|
|
161
|
+
try { this.onTrouble?.({ kind, url, reason: String(reason || '').slice(0, 200), at: new Date().toISOString() }); }
|
|
162
|
+
catch { /* telling must never stop the drain */ }
|
|
163
|
+
}
|
|
164
|
+
|
|
157
165
|
_clearAttempt(url) {
|
|
158
166
|
const all = this.store.read(ATTEMPTS_DOC, {});
|
|
159
167
|
if (!all[url]) return;
|
|
@@ -202,13 +210,25 @@ export class Intake {
|
|
|
202
210
|
this._drainAgain = false;
|
|
203
211
|
// Paced, not immediate: chained sweeps put a ceiling on work per unit
|
|
204
212
|
// time, so a delivery flood cannot run the drain back-to-back.
|
|
205
|
-
|
|
206
|
-
|
|
213
|
+
this._chainTimer = setTimeout(() => {
|
|
214
|
+
this._chainTimer = null;
|
|
215
|
+
this.drain().catch(e => this.log(`drain: ${e.message}`));
|
|
216
|
+
}, CHAIN_GAP_MS);
|
|
217
|
+
this._chainTimer.unref?.();
|
|
207
218
|
}
|
|
208
219
|
});
|
|
209
220
|
return this._draining;
|
|
210
221
|
}
|
|
211
222
|
|
|
223
|
+
// Resolves once no sweep is running and none is waiting to follow on, so a
|
|
224
|
+
// browser worker can stay up until the round it started is done.
|
|
225
|
+
async settled() {
|
|
226
|
+
while (this._draining || this._chainTimer) {
|
|
227
|
+
if (this._draining) await this._draining.catch(() => {});
|
|
228
|
+
else await new Promise((r) => setTimeout(r, 500));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
212
232
|
// Discard the content waiting in the inbox from before `before`, on the
|
|
213
233
|
// owner's say-so — the admin page asks, this does it. NOT a blind sweep:
|
|
214
234
|
// every item is read, because the type is the only thing that decides its
|
|
@@ -447,6 +467,7 @@ export class Intake {
|
|
|
447
467
|
this.log(`inbox batch ${url} attempt ${n}/${MAX_ITEM_ATTEMPTS}: ${e.message}`);
|
|
448
468
|
if (n >= MAX_ITEM_ATTEMPTS) {
|
|
449
469
|
this.store.addDeadLetter({ inboxUrl: url, reason: `failed ${n}x: ${e.message}`, activity: null });
|
|
470
|
+
this._trouble('set-aside', url, e.message);
|
|
450
471
|
pending.push(url);
|
|
451
472
|
}
|
|
452
473
|
}
|
|
@@ -489,6 +510,7 @@ export class Intake {
|
|
|
489
510
|
this.log(`inbox item ${url} attempt ${n}/${MAX_ITEM_ATTEMPTS}: ${e.message}`);
|
|
490
511
|
if (n >= MAX_ITEM_ATTEMPTS) {
|
|
491
512
|
this.store.addDeadLetter({ inboxUrl: url, reason: `failed ${n}x: ${e.message}`, activity: trimActivity(at.activity) });
|
|
513
|
+
this._trouble('set-aside', url, e.message);
|
|
492
514
|
// The dead letter IS the record of this item — deleting before it is
|
|
493
515
|
// written down would lose the only evidence it ever arrived, so it
|
|
494
516
|
// goes through the same commit-then-delete batch as everything else.
|
|
@@ -522,6 +544,14 @@ export class Intake {
|
|
|
522
544
|
const read = raw ? await readLenient(raw) : null;
|
|
523
545
|
if (read?.degraded) this.log(`inbox item ${url} grounded to read: ${read.degraded}`);
|
|
524
546
|
const activity = at.activity = read?.view ?? read?.doc ?? null;
|
|
547
|
+
// The gateway's test letter (lib/gateway/check-letter.mjs): read this far
|
|
548
|
+
// like any mail, then answered, and shown nowhere. An answer that does
|
|
549
|
+
// not go leaves it to be tried again.
|
|
550
|
+
const from = typeof activity?.actor === 'string' ? activity.actor : activity?.actor?.id;
|
|
551
|
+
if (from && this.checkLetters?.actor && from === this.checkLetters.actor) {
|
|
552
|
+
await this.checkLetters.taken(String(activity.id || ''));
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
525
555
|
// What the shapes make of it. This decides NOTHING — the activity is
|
|
526
556
|
// handled either way. It is written down so that which shapes fire on
|
|
527
557
|
// real traffic is a question with an answer, rather than a guess made
|
|
@@ -577,6 +607,7 @@ export class Intake {
|
|
|
577
607
|
const batch = await this._batchEntries(url);
|
|
578
608
|
if (!batch) {
|
|
579
609
|
this.store.addDeadLetter({ inboxUrl: url, reason: 'unreadable batch', activity: null });
|
|
610
|
+
this._trouble('set-aside', url, 'unreadable batch');
|
|
580
611
|
return { entries: 1, complete: true };
|
|
581
612
|
}
|
|
582
613
|
let complete = true;
|
|
@@ -596,6 +627,7 @@ export class Intake {
|
|
|
596
627
|
this.log(`inbox batch entry ${key} attempt ${n}/${MAX_ITEM_ATTEMPTS}: ${err.message}`);
|
|
597
628
|
if (n >= MAX_ITEM_ATTEMPTS) {
|
|
598
629
|
this.store.addDeadLetter({ inboxUrl: key, reason: `failed ${n}x: ${err.message}`, activity: trimActivity(at.activity) });
|
|
630
|
+
this._trouble('set-aside', key, err.message);
|
|
599
631
|
} else {
|
|
600
632
|
complete = false;
|
|
601
633
|
}
|
|
@@ -635,6 +667,7 @@ export class Intake {
|
|
|
635
667
|
this.log(`held delivery ${key} attempt ${n}/${MAX_ITEM_ATTEMPTS}: ${err.message}`);
|
|
636
668
|
if (n >= MAX_ITEM_ATTEMPTS) {
|
|
637
669
|
this.store.addDeadLetter({ inboxUrl: key, reason: `failed ${n}x: ${err.message}`, activity: trimActivity(at.activity) });
|
|
670
|
+
this._trouble('set-aside', key, err.message);
|
|
638
671
|
done.push(e.name);
|
|
639
672
|
}
|
|
640
673
|
}
|
|
@@ -138,8 +138,10 @@ export function receiptVouchesFor(intake, receipt, actor) {
|
|
|
138
138
|
// Delete is verified — the same verify-by-dereference the rest of intake uses.
|
|
139
139
|
export async function isGone(intake, url) {
|
|
140
140
|
let res;
|
|
141
|
+
// A deliverer that throws on a refusal (the browser's relay) still carries
|
|
142
|
+
// the origin's answer: 404 or 410 from the origin itself is gone.
|
|
141
143
|
try { res = await intake.deliverer.signedFetch(url, { headers: { accept: ACCEPT_AP } }); }
|
|
142
|
-
catch { return null; }
|
|
144
|
+
catch (e) { return e?.answered && (e.status === 404 || e.status === 410) ? true : null; }
|
|
143
145
|
if (res.status === 404 || res.status === 410) return true;
|
|
144
146
|
if (res.status < 400) {
|
|
145
147
|
// A Tombstone answers 200 and still means deleted.
|
package/lib/core/pod-only.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// pod-only.mjs — what stays on the pod, and only there, when an account works
|
|
2
2
|
// from a copy kept at its gateway (lib/gateway/copy.mjs): the signing key, the
|
|
3
|
-
// pod's own lease, and the passwords and tokens of accounts on other servers
|
|
4
|
-
|
|
3
|
+
// pod's own lease, and the passwords and tokens of accounts on other servers
|
|
4
|
+
// (`conn-*` as the vault names them, and the browser's two documents).
|
|
5
|
+
const CONNECTED = new Set(['fediaccts.json', 'atproto.json']);
|
|
6
|
+
export const podOnly = (name) => name === 'keys.json' || name === 'lease.json' || name.startsWith('conn-') || CONNECTED.has(name);
|
|
@@ -99,13 +99,26 @@ export class Publisher {
|
|
|
99
99
|
// `clientOrigin` is overridden only by the agent serving a client its own
|
|
100
100
|
// local actor: standalone, the published document cannot name a loopback
|
|
101
101
|
// address, but a client already talking to that address can be told it.
|
|
102
|
+
// Whether follows wait for the owner, from the setting as saved now: this
|
|
103
|
+
// config is a copy taken when the agent started, and the saved one may have
|
|
104
|
+
// changed since.
|
|
105
|
+
approvesByHand() {
|
|
106
|
+
const saved = this.store?.getConfig?.() || {};
|
|
107
|
+
return wire.followsNeedApproval({ ...this.config,
|
|
108
|
+
...('autoAcceptFollows' in saved ? { autoAcceptFollows: saved.autoAcceptFollows } : {}),
|
|
109
|
+
...('approveJoins' in saved ? { approveJoins: saved.approveJoins } : {}) });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Whether the profile last published says what the setting says.
|
|
113
|
+
profileAgrees() { return this.store.read('published.json', {}).approvesByHand === this.approvesByHand(); }
|
|
114
|
+
|
|
102
115
|
actorDocFor({ priv = false, moderators = null, inbox = null, movedTo = this.config.movedTo || null,
|
|
103
116
|
clientOrigin = this.clientOrigin } = {}) {
|
|
104
117
|
const { urls } = this;
|
|
105
118
|
return wire.actorDoc({
|
|
106
119
|
urls, handle: wire.publicHandle(this.config), name: this.config.name, publicKeyPem: this.publicKeyPem,
|
|
107
120
|
movedTo, kind: this.config.kind,
|
|
108
|
-
approveJoins:
|
|
121
|
+
approveJoins: this.approvesByHand(),
|
|
109
122
|
assertionKey: this.assertionKey,
|
|
110
123
|
summary: this.config.summary || null, icon: this.config.icon || null,
|
|
111
124
|
image: this.config.image || null, fields: this.config.fields || [],
|
|
@@ -283,10 +296,11 @@ export class Publisher {
|
|
|
283
296
|
// one the operator makes because the first did not work — was skipped as a
|
|
284
297
|
// no-op and reported success. The digest is a record of what is up there,
|
|
285
298
|
// and an unreachable document is not up there.
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
299
|
+
// What the actor now says about followers is recorded whatever else was
|
|
300
|
+
// unreachable: it was written, and a start that finds it disagreeing
|
|
301
|
+
// writes it again (profileAgrees).
|
|
302
|
+
this.store.write('published.json', { ...this.store.read('published.json', {}),
|
|
303
|
+
...(!unreachable.length ? { surfaceDigest: surface } : {}), approvesByHand: !!actorDoc.manuallyApprovesFollowers });
|
|
290
304
|
// Named as the fediverse sees it: a fronted actor's name and host are the
|
|
291
305
|
// front's, not the pod's.
|
|
292
306
|
const pubName = wire.publicHandle(this.config);
|
|
@@ -18,6 +18,7 @@ import { Deliverer } from '../core/deliver.mjs';
|
|
|
18
18
|
import { Publisher } from '../core/publisher/index.mjs';
|
|
19
19
|
import { Intake } from '../core/intake/index.mjs';
|
|
20
20
|
import { C2S } from '../client/c2s.mjs';
|
|
21
|
+
import { TagFeed } from '../connections/tagfeed.mjs';
|
|
21
22
|
import { keeperSession } from './keeper-session.mjs';
|
|
22
23
|
import { CopyStorage, copyMeta, copyLease, keptBefore, holdOn, GATEWAY_HOLDER } from './copy.mjs';
|
|
23
24
|
import { makeCopy } from './state-api.mjs';
|
|
@@ -76,14 +77,15 @@ export async function settle(ctx, handle, rec, at, { log = console.log } = {}) {
|
|
|
76
77
|
|
|
77
78
|
/**
|
|
78
79
|
* The account's state store and lease, as `holder`: over the copy when there
|
|
79
|
-
* is one, else over the pod.
|
|
80
|
+
* is one, else over the pod. `podNames`: what of the pod-only documents the
|
|
81
|
+
* store loads with the copy.
|
|
80
82
|
*/
|
|
81
|
-
export function stateAndLease(ctx, handle, at, { log = console.log, holder = GATEWAY_HOLDER } = {}) {
|
|
83
|
+
export function stateAndLease(ctx, handle, at, { log = console.log, holder = GATEWAY_HOLDER, podNames = ['keys.json'] } = {}) {
|
|
82
84
|
const podState = new HttpStorage(at.urls.state, at.podFetch);
|
|
83
85
|
if (at.inCopy) {
|
|
84
86
|
return {
|
|
85
|
-
// Of what stays on the pod, only the key: one read per piece of work.
|
|
86
|
-
store: new PodStore({ storage: new CopyStorage(ctx.copyKv, handle, { holder, pod: podState, podNames
|
|
87
|
+
// Of what stays on the pod, only the key, by default: one read per piece of work.
|
|
88
|
+
store: new PodStore({ storage: new CopyStorage(ctx.copyKv, handle, { holder, pod: podState, podNames }), log }),
|
|
87
89
|
lease: copyLease(ctx.copyKv, handle, { id: holder, log }),
|
|
88
90
|
};
|
|
89
91
|
}
|
|
@@ -99,7 +101,7 @@ export function stateAndLease(ctx, handle, at, { log = console.log, holder = GAT
|
|
|
99
101
|
* back onto the pod), the key from the pod, a deliverer that sends only when
|
|
100
102
|
* asked, a publisher, and the intake. Returns the agent, or { skipped }.
|
|
101
103
|
*/
|
|
102
|
-
export async function actingAgent(at, store, lease, { log = console.log, push = false } = {}) {
|
|
104
|
+
export async function actingAgent(at, store, lease, { log = console.log, push = false, checkLetters = null } = {}) {
|
|
103
105
|
const config = store.getConfig();
|
|
104
106
|
if (!config) return { skipped: 'no account on the pod' };
|
|
105
107
|
const publicBase = config.gateway?.frontActor ? config.gateway.frontActor.replace(/ap\/actor\/?$/u, '') : null;
|
|
@@ -115,6 +117,8 @@ export async function actingAgent(at, store, lease, { log = console.log, push =
|
|
|
115
117
|
// What the owner's own posts, taken at the outbox door, need of an agent.
|
|
116
118
|
const agent = {
|
|
117
119
|
store, publisher, deliverer, remote: at.remote, urls, config, lease, intake: null, viewer: false,
|
|
120
|
+
// The hashtags followed, changed from an app; FediPod in the browser reads them.
|
|
121
|
+
tagfeed: new TagFeed({ store, intake: null, log }),
|
|
118
122
|
configured: () => true, requestTakeover: async () => true,
|
|
119
123
|
};
|
|
120
124
|
const c2s = new C2S({ agent, log });
|
|
@@ -122,6 +126,8 @@ export async function actingAgent(at, store, lease, { log = console.log, push =
|
|
|
122
126
|
config, urls, remote: at.remote, store, deliverer, publisher, log, lease, push,
|
|
123
127
|
ownerPost: (a, o) => c2s.dispatch(a, o),
|
|
124
128
|
});
|
|
129
|
+
// The gateway's test letter, taken in here and answered (check-letter.mjs).
|
|
130
|
+
agent.intake.checkLetters = checkLetters;
|
|
125
131
|
publisher.resolveActor = (u) => agent.intake.fetchAP(u);
|
|
126
132
|
return agent;
|
|
127
133
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// check-letter.mjs — the gateway's test letter. Every six hours it writes to
|
|
2
|
+
// its admin's own account through the account's door, as any server would,
|
|
3
|
+
// and FediPod answers once it has taken the letter in (POST /api/check), or
|
|
4
|
+
// the gateway's own reader does when it reads held mail (account-agent.mjs).
|
|
5
|
+
// A letter still unanswered after FediPod has been open an hour means mail is
|
|
6
|
+
// not getting through, and the owner's phones are told. The letter itself is
|
|
7
|
+
// shown nowhere (lib/core/intake: checkLetters).
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import { PRESENT_MS } from './held-mail.mjs';
|
|
10
|
+
|
|
11
|
+
export const LETTER_EVERY_MS = 6 * 3600_000;
|
|
12
|
+
export const ANSWER_WITHIN_MS = 3600_000;
|
|
13
|
+
const LETTER_ID = /^https?:\/\/[^/]+\/check\/letters\/[0-9a-f]{24}$/u;
|
|
14
|
+
|
|
15
|
+
const base = (origin) => String(origin || '').replace(/\/$/u, '');
|
|
16
|
+
export const checkActor = (origin) => `${base(origin)}/check/actor`;
|
|
17
|
+
const stateKey = (handle) => `check/${handle}`;
|
|
18
|
+
const read = async (kv, handle) => { try { return JSON.parse((await kv.get(stateKey(handle)))?.text || 'null') || {}; } catch { return {}; } };
|
|
19
|
+
const save = (kv, handle, st) => kv.set(stateKey(handle), JSON.stringify(st));
|
|
20
|
+
|
|
21
|
+
// Who gets letters: the admin's own browser accounts, still at this gateway.
|
|
22
|
+
const checked = (ctx, rec) => !!(ctx.adminWebId && rec?.openedAt && rec.actorUrl && rec.webId === ctx.adminWebId && !rec.movedTo);
|
|
23
|
+
|
|
24
|
+
/** The letter, as the door takes it: a direct note to the account. */
|
|
25
|
+
export function letterFor(origin, letter, to) {
|
|
26
|
+
const actor = checkActor(origin);
|
|
27
|
+
return {
|
|
28
|
+
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
29
|
+
id: letter, type: 'Create', actor, to: [to],
|
|
30
|
+
object: { id: `${letter}#note`, type: 'Note', attributedTo: actor, to: [to], content: '<p>A test letter from this gateway.</p>' },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The round's part: for each account checked, a letter unanswered after an
|
|
36
|
+
* hour of FediPod open is alerted once, and a new one goes when none is
|
|
37
|
+
* waiting and six hours have passed since the last. `send(handle, rec, doc)`
|
|
38
|
+
* delivers a letter; `alert(handle, text)` tells the owner's phones.
|
|
39
|
+
*/
|
|
40
|
+
export async function roundChecks(ctx, { kv = ctx.mastoKv, now = Date.now(), send, alert, log = console.log } = {}) {
|
|
41
|
+
if (!kv || !ctx.adminWebId || !ctx.listDirectory || !ctx.frontOrigin) return;
|
|
42
|
+
const rows = await ctx.listDirectory();
|
|
43
|
+
for (const [handle, rec] of Object.entries(rows || {})) {
|
|
44
|
+
if (!checked(ctx, rec)) continue;
|
|
45
|
+
const st = await read(kv, handle);
|
|
46
|
+
const presentAt = Number(await ctx.presentAt?.(handle).catch(() => 0)) || 0;
|
|
47
|
+
const open = !!presentAt && now - presentAt < PRESENT_MS;
|
|
48
|
+
const waiting = st.letter && !st.answeredAt && !st.alertedAt;
|
|
49
|
+
if (waiting) {
|
|
50
|
+
// Open since this round, or since an earlier one; closed starts it again.
|
|
51
|
+
st.openSince = open ? (st.openSince ?? now) : null;
|
|
52
|
+
if (open && now - st.openSince >= ANSWER_WITHIN_MS) {
|
|
53
|
+
st.alertedAt = now;
|
|
54
|
+
await alert(handle, 'Its test letter has not reached your FediPod after an hour with FediPod open. Mail may not be getting through.')
|
|
55
|
+
.catch((e) => log(`check @${handle}: alert not sent: ${e?.message || e}`));
|
|
56
|
+
log(`check @${handle}: letter not answered after an hour open — owner told`);
|
|
57
|
+
}
|
|
58
|
+
await save(kv, handle, st);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (typeof st.sentAt === 'number' && now - st.sentAt < LETTER_EVERY_MS) continue;
|
|
62
|
+
const letter = `${base(ctx.frontOrigin)}/check/letters/${crypto.randomBytes(12).toString('hex')}`;
|
|
63
|
+
const status = await send(handle, rec, letterFor(ctx.frontOrigin, letter, rec.actorUrl)).catch((e) => { log(`check @${handle}: letter not sent: ${e?.message || e}`); return 0; });
|
|
64
|
+
if (status !== 202) { log(`check @${handle}: the door answered ${status} to the letter`); continue; }
|
|
65
|
+
await save(kv, handle, { letter, sentAt: now });
|
|
66
|
+
log(`check @${handle}: letter sent`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The letter taken in: true when it is the one waiting. */
|
|
71
|
+
export async function answerLetter(kv, handle, letter, { now = Date.now() } = {}) {
|
|
72
|
+
if (!kv || !LETTER_ID.test(String(letter || ''))) return false;
|
|
73
|
+
const st = await read(kv, handle);
|
|
74
|
+
if (st.letter !== letter) return false;
|
|
75
|
+
if (!st.answeredAt) await save(kv, handle, { ...st, answeredAt: now });
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** When the letter waiting was alerted and is still unanswered, its time; else null. */
|
|
80
|
+
export async function missingLetter(kv, handle) {
|
|
81
|
+
if (!kv) return null;
|
|
82
|
+
const st = await read(kv, handle);
|
|
83
|
+
return st.alertedAt && !st.answeredAt ? new Date(st.alertedAt).toISOString() : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What the gateway's reader needs to take a letter in at the gateway itself. */
|
|
87
|
+
export const checkLettersFor = (ctx, handle) => (ctx?.frontOrigin && ctx.mastoKv
|
|
88
|
+
? { actor: checkActor(ctx.frontOrigin), taken: async (letter) => { await answerLetter(ctx.mastoKv, handle, letter); } }
|
|
89
|
+
: null);
|
|
90
|
+
|
|
91
|
+
// The letter's sender, for anything that looks it up: an application, whose
|
|
92
|
+
// inbox takes and keeps nothing. Returns a response, or null for other paths.
|
|
93
|
+
export function routeCheckActor(request, pathname, ctx, { j }) {
|
|
94
|
+
if (pathname === '/check/actor' && (request.method === 'GET' || request.method === 'HEAD')) {
|
|
95
|
+
const id = checkActor(ctx.frontOrigin || new URL(request.url).origin);
|
|
96
|
+
return j(200, {
|
|
97
|
+
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
98
|
+
id, type: 'Application', preferredUsername: 'check', name: `${ctx.host || new URL(id).host} test letters`,
|
|
99
|
+
inbox: `${base(new URL(id).origin)}/check/inbox`,
|
|
100
|
+
}, 'application/activity+json');
|
|
101
|
+
}
|
|
102
|
+
if (pathname === '/check/inbox' && request.method === 'POST') return { status: 202, headers: {}, body: null };
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The owner's FediPod, saying it took the letter in, proved like /api/here;
|
|
107
|
+
// and the letter's sender, for anything that looks it up.
|
|
108
|
+
export async function routeCheckApi(request, pathname, ctx, deps) {
|
|
109
|
+
if (pathname.startsWith('/check/')) return routeCheckActor(request, pathname, ctx, deps);
|
|
110
|
+
if (pathname !== '/api/check') return null;
|
|
111
|
+
const { j } = deps;
|
|
112
|
+
if (request.method === 'OPTIONS') return deps.apiPreflight();
|
|
113
|
+
if (request.method !== 'POST') return null;
|
|
114
|
+
let body;
|
|
115
|
+
try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
|
|
116
|
+
const handle = String(body.handle || '').toLowerCase();
|
|
117
|
+
const rec = await ctx.lookup(handle);
|
|
118
|
+
if (!rec) return j(404, { error: 'no such account' });
|
|
119
|
+
const who = await deps.provedOwner(request, pathname, ctx, rec, deps);
|
|
120
|
+
if (who.error) return who.error;
|
|
121
|
+
const answered = await answerLetter(ctx.mastoKv, handle, String(body.letter || ''));
|
|
122
|
+
return j(200, { ok: true, handle, answered });
|
|
123
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// connected-feeds.mjs — the owner's other accounts read into theirs at the
|
|
2
|
+
// gateway, for an app such as Elk while FediPod is closed in the browser.
|
|
3
|
+
//
|
|
4
|
+
// FediPod in the browser reads the owner's connected Mastodon-API accounts and
|
|
5
|
+
// Bluesky account every five minutes while it is open (acctfeed.mjs,
|
|
6
|
+
// bskyfeed.mjs). Here the same reads are made, at most every five minutes,
|
|
7
|
+
// when an app asks for the home timeline or the notifications and nobody else
|
|
8
|
+
// holds the account. The logins are read from the pod for the one read and are
|
|
9
|
+
// never kept at the gateway (lib/core/pod-only.mjs); a login the far server
|
|
10
|
+
// renews goes back to the pod.
|
|
11
|
+
import { FediAccounts } from '../connections/fediacct.mjs';
|
|
12
|
+
import { AcctFeed } from '../connections/acctfeed.mjs';
|
|
13
|
+
import { Atproto } from '../connections/atproto.mjs';
|
|
14
|
+
import { BskyFeed } from '../connections/bskyfeed.mjs';
|
|
15
|
+
import { safeFetch } from '../shared/safefetch.mjs';
|
|
16
|
+
import { apUrls } from '../core/wire.mjs';
|
|
17
|
+
import { lockCopy, heldByBrowser } from './copy.mjs';
|
|
18
|
+
import { reachAccount, settle, stateAndLease } from './account-agent.mjs';
|
|
19
|
+
|
|
20
|
+
export const FEEDS_EVERY_MS = 5 * 60_000;
|
|
21
|
+
// An account with nothing connected is looked at again only this often.
|
|
22
|
+
export const NONE_EVERY_MS = 60 * 60_000;
|
|
23
|
+
// Each request to another server is cut short, and Bluesky is not started
|
|
24
|
+
// once this much of the read has gone, so an app's write waiting on the
|
|
25
|
+
// account does not wait long.
|
|
26
|
+
const ASK_MS = 6_000;
|
|
27
|
+
const READ_MS = 15_000;
|
|
28
|
+
const CONNECTED = ['fediaccts.json', 'atproto.json'];
|
|
29
|
+
|
|
30
|
+
// The logins as FediPod in the browser keeps them: one document for the
|
|
31
|
+
// Mastodon-API accounts, by id, and one for Bluesky.
|
|
32
|
+
function accountsVault(store) {
|
|
33
|
+
const all = () => store.read('fediaccts.json', {}) || {};
|
|
34
|
+
return {
|
|
35
|
+
names: () => Object.keys(all()),
|
|
36
|
+
read: (id) => all()[id] || null,
|
|
37
|
+
write: (id, rec) => store.write('fediaccts.json', { ...all(), [id]: { ...rec, storage: 'pod' } }),
|
|
38
|
+
remove: async () => false,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const NO_APPS = { names: () => [], read: () => null, write: () => {}, remove: async () => false };
|
|
42
|
+
function blueskyVault(store) {
|
|
43
|
+
return {
|
|
44
|
+
names: () => (store.read('atproto.json', null) ? ['bluesky'] : []),
|
|
45
|
+
read: () => store.read('atproto.json', null),
|
|
46
|
+
write: (_, rec) => store.write('atproto.json', { ...rec, storage: 'pod' }),
|
|
47
|
+
remove: async () => false,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const dueKey = (handle) => `${handle}/feeds-due`;
|
|
52
|
+
|
|
53
|
+
/** For an app checking in: when due, and never over FediPod open in a browser. */
|
|
54
|
+
export async function readConnectedForApp(ctx, handle, rec, { log = console.log, fetcher = null, session = null } = {}) {
|
|
55
|
+
if (!ctx.copyKv) return 0;
|
|
56
|
+
const due = async () => Date.now() >= Number((await ctx.copyKv.get(dueKey(handle)))?.text || 0);
|
|
57
|
+
// FediPod open in a browser reads them itself; the pod is not asked.
|
|
58
|
+
if (!await due() || await heldByBrowser(ctx.copyKv, handle)) return 0;
|
|
59
|
+
const unlock = await lockCopy(ctx.copyKv, handle, { waitMs: 1000 });
|
|
60
|
+
if (!unlock) return 0;
|
|
61
|
+
try {
|
|
62
|
+
// Another request may have read them while this one waited.
|
|
63
|
+
if (!await due()) return 0;
|
|
64
|
+
await ctx.copyKv.set(dueKey(handle), String(Date.now() + FEEDS_EVERY_MS));
|
|
65
|
+
const out = await readConnected(ctx, handle, rec, { log, fetcher, session });
|
|
66
|
+
if (out.none) await ctx.copyKv.set(dueKey(handle), String(Date.now() + NONE_EVERY_MS));
|
|
67
|
+
return out.added || 0;
|
|
68
|
+
} finally { await unlock(); }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One read of every connected account into the account, by a caller holding
|
|
73
|
+
* lockCopy. Returns { added } or { none } when nothing is connected.
|
|
74
|
+
*/
|
|
75
|
+
export async function readConnected(ctx, handle, rec, { log = console.log, fetcher = null, session = null } = {}) {
|
|
76
|
+
const at = await reachAccount(ctx, handle, rec, { log, session });
|
|
77
|
+
if (at.skipped) return { added: 0 };
|
|
78
|
+
if (!(await settle(ctx, handle, rec, at, { log })).ok) return { added: 0 };
|
|
79
|
+
// The key is not needed to read: only the logins come from the pod.
|
|
80
|
+
const { store, lease } = stateAndLease(ctx, handle, at, { log, podNames: [] });
|
|
81
|
+
try { await store.refresh(CONNECTED); } catch (e) { log(`@${handle}: connected accounts: ${e.message}`); return { added: 0 }; }
|
|
82
|
+
const out = fetcher || ctx.fetchImpl || null;
|
|
83
|
+
const ask = (url, init = {}) => {
|
|
84
|
+
const cut = { ...init, signal: AbortSignal.timeout(ASK_MS) };
|
|
85
|
+
return out ? out(url, cut) : safeFetch(url, cut);
|
|
86
|
+
};
|
|
87
|
+
const said = (m) => log(`@${handle}: ${m}`);
|
|
88
|
+
const masto = new FediAccounts({ vault: accountsVault(store), apps: NO_APPS, log: said, fetcher: ask });
|
|
89
|
+
const bsky = new Atproto({ vault: blueskyVault(store), log: said, fetcher: ask });
|
|
90
|
+
const bskyOn = () => bsky.connected() && !bsky.read()?.feedPaused;
|
|
91
|
+
if (!masto.connected() && !bskyOn()) return { none: true };
|
|
92
|
+
if (!await lease.acquire()) return { added: 0 };
|
|
93
|
+
try {
|
|
94
|
+
await store.load();
|
|
95
|
+
const config = store.getConfig();
|
|
96
|
+
if (!config || config.quiescedAt) return { added: 0 };
|
|
97
|
+
// Stamped as FediPod in the browser stamps them, so a login made for
|
|
98
|
+
// another actor is left alone here too.
|
|
99
|
+
const publicBase = config.gateway?.frontActor ? config.gateway.frontActor.replace(/ap\/actor\/?$/u, '') : null;
|
|
100
|
+
masto.actorId = bsky.actorId = apUrls(at.pod, config.root || at.root, { publicBase }).actor;
|
|
101
|
+
const started = Date.now();
|
|
102
|
+
let added = 0;
|
|
103
|
+
if (masto.connected()) {
|
|
104
|
+
const feed = new AcctFeed({ store, accounts: masto, log: said });
|
|
105
|
+
await feed.sweep();
|
|
106
|
+
added += feed.lastAdded;
|
|
107
|
+
}
|
|
108
|
+
if (bskyOn() && Date.now() - started < READ_MS) {
|
|
109
|
+
const feed = new BskyFeed({ store, atproto: bsky, log: said });
|
|
110
|
+
await feed.sweep();
|
|
111
|
+
added += feed.lastAdded;
|
|
112
|
+
}
|
|
113
|
+
if (!await store.commit()) said('connected accounts read, but not written to the copy');
|
|
114
|
+
return { added };
|
|
115
|
+
} finally { await lease.release().catch(() => {}); }
|
|
116
|
+
}
|
package/lib/gateway/copy.mjs
CHANGED
|
@@ -375,6 +375,25 @@ export async function flushCopy(kv, h, { pod, log = () => {} }) {
|
|
|
375
375
|
let changed = false;
|
|
376
376
|
for (const b of docs) {
|
|
377
377
|
const name = b.key.slice(`${h}/d/`.length);
|
|
378
|
+
// A pod-only document in the copy was put there before it was pod-only.
|
|
379
|
+
// It goes to the pod unless the pod's is newer (changed since the copy
|
|
380
|
+
// last wrote it, or made since the copy was), then out of the copy and
|
|
381
|
+
// its record.
|
|
382
|
+
if (podOnly(name)) {
|
|
383
|
+
const got = await kv.get(b.key);
|
|
384
|
+
const there = typeof pod.read === 'function' ? await pod.read(name).catch(() => null) : null;
|
|
385
|
+
const podMoved = !!there?.ok && (!flushed[name] || hashOf(there.body) !== flushed[name].hash);
|
|
386
|
+
if (got && !podMoved && flushed[name]?.hash !== hashOf(got.text)) {
|
|
387
|
+
const w = await pod.write(name, got.text, 'application/json');
|
|
388
|
+
if (!w.ok) { log(`copy @${h}: ${name} not written to the pod (${w.why})`); continue; }
|
|
389
|
+
n++;
|
|
390
|
+
}
|
|
391
|
+
await kv.delete(b.key);
|
|
392
|
+
delete flushed[name];
|
|
393
|
+
changed = true;
|
|
394
|
+
log(`copy @${h}: ${name} is kept on the pod only now, and left the copy`);
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
378
397
|
if (flushed[name]?.etag === b.etag) continue;
|
|
379
398
|
const got = await kv.get(b.key);
|
|
380
399
|
if (!got) continue;
|
|
@@ -23,7 +23,8 @@ import * as podRoot from '../pod/root.mjs';
|
|
|
23
23
|
import * as podPolicy from '../pod/policy.mjs';
|
|
24
24
|
import { podBaseOfWebId } from '../pod/urls.mjs';
|
|
25
25
|
import { routeQuietApi, noteOpened, noteReceived, closedState, accountState, closedAnswer, provedOwner } from './quiet.mjs';
|
|
26
|
-
import { holdsMail, mayBeAway, isPresent, holdingPut, afterHeld, routeHereApi, routeKeeperApi, routePushApi } from './held-mail.mjs';
|
|
26
|
+
import { holdsMail, mayBeAway, isPresent, holdingPut, afterHeld, routeHereApi, routeKeeperApi, routePushApi, routeAlertApi } from './held-mail.mjs';
|
|
27
|
+
import { routeCheckApi, missingLetter } from './check-letter.mjs';
|
|
27
28
|
import { routeNoticesApi } from './notices.mjs';
|
|
28
29
|
import { withSecurityHeaders } from './headers.mjs';
|
|
29
30
|
import { claimedWebId, verifyPodToken } from './token-claims.mjs';
|
|
@@ -574,8 +575,9 @@ async function route(request, ctx) {
|
|
|
574
575
|
const quiet = await routeQuietApi(request, pathname, ctx, { j, verifyPodToken, webidUnderPod, apiPreflight });
|
|
575
576
|
if (quiet) return withApiCors(quiet);
|
|
576
577
|
// The owner's app saying it is open, taking what was held, and naming what to push (held-mail.mjs).
|
|
577
|
-
const owned = { j, verifyPodToken, webidUnderPod, apiPreflight, provedOwner };
|
|
578
|
-
const here = await routeHereApi(request, pathname, ctx, owned) || await routeKeeperApi(request, pathname, ctx, owned) || await routePushApi(request, pathname, ctx, owned)
|
|
578
|
+
const owned = { j, verifyPodToken, webidUnderPod, apiPreflight, provedOwner, missingLetter: (h) => missingLetter(ctx.mastoKv, h) };
|
|
579
|
+
const here = await routeHereApi(request, pathname, ctx, owned) || await routeKeeperApi(request, pathname, ctx, owned) || await routePushApi(request, pathname, ctx, owned)
|
|
580
|
+
|| await routeAlertApi(request, pathname, ctx, owned) || await routeCheckApi(request, pathname, ctx, owned);
|
|
579
581
|
if (here) return withApiCors(here);
|
|
580
582
|
|
|
581
583
|
// The relay: the front sends requests a browser has already signed. A page
|