fedipod 1.36.6 → 1.40.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 +29 -8
- package/architecture.md +8 -0
- package/bin/fedipod.mjs +6 -0
- package/cli.md +7 -1
- package/device-agent.md +3 -3
- package/gateway.md +69 -8
- package/groups.md +2 -1
- package/gui.md +3 -1
- package/lib/client/c2s.mjs +97 -43
- package/lib/client/masto/accounts.mjs +1 -0
- package/lib/client/masto/bridge.mjs +61 -0
- package/lib/client/masto/index.mjs +4 -1
- package/lib/client/masto/oauth.mjs +1 -1
- package/lib/client/masto/statuses.mjs +3 -0
- package/lib/connections/acctfeed.mjs +13 -9
- package/lib/connections/bskyfeed.mjs +13 -9
- package/lib/connections/tagfeed.mjs +14 -9
- package/lib/core/deliver.mjs +17 -1
- package/lib/core/intake/activities.mjs +18 -2
- package/lib/core/intake/group.mjs +3 -1
- package/lib/core/intake/index.mjs +226 -48
- package/lib/core/intake/notes.mjs +12 -5
- package/lib/core/intake/verify.mjs +11 -0
- package/lib/core/lease.mjs +15 -1
- package/lib/core/place.mjs +82 -0
- package/lib/core/pod-only.mjs +4 -0
- package/lib/core/publisher/collections.mjs +37 -2
- package/lib/core/publisher/index.mjs +25 -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/scheduled.mjs +39 -0
- package/lib/core/social.mjs +62 -32
- package/lib/core/storage.mjs +67 -0
- package/lib/core/store.mjs +77 -5
- 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/account-agent.mjs +111 -0
- package/lib/gateway/copy.mjs +381 -0
- package/lib/gateway/front-core.mjs +74 -63
- package/lib/gateway/gateway-core.mjs +107 -9
- package/lib/gateway/held-mail.mjs +198 -0
- package/lib/gateway/keeper-due.mjs +39 -0
- package/lib/gateway/keeper-session.mjs +10 -0
- package/lib/gateway/keeper.mjs +72 -0
- package/lib/gateway/masto-gateway.mjs +510 -0
- package/lib/gateway/quiet.mjs +7 -2
- package/lib/gateway/relay-extras.mjs +89 -0
- package/lib/gateway/state-api.mjs +207 -0
- package/lib/gateway/token-claims.mjs +16 -0
- package/lib/pod/containers.mjs +17 -0
- package/lib/pod/location.mjs +52 -0
- package/lib/pod/notes.mjs +2 -4
- package/lib/pod/transport.mjs +214 -24
- 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 +5 -5
- package/lib/session/demo.html +1 -1
- package/lib/session/fedi-account.mjs +19 -10
- package/lib/session/package.json +2 -2
- package/package.json +2 -2
- package/run-agent.mjs +6 -16
- package/scripts/stage-site.mjs +17 -4
- package/web/admin/actors.js +2 -0
- package/web/admin/gateway.js +14 -1
- package/web/admin/index.html +13 -0
- package/web/admin/oauth-signin.mjs +1 -1
- 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 +2 -2
- package/web/app/admin-facade.mjs +12 -2
- package/web/app/agent.mjs +228 -51
- package/web/app/boot.mjs +75 -32
- package/web/app/copy-mode.mjs +223 -0
- package/web/app/dist/boot.js +498 -88
- package/web/app/dist/boot.js.map +4 -4
- package/web/app/dist/sw.js +4016 -2539
- 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/sw-src.mjs +15 -53
- package/web/app/update.js +3 -2
- package/web/app/warm-start.mjs +115 -0
- package/web/app-signin/app-signin.mjs +78 -0
- package/web/app-signin/index.html +41 -0
- package/web/front/run.html +7 -1
- package/web/front/run.js +30 -4
|
@@ -63,6 +63,24 @@ function concernsUsAtEdge(activity, ident) {
|
|
|
63
63
|
return !!inReplyTo && ident.notesPrefix && String(inReplyTo).startsWith(ident.notesPrefix);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// Whether a delivery becomes one of the account's notifications rather than
|
|
67
|
+
// a post in its timeline: a follow, a like, boost or reaction to its own post,
|
|
68
|
+
// or a post that names it — a mention, a reply to it, a direct message. Pure
|
|
69
|
+
// addressing, like the check above; the drain decides for certain.
|
|
70
|
+
export function notifies(activity, ident) {
|
|
71
|
+
const type = activity?.type;
|
|
72
|
+
if (type === 'Follow') return true;
|
|
73
|
+
const obj = typeof activity.object === 'object' ? activity.object : null;
|
|
74
|
+
const target = idOf(activity.object);
|
|
75
|
+
const ours = (u) => !!u && !!ident.notesPrefix && String(u).startsWith(ident.notesPrefix);
|
|
76
|
+
if (['Like', 'Announce', 'EmojiReact'].includes(type)) return ours(target);
|
|
77
|
+
if (type !== 'Create') return false;
|
|
78
|
+
const named = [].concat(activity.to || [], activity.cc || [], obj?.to || [], obj?.cc || []).map(idOf);
|
|
79
|
+
if (named.includes(ident.actorUrl)) return true;
|
|
80
|
+
if ([].concat(obj?.tag || []).some((t) => t?.type === 'Mention' && t.href === ident.actorUrl)) return true;
|
|
81
|
+
return ours(idOf(obj?.inReplyTo));
|
|
82
|
+
}
|
|
83
|
+
|
|
66
84
|
// Handle one inbound delivery. `request` is a WHATWG Request. `ident` is the
|
|
67
85
|
// resolved identity policy: { inboxUrl, actorUrl, followersUrl, notesPrefix,
|
|
68
86
|
// following, blocklist, kind, gatewayWebId, hmacSecret }. `podPut(url, body,
|
|
@@ -119,7 +137,8 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
|
|
|
119
137
|
// holding any state.
|
|
120
138
|
if (!okA) return { status: 502, reason: 'pod inbox write failed' };
|
|
121
139
|
if (receipt) await inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt);
|
|
122
|
-
return { status: 202, reason: v.verified ? 'verified' : 'buffered-unverified', content, bytes: Buffer.byteLength(raw)
|
|
140
|
+
return { status: 202, reason: v.verified ? 'verified' : 'buffered-unverified', content, bytes: Buffer.byteLength(raw),
|
|
141
|
+
type: activity.type, notifies: notifies(activity, ident) };
|
|
123
142
|
}
|
|
124
143
|
|
|
125
144
|
// The outbox door: the owner's own post, taken on their behalf.
|
|
@@ -133,14 +152,15 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
|
|
|
133
152
|
// client-to-server dispatcher, which publishes and delivers it — so the post
|
|
134
153
|
// goes out when the agent next runs, the same way inbound mail is read.
|
|
135
154
|
//
|
|
136
|
-
// `slug` is the name the
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
155
|
+
// `slug` is the name of the new document — the client's, or one the door
|
|
156
|
+
// chose — and `serial` and `at` name any other activity. They ride in the
|
|
157
|
+
// receipt so the dispatcher uses them, and they are what let the door answer a
|
|
158
|
+
// Location before anything exists. A name the client chose that is already
|
|
159
|
+
// taken is the one case where the agent picks another.
|
|
140
160
|
export const SLUG_OK = /^[A-Za-z0-9._-]{1,64}$/u;
|
|
141
161
|
export const safeSlug = (s) => (typeof s === 'string' && SLUG_OK.test(s) && !/^\.+$/u.test(s) ? s : null);
|
|
142
162
|
|
|
143
|
-
export async function handleOwnerPost(request, ident, { podPut, ownerWebId, maxBytes = DEFAULT_MAX_BYTES } = {}) {
|
|
163
|
+
export async function handleOwnerPost(request, ident, { podPut, ownerWebId, maxBytes = DEFAULT_MAX_BYTES, exists = null } = {}) {
|
|
144
164
|
if (!ident.hmacSecret) return { status: 409, reason: 'this account has no door secret — attach it again' };
|
|
145
165
|
let raw;
|
|
146
166
|
try { raw = await request.text(); } catch { return { status: 400, reason: 'unreadable body' }; }
|
|
@@ -150,16 +170,94 @@ export async function handleOwnerPost(request, ident, { podPut, ownerWebId, maxB
|
|
|
150
170
|
if (!doc || typeof doc !== 'object' || Array.isArray(doc) || !doc.type) {
|
|
151
171
|
return { status: 400, reason: 'a typed ActivityStreams object is required' };
|
|
152
172
|
}
|
|
153
|
-
|
|
173
|
+
// A post that makes something new is named HERE, when the client did not
|
|
174
|
+
// name it, so the answer can give the address of what it made (§6: 201 with
|
|
175
|
+
// the new activity's id in Location) though nothing has been made yet.
|
|
176
|
+
// What the account would refuse is refused here, before the app is told
|
|
177
|
+
// "created" for something that will never exist.
|
|
178
|
+
const refusal = refusedAtTheDoor(doc, ident);
|
|
179
|
+
if (refusal) return { status: 422, reason: refusal };
|
|
180
|
+
const makes = doc.type === 'Create' || !ACTIVITY_TYPES.has(doc.type);
|
|
181
|
+
// A name the client asks for that is already taken is replaced here, so the
|
|
182
|
+
// address answered is the one the post gets.
|
|
183
|
+
let slug = safeSlug(request.headers.get('slug'));
|
|
184
|
+
if (slug && makes && exists && await exists(containerFor(doc, ident) + slug).catch(() => false)) slug = null;
|
|
185
|
+
if (!slug && makes) slug = mintSlug();
|
|
186
|
+
// Anything else is named the way the agent names it, from a serial and a
|
|
187
|
+
// time chosen here and handed over in the receipt.
|
|
188
|
+
// Two posts in the same millisecond still get different names.
|
|
189
|
+
const now = Date.now();
|
|
190
|
+
const serial = now * 1000 + crypto.randomInt(1000);
|
|
191
|
+
const at = new Date(now).toISOString();
|
|
192
|
+
const hash = sha256hex(raw);
|
|
193
|
+
// The receipt names the body it vouches for, so it vouches for nothing else,
|
|
194
|
+
// and the agent can tell the same body sent twice.
|
|
154
195
|
const receipt = signReceipt({
|
|
155
196
|
v: 1, verified: true, method: 'c2s', keyId: ownerWebId || null, actor: ident.actorUrl,
|
|
156
197
|
checks: ['owner-token'], reason: 'owner', gateway: ident.gatewayWebId, ...(slug ? { slug } : {}),
|
|
198
|
+
serial, at, hash,
|
|
157
199
|
}, ident.hmacSecret);
|
|
158
|
-
const hash = sha256hex(raw);
|
|
159
200
|
const okA = await inbox.appendVerifiedDelivery(podPut, ident.inboxUrl, hash, raw);
|
|
160
201
|
if (!okA) return { status: 502, reason: 'pod inbox write failed' };
|
|
161
202
|
await inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt);
|
|
162
|
-
|
|
203
|
+
if (makes) {
|
|
204
|
+
const object = slug && ident.notesPrefix ? containerFor(doc, ident) + slug : null;
|
|
205
|
+
return { status: 201, reason: 'accepted', object, location: object ? object + '-create' : null };
|
|
206
|
+
}
|
|
207
|
+
const object = typeof doc.object === 'string' ? doc.object : doc.object?.id || null;
|
|
208
|
+
return { status: 201, reason: 'accepted', object, location: activityIdFor(doc.type, object, ident, serial, at) };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// The id the agent gives each kind of activity (lib/core/wire.mjs): an edit by
|
|
212
|
+
// its note and time, a deletion by its note, a boost and an undo as documents
|
|
213
|
+
// in the posts folder, the rest by the actor and serial.
|
|
214
|
+
const SERIAL_NAMED = new Set(['Like', 'Follow', 'Block', 'Add', 'Remove', 'Accept', 'Reject']);
|
|
215
|
+
function activityIdFor(type, object, ident, serial, at) {
|
|
216
|
+
const actor = ident.actorUrl;
|
|
217
|
+
if ((type === 'Update' || type === 'Delete') && object && object !== actor) {
|
|
218
|
+
return type === 'Delete' ? object + '-delete' : object + '-update-' + at.replace(/[^0-9TZ]/g, '');
|
|
219
|
+
}
|
|
220
|
+
if ((type === 'Announce' || type === 'Undo') && ident.notesPrefix) return `${ident.notesPrefix}${type.toLowerCase()}-${serial}`;
|
|
221
|
+
return SERIAL_NAMED.has(type) ? `${actor}#${type.toLowerCase()}-${serial}` : null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// The account's own changes are made on its manage page, and the one
|
|
225
|
+
// collection a client may add to or take from is the pinned posts.
|
|
226
|
+
function refusedAtTheDoor(doc, ident) {
|
|
227
|
+
const object = typeof doc.object === 'string' ? doc.object : doc.object?.id || null;
|
|
228
|
+
const target = typeof doc.target === 'string' ? doc.target : doc.target?.id || null;
|
|
229
|
+
if (doc.type === 'Move') return 'moving the account is done on its manage page';
|
|
230
|
+
if ((doc.type === 'Update' || doc.type === 'Delete') && object === ident.actorUrl) {
|
|
231
|
+
return 'the account itself is changed on its manage page';
|
|
232
|
+
}
|
|
233
|
+
if ((doc.type === 'Add' || doc.type === 'Remove') && target !== ident.actorUrl.replace(/actor$/u, 'featured')) {
|
|
234
|
+
return 'the pinned posts are the one collection an app may add to or take from';
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// The activity types §6 names; anything else with a type is an object the
|
|
240
|
+
// agent wraps in a Create. The same list the dispatcher keeps (lib/client/c2s.mjs).
|
|
241
|
+
const ACTIVITY_TYPES = new Set([
|
|
242
|
+
'Create', 'Update', 'Delete', 'Follow', 'Like', 'Announce', 'Undo',
|
|
243
|
+
'Block', 'Add', 'Remove', 'Accept', 'Reject', 'Move',
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
// The name the agent would have chosen itself: the day and eight hex characters.
|
|
247
|
+
const mintSlug = () => new Date().toISOString().slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
|
|
248
|
+
|
|
249
|
+
// Where the agent will keep it: public and unlisted posts under notes/, posts
|
|
250
|
+
// for followers or named people under private/ — decided by the addressing,
|
|
251
|
+
// read the way the dispatcher reads it (nothing stated is public).
|
|
252
|
+
const PUBLIC_NAMES = new Set(['https://www.w3.org/ns/activitystreams#Public', 'as:Public', 'Public']);
|
|
253
|
+
function containerFor(doc, ident) {
|
|
254
|
+
const object = doc.type === 'Create' && doc.object && typeof doc.object === 'object' ? doc.object : doc;
|
|
255
|
+
const list = (v) => (v == null ? [] : Array.isArray(v) ? v : [v]).map(x => (typeof x === 'string' ? x : x?.id));
|
|
256
|
+
const to = list(doc.to ?? object.to);
|
|
257
|
+
const cc = list(doc.cc ?? object.cc);
|
|
258
|
+
const blind = list(doc.bto ?? object.bto).length + list(doc.bcc ?? object.bcc).length;
|
|
259
|
+
const open = (!to.length && !cc.length && !blind) || [...to, ...cc].some(a => PUBLIC_NAMES.has(a));
|
|
260
|
+
return open ? ident.notesPrefix : ident.notesPrefix.replace(/ap\/notes\/$/u, 'ap/private/');
|
|
163
261
|
}
|
|
164
262
|
|
|
165
263
|
export const _internal = { isBlocked, concernsUsAtEdge, httpUrl, sha256hex };
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// held-mail.mjs — a browser account's mail, held at the gateway while its
|
|
2
|
+
// owner's app is closed, and handed to the pod as one batch.
|
|
3
|
+
//
|
|
4
|
+
// Every delivery the door takes is two writes into the owner's pod inbox, and
|
|
5
|
+
// the drain then reads and deletes both: six pod requests a piece. While the
|
|
6
|
+
// app is closed nobody reads it, so the door keeps it here instead
|
|
7
|
+
// (ctx.holdMail), and writes it to the pod inbox as one document when the app
|
|
8
|
+
// says it is open (POST /api/here) or when the timer comes round (flushAll).
|
|
9
|
+
// While the app is open, mail goes straight to the pod as it always has.
|
|
10
|
+
//
|
|
11
|
+
// A batch is plain JSON, `{ "batch": [ { name, body, receipt } ] }`: `name` is
|
|
12
|
+
// what the item would have been called in the inbox, `body` its bytes, and
|
|
13
|
+
// `receipt` the door's receipt for it, or null. The drain reads it
|
|
14
|
+
// (lib/core/intake: batch documents).
|
|
15
|
+
//
|
|
16
|
+
// ctx, when the deploy holds mail:
|
|
17
|
+
// holdMail(handle, name, body, ct) keep one inbox item
|
|
18
|
+
// listHeld(handle) -> [name] what is kept for an account
|
|
19
|
+
// readHeld(handle, name) -> string one kept item
|
|
20
|
+
// dropHeld(handle, name) let one go
|
|
21
|
+
// heldAccounts() -> [handle] every account with something kept
|
|
22
|
+
// markPresent(handle) the owner's app says it is open
|
|
23
|
+
// presentAt(handle) -> ms when it last said so
|
|
24
|
+
// noteNext(handle, at, {earliest}) when the account's keeper next has work
|
|
25
|
+
|
|
26
|
+
import { copyMeta, keptNow } from './copy.mjs';
|
|
27
|
+
|
|
28
|
+
// The app says it is open every five minutes; a little over that counts.
|
|
29
|
+
export const PRESENT_MS = 8 * 60_000;
|
|
30
|
+
// Entries per batch document, so a long backlog is a few documents, not one
|
|
31
|
+
// the drain cannot read.
|
|
32
|
+
export const BATCH_MAX = 100;
|
|
33
|
+
const RECEIPT = '.receipt.json';
|
|
34
|
+
|
|
35
|
+
const inboxOf = (rec) => rec.inboxUrl || rec.podHome + 'ap/inbox/';
|
|
36
|
+
|
|
37
|
+
// Only a browser account is held: its owner signs in (so the row has
|
|
38
|
+
// `openedAt`), and its app says when it is open. An account run by a DeviceAgent
|
|
39
|
+
// drains whenever mail lands, and keeps its mail as it always has.
|
|
40
|
+
export const holdsMail = (ctx, rec) => !!(ctx.holdMail && rec?.openedAt);
|
|
41
|
+
|
|
42
|
+
// Asked on every delivery, so it is remembered a minute per running copy.
|
|
43
|
+
const seen = new Map(); // handle -> { at, asked }
|
|
44
|
+
export async function isPresent(ctx, handle, now = Date.now()) {
|
|
45
|
+
const had = seen.get(handle);
|
|
46
|
+
if (had && now - had.asked < 60_000) return !!had.at && now - had.at < PRESENT_MS;
|
|
47
|
+
const at = Number(await ctx.presentAt?.(handle).catch(() => 0)) || 0;
|
|
48
|
+
if (seen.size > 10_000) seen.clear();
|
|
49
|
+
seen.set(handle, { at, asked: now });
|
|
50
|
+
return !!at && now - at < PRESENT_MS;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The door's put while the app is closed: the same name the pod would have
|
|
54
|
+
// given the item, kept here instead.
|
|
55
|
+
export const holdingPut = (ctx, handle, rec) => async (url, body, ct) => {
|
|
56
|
+
const name = url.slice(inboxOf(rec).length);
|
|
57
|
+
await ctx.holdMail(handle, name, body, ct);
|
|
58
|
+
return true;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What is held for one account, each delivery with its receipt:
|
|
63
|
+
* [{ name, body, receipt, held: [the held names it came from] }]. A receipt
|
|
64
|
+
* whose delivery is not here has nothing to vouch for, and is let go.
|
|
65
|
+
*/
|
|
66
|
+
export async function heldEntries(ctx, handle) {
|
|
67
|
+
const names = await ctx.listHeld(handle);
|
|
68
|
+
if (!names.length) return [];
|
|
69
|
+
const items = new Map(); // item name -> { body, receipt, held: [names] }
|
|
70
|
+
for (const name of names) {
|
|
71
|
+
const got = await ctx.readHeld(handle, name);
|
|
72
|
+
const item = name.endsWith(RECEIPT) ? name.slice(0, -RECEIPT.length) : name;
|
|
73
|
+
const e = items.get(item) || { held: [] };
|
|
74
|
+
e.held.push(name);
|
|
75
|
+
if (got != null) {
|
|
76
|
+
if (name.endsWith(RECEIPT)) { try { e.receipt = JSON.parse(got); } catch { e.receipt = null; } } else e.body = got;
|
|
77
|
+
}
|
|
78
|
+
items.set(item, e);
|
|
79
|
+
}
|
|
80
|
+
for (const [item, e] of items) {
|
|
81
|
+
if (typeof e.body !== 'string') { for (const n of e.held) await ctx.dropHeld(handle, n); items.delete(item); }
|
|
82
|
+
}
|
|
83
|
+
return [...items].map(([name, e]) => ({ name, body: e.body, receipt: e.receipt || null, held: e.held }));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Everything held for one account, written to its pod inbox in batches and
|
|
87
|
+
// then let go. Returns how many deliveries reached the pod. A batch the pod
|
|
88
|
+
// refuses stays held for the next try.
|
|
89
|
+
export async function flushHeld(ctx, handle, rec, { now = Date.now() } = {}) {
|
|
90
|
+
const all = (await heldEntries(ctx, handle)).map((e) => [e.name, e]);
|
|
91
|
+
if (!all.length) return 0;
|
|
92
|
+
let delivered = 0;
|
|
93
|
+
for (let i = 0; i < all.length; i += BATCH_MAX) {
|
|
94
|
+
const chunk = all.slice(i, i + BATCH_MAX);
|
|
95
|
+
const batch = chunk.map(([name, e]) => ({ name, body: e.body, receipt: e.receipt || null }));
|
|
96
|
+
const doc = `batch-${now}-${i / BATCH_MAX}-${Math.random().toString(36).slice(2, 8)}.json`;
|
|
97
|
+
const ok = await ctx.podPut(handle, inboxOf(rec) + doc, JSON.stringify({ batch }), 'application/json');
|
|
98
|
+
if (!ok) break;
|
|
99
|
+
for (const [, e] of chunk) for (const n of e.held) await ctx.dropHeld(handle, n);
|
|
100
|
+
delivered += chunk.length;
|
|
101
|
+
}
|
|
102
|
+
return delivered;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Held mail the keeper should not leave until the owner's app opens: a
|
|
106
|
+
// follow, so it is accepted while they are away. Anything else waits, in the
|
|
107
|
+
// pod inbox, for the app or for the keeper's next run.
|
|
108
|
+
const WAKES_KEEPER = new Set(['Follow']);
|
|
109
|
+
export async function wakesKeeper(ctx, handle, rec, type) {
|
|
110
|
+
if (!keptNow(ctx, rec) || !ctx.noteNext || !WAKES_KEEPER.has(type)) return false;
|
|
111
|
+
await ctx.noteNext(handle, new Date().toISOString(), { earliest: true }).catch(() => {});
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// After a delivery is held: a follow wakes the keeper, and anything that
|
|
116
|
+
// becomes a notification is read in and pushed at once to the phones and
|
|
117
|
+
// browsers the owner signed up for notifications (masto-gateway.mjs: pushHeld).
|
|
118
|
+
export async function afterHeld(ctx, handle, rec, { type, notifies = false } = {}) {
|
|
119
|
+
await wakesKeeper(ctx, handle, rec, type);
|
|
120
|
+
if (notifies && keptNow(ctx, rec) && ctx.startPush && await ctx.pushWanted?.(handle).catch(() => false)) {
|
|
121
|
+
await ctx.startPush(handle).catch((e) => console.log(`push @${handle}: not started: ${e?.message || e}`));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The timer's round: every account with mail kept, delivered. An account that
|
|
126
|
+
// is gone, closed or moved has nowhere to deliver to, so its mail is let go.
|
|
127
|
+
// `keep(handle, rec)` answers true when the account's keeper will take its mail
|
|
128
|
+
// itself (lib/gateway/keeper.mjs), which delivers it and then reads it.
|
|
129
|
+
export async function flushAll(ctx, { log = console.log, isGone = async () => false, keep = async () => false } = {}) {
|
|
130
|
+
for (const handle of await ctx.heldAccounts()) {
|
|
131
|
+
const rec = await ctx.lookup(handle);
|
|
132
|
+
if (!rec || rec.movedTo || await isGone(handle, rec)) {
|
|
133
|
+
for (const n of await ctx.listHeld(handle)) await ctx.dropHeld(handle, n);
|
|
134
|
+
log(`held mail @${handle}: let go (no account to deliver to)`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (await keep(handle, rec).catch(() => false)) continue;
|
|
138
|
+
const n = await flushHeld(ctx, handle, rec).catch((e) => { log(`held mail @${handle}: ${e?.message || e}`); return 0; });
|
|
139
|
+
if (n) log(`held mail @${handle}: ${n} delivered to the pod`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The owner letting the gateway act for them while the app is closed, or
|
|
144
|
+
// stopping it. Their app has already named the gateway in the rules on their
|
|
145
|
+
// pod (or taken it out) before it asks. Proved like /api/here.
|
|
146
|
+
export async function routeKeeperApi(request, pathname, ctx, deps) {
|
|
147
|
+
if (pathname !== '/api/keeper') return null;
|
|
148
|
+
const { j } = deps;
|
|
149
|
+
if (request.method === 'OPTIONS') return deps.apiPreflight();
|
|
150
|
+
if (request.method !== 'POST') return null;
|
|
151
|
+
if (!ctx.putDirectory) return j(501, { error: 'this front keeps no directory' });
|
|
152
|
+
let body;
|
|
153
|
+
try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
|
|
154
|
+
const handle = String(body.handle || '').toLowerCase();
|
|
155
|
+
const rec = await ctx.lookup(handle);
|
|
156
|
+
if (!rec) return j(404, { error: 'no such account' });
|
|
157
|
+
const who = await deps.provedOwner(request, pathname, ctx, rec, deps);
|
|
158
|
+
if (who.error) return who.error;
|
|
159
|
+
const on = body.on === true;
|
|
160
|
+
if (on && !ctx.keeperWebId) return j(501, { error: 'this gateway cannot act for accounts' });
|
|
161
|
+
// The account's copy goes back to the pod before the gateway stops
|
|
162
|
+
// (state-api.mjs: leave); the owner's app asks for that first.
|
|
163
|
+
if (!on && ctx.copyKv && await copyMeta(ctx.copyKv, handle)) {
|
|
164
|
+
return j(409, { error: "the account's copy is still at the gateway; give it back to the pod first", copy: true });
|
|
165
|
+
}
|
|
166
|
+
const next = { ...rec };
|
|
167
|
+
if (on) next.keeper = { webId: ctx.keeperWebId, at: new Date().toISOString() };
|
|
168
|
+
else delete next.keeper;
|
|
169
|
+
await ctx.putDirectory(handle, next);
|
|
170
|
+
console.log(`keeper @${handle}: ${on ? 'on' : 'off'}, at the owner's word`);
|
|
171
|
+
return j(200, { ok: true, handle, kept: on, keeper: ctx.keeperWebId || null });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// The app's "I am open": marks the account present, so mail goes straight to
|
|
175
|
+
// the pod, and delivers what was kept while it was closed. The owner is proved
|
|
176
|
+
// the way the relay proves them. Returns a response, or null for other paths.
|
|
177
|
+
export async function routeHereApi(request, pathname, ctx, deps) {
|
|
178
|
+
if (pathname !== '/api/here') return null;
|
|
179
|
+
const { j } = deps;
|
|
180
|
+
if (request.method === 'OPTIONS') return deps.apiPreflight();
|
|
181
|
+
if (request.method !== 'POST') return null;
|
|
182
|
+
if (!ctx.markPresent) return j(501, { error: 'this gateway holds no mail' });
|
|
183
|
+
let body;
|
|
184
|
+
try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
|
|
185
|
+
const handle = String(body.handle || '').toLowerCase();
|
|
186
|
+
const rec = await ctx.lookup(handle);
|
|
187
|
+
if (!rec) return j(404, { error: 'no such account' });
|
|
188
|
+
const who = await deps.provedOwner(request, pathname, ctx, rec, deps);
|
|
189
|
+
if (who.error) return who.error;
|
|
190
|
+
await ctx.markPresent(handle);
|
|
191
|
+
seen.set(handle, { at: Date.now(), asked: Date.now() });
|
|
192
|
+
// When its next scheduled post falls due, so the keeper publishes it if
|
|
193
|
+
// the app has closed by then (keeper.mjs).
|
|
194
|
+
if (body.nextAt !== undefined && ctx.noteNext) await ctx.noteNext(handle, body.nextAt || null).catch(() => {});
|
|
195
|
+
const flushed = await flushHeld(ctx, handle, rec)
|
|
196
|
+
.catch((e) => { console.log(`here @${handle}: held mail not delivered: ${e?.message || e}`); return 0; });
|
|
197
|
+
return j(200, { ok: true, handle, flushed });
|
|
198
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// keeper-due.mjs — when each kept account's keeper next has work
|
|
2
|
+
// (keeper.mjs), kept per account in a small key-value store: Netlify Blobs on
|
|
3
|
+
// Netlify, a Map in a test. `store` answers get(key) -> object|null,
|
|
4
|
+
// set(key, object) and keys() -> [key].
|
|
5
|
+
//
|
|
6
|
+
// A run sets the time from what it left (nextAt). A follow held at the door
|
|
7
|
+
// sets it to now, and one that arrives while a run is going is not lost when
|
|
8
|
+
// the run finishes. A run that could not take the account's lease tries again
|
|
9
|
+
// next round; one that failed, in an hour.
|
|
10
|
+
|
|
11
|
+
const RETRY_FAILED_MS = 3_600_000;
|
|
12
|
+
|
|
13
|
+
// The sooner of two times (ISO strings, either of them null).
|
|
14
|
+
export const earliest = (a, b) => (!a ? b || null : !b ? a : Date.parse(a) <= Date.parse(b) ? a : b);
|
|
15
|
+
|
|
16
|
+
export function keeperBook(store) {
|
|
17
|
+
return {
|
|
18
|
+
async noteKept(handle, out, started = 0, now = Date.now()) {
|
|
19
|
+
const prev = (await store.get(handle)) || {};
|
|
20
|
+
const next = out?.retry === 'soon' ? prev.nextAt
|
|
21
|
+
: out?.retry === 'later' ? new Date(now + RETRY_FAILED_MS).toISOString() : out?.nextAt;
|
|
22
|
+
const woken = prev.noted >= started ? prev.nextAt : null;
|
|
23
|
+
await store.set(handle, { at: now, waiting: out?.waiting || 0, skipped: out?.skipped || null,
|
|
24
|
+
nextAt: earliest(next || null, woken) });
|
|
25
|
+
},
|
|
26
|
+
async noteNext(handle, nextAt, { earliest: keepSooner = false } = {}) {
|
|
27
|
+
const prev = (await store.get(handle)) || {};
|
|
28
|
+
await store.set(handle, { ...prev, nextAt: keepSooner ? earliest(prev.nextAt, nextAt) : nextAt, noted: Date.now() });
|
|
29
|
+
},
|
|
30
|
+
async keeperDue(now = Date.now()) {
|
|
31
|
+
const due = [];
|
|
32
|
+
for (const key of await store.keys()) {
|
|
33
|
+
const k = (await store.get(key)) || {};
|
|
34
|
+
if (k.nextAt && Date.parse(k.nextAt) <= now) due.push(key);
|
|
35
|
+
}
|
|
36
|
+
return due;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// keeper-session.mjs — the gateway's own pod identity at work: one grant per
|
|
2
|
+
// running copy, shared by every account it works for, so a round is one token,
|
|
3
|
+
// not one per account (keeper.mjs, state-api.mjs).
|
|
4
|
+
import grant from '../../vendor/idp-grant.cjs';
|
|
5
|
+
|
|
6
|
+
let shared = null;
|
|
7
|
+
export function keeperSession(cred) {
|
|
8
|
+
if (!shared || shared.cred !== cred) shared = { cred, session: grant.createGrantSession(cred) };
|
|
9
|
+
return shared.session;
|
|
10
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// keeper.mjs — the gateway acting for a browser account while its owner's app
|
|
2
|
+
// is closed: the mail it held reaches the pod, the inbox is drained, follows
|
|
3
|
+
// and replies are handled, and deliveries waiting to go out are sent.
|
|
4
|
+
//
|
|
5
|
+
// The owner lets it (their app names the gateway's pod identity in the rules on
|
|
6
|
+
// their account's folder, and the row records `keeper`). It works with that
|
|
7
|
+
// identity and nothing else: it reads the signing key from the pod when it
|
|
8
|
+
// needs it, keeps no copy, and never makes or changes one. It takes the
|
|
9
|
+
// account's lease like any other device, and gives it back when done, so the
|
|
10
|
+
// owner's app finds the lease free and takes over as usual.
|
|
11
|
+
//
|
|
12
|
+
// One run is one account: keepOnce(). The fifteen-minute round (flush-mail.mjs)
|
|
13
|
+
// starts one only when an account whose app is closed has work due: a follow
|
|
14
|
+
// held at the door, a failed delivery's next try, a scheduled post or a poll's
|
|
15
|
+
// end. Other mail waits in the pod inbox for the app.
|
|
16
|
+
|
|
17
|
+
import { keeperSession } from './keeper-session.mjs';
|
|
18
|
+
import { flushHeld, heldEntries } from './held-mail.mjs';
|
|
19
|
+
import { publishDue, nextDue } from '../core/scheduled.mjs';
|
|
20
|
+
import { lockCopy } from './copy.mjs';
|
|
21
|
+
import { reachAccount, stateAndLease, actingAgent } from './account-agent.mjs';
|
|
22
|
+
|
|
23
|
+
export { keeperSession };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One account's pending work, done once. `ctx` is the gateway's: keeperWebId,
|
|
27
|
+
* keeperCredential and the held-mail stores. `session` may be handed in (a
|
|
28
|
+
* test does); otherwise the keeper's grant is used. Returns what happened.
|
|
29
|
+
*
|
|
30
|
+
* An account whose copy the gateway keeps (copy.mjs) is worked on there: its
|
|
31
|
+
* held mail is read straight into the copy, and the pod is written by the
|
|
32
|
+
* round, not here. Otherwise the held mail goes to the pod inbox first, so the
|
|
33
|
+
* drain reads it, as before.
|
|
34
|
+
*/
|
|
35
|
+
export async function keepOnce(ctx, handle, rec, { log = console.log, session = null } = {}) {
|
|
36
|
+
const say = (m) => log(`keeper @${handle}: ${m}`);
|
|
37
|
+
const at = await reachAccount(ctx, handle, rec, { log, session });
|
|
38
|
+
if (at.skipped) return { skipped: at.skipped };
|
|
39
|
+
|
|
40
|
+
const unlock = at.inCopy ? await lockCopy(ctx.copyKv, handle) : async () => {};
|
|
41
|
+
if (!unlock) return { skipped: 'the gateway is busy with this account', retry: 'soon' };
|
|
42
|
+
try {
|
|
43
|
+
// Held mail goes in first, so this drain reads it.
|
|
44
|
+
const flushed = !at.inCopy && ctx.listHeld
|
|
45
|
+
? await flushHeld(ctx, handle, rec).catch((e) => { say(`held mail not delivered: ${e.message}`); return 0; }) : 0;
|
|
46
|
+
const { store, lease } = stateAndLease(ctx, handle, at, { log });
|
|
47
|
+
if (!await lease.acquire()) return { skipped: 'another device is acting on this account', flushed, retry: 'soon' };
|
|
48
|
+
try {
|
|
49
|
+
await store.load();
|
|
50
|
+
const agent = await actingAgent(at, store, lease, { log });
|
|
51
|
+
if (agent.skipped) return { skipped: agent.skipped, flushed };
|
|
52
|
+
let taken = 0;
|
|
53
|
+
if (at.inCopy && ctx.listHeld) {
|
|
54
|
+
const entries = await heldEntries(ctx, handle);
|
|
55
|
+
const done = new Set(await agent.intake.takeHeld(entries));
|
|
56
|
+
for (const e of entries) if (done.has(e.name)) for (const n of e.held) await ctx.dropHeld(handle, n);
|
|
57
|
+
taken = done.size;
|
|
58
|
+
}
|
|
59
|
+
await agent.intake.drain();
|
|
60
|
+
const published = await publishDue(store, agent.publisher, say);
|
|
61
|
+
await agent.deliverer.drainQueue();
|
|
62
|
+
await store.flush?.();
|
|
63
|
+
agent.publisher.stopPolls?.();
|
|
64
|
+
const waiting = (store.getQueue?.() || []).length;
|
|
65
|
+
const nextAt = nextDue(store);
|
|
66
|
+
say(`done: ${flushed + taken} held delivered, inbox drained, ${published} scheduled post(s) published, ${waiting} delivery(ies) waiting`);
|
|
67
|
+
return { flushed: flushed + taken, drained: true, published, waiting, nextAt };
|
|
68
|
+
} finally {
|
|
69
|
+
await lease.release().catch(() => {});
|
|
70
|
+
}
|
|
71
|
+
} finally { await unlock(); }
|
|
72
|
+
}
|