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.
Files changed (94) hide show
  1. package/README.md +29 -8
  2. package/architecture.md +8 -0
  3. package/bin/fedipod.mjs +6 -0
  4. package/cli.md +7 -1
  5. package/device-agent.md +3 -3
  6. package/gateway.md +69 -8
  7. package/groups.md +2 -1
  8. package/gui.md +3 -1
  9. package/lib/client/c2s.mjs +97 -43
  10. package/lib/client/masto/accounts.mjs +1 -0
  11. package/lib/client/masto/bridge.mjs +61 -0
  12. package/lib/client/masto/index.mjs +4 -1
  13. package/lib/client/masto/oauth.mjs +1 -1
  14. package/lib/client/masto/statuses.mjs +3 -0
  15. package/lib/connections/acctfeed.mjs +13 -9
  16. package/lib/connections/bskyfeed.mjs +13 -9
  17. package/lib/connections/tagfeed.mjs +14 -9
  18. package/lib/core/deliver.mjs +17 -1
  19. package/lib/core/intake/activities.mjs +18 -2
  20. package/lib/core/intake/group.mjs +3 -1
  21. package/lib/core/intake/index.mjs +226 -48
  22. package/lib/core/intake/notes.mjs +12 -5
  23. package/lib/core/intake/verify.mjs +11 -0
  24. package/lib/core/lease.mjs +15 -1
  25. package/lib/core/place.mjs +82 -0
  26. package/lib/core/pod-only.mjs +4 -0
  27. package/lib/core/publisher/collections.mjs +37 -2
  28. package/lib/core/publisher/index.mjs +25 -1
  29. package/lib/core/publisher/notes.mjs +73 -7
  30. package/lib/core/publisher/own.mjs +143 -0
  31. package/lib/core/publisher/questions.mjs +5 -3
  32. package/lib/core/scheduled.mjs +39 -0
  33. package/lib/core/social.mjs +62 -32
  34. package/lib/core/storage.mjs +67 -0
  35. package/lib/core/store.mjs +77 -5
  36. package/lib/core/wire.mjs +51 -15
  37. package/lib/device/admin/routes/lifecycle.mjs +1 -1
  38. package/lib/device/admin/routes/setup.mjs +17 -1
  39. package/lib/device/cli/commands/setup.mjs +50 -8
  40. package/lib/device/cli/context.mjs +1 -1
  41. package/lib/device/migrate.mjs +1 -1
  42. package/lib/device/setup.mjs +45 -8
  43. package/lib/gateway/account-agent.mjs +111 -0
  44. package/lib/gateway/copy.mjs +381 -0
  45. package/lib/gateway/front-core.mjs +74 -63
  46. package/lib/gateway/gateway-core.mjs +107 -9
  47. package/lib/gateway/held-mail.mjs +198 -0
  48. package/lib/gateway/keeper-due.mjs +39 -0
  49. package/lib/gateway/keeper-session.mjs +10 -0
  50. package/lib/gateway/keeper.mjs +72 -0
  51. package/lib/gateway/masto-gateway.mjs +510 -0
  52. package/lib/gateway/quiet.mjs +7 -2
  53. package/lib/gateway/relay-extras.mjs +89 -0
  54. package/lib/gateway/state-api.mjs +207 -0
  55. package/lib/gateway/token-claims.mjs +16 -0
  56. package/lib/pod/containers.mjs +17 -0
  57. package/lib/pod/location.mjs +52 -0
  58. package/lib/pod/notes.mjs +2 -4
  59. package/lib/pod/transport.mjs +214 -24
  60. package/lib/pod/type-index.mjs +101 -0
  61. package/lib/pod/urls.mjs +6 -0
  62. package/lib/server/embed.mjs +7 -6
  63. package/lib/session/README.md +5 -5
  64. package/lib/session/demo.html +1 -1
  65. package/lib/session/fedi-account.mjs +19 -10
  66. package/lib/session/package.json +2 -2
  67. package/package.json +2 -2
  68. package/run-agent.mjs +6 -16
  69. package/scripts/stage-site.mjs +17 -4
  70. package/web/admin/actors.js +2 -0
  71. package/web/admin/gateway.js +14 -1
  72. package/web/admin/index.html +13 -0
  73. package/web/admin/oauth-signin.mjs +1 -1
  74. package/web/admin/record.js +4 -1
  75. package/web/admin/setup/index.html +17 -1
  76. package/web/admin/setup/setup.js +18 -5
  77. package/web/app/README.md +2 -2
  78. package/web/app/admin-facade.mjs +12 -2
  79. package/web/app/agent.mjs +228 -51
  80. package/web/app/boot.mjs +75 -32
  81. package/web/app/copy-mode.mjs +223 -0
  82. package/web/app/dist/boot.js +498 -88
  83. package/web/app/dist/boot.js.map +4 -4
  84. package/web/app/dist/sw.js +4016 -2539
  85. package/web/app/dist/sw.js.map +4 -4
  86. package/web/app/index.html +16 -0
  87. package/web/app/signup.mjs +65 -26
  88. package/web/app/sw-src.mjs +15 -53
  89. package/web/app/update.js +3 -2
  90. package/web/app/warm-start.mjs +115 -0
  91. package/web/app-signin/app-signin.mjs +78 -0
  92. package/web/app-signin/index.html +41 -0
  93. package/web/front/run.html +7 -1
  94. 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 client asked for its new document. It rides in the
137
- // receipt so the dispatcher can use it, and it is what lets the door answer a
138
- // Location before anything exists: the object will live at notesPrefix+slug
139
- // unless that name is taken, in which case the agent mints another.
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
- const slug = safeSlug(request.headers.get('slug'));
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
- return { status: 201, reason: 'accepted', location: slug && ident.notesPrefix ? ident.notesPrefix + slug : null };
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
+ }