fedipod 1.44.0 → 1.47.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 +8 -13
- package/architecture.md +5 -2
- package/gateway.md +56 -24
- package/gui.md +3 -2
- package/lib/client/c2s.mjs +23 -74
- package/lib/client/masto/index.mjs +5 -4
- package/lib/client/masto/render.mjs +4 -1
- package/lib/core/intake/activities.mjs +8 -9
- package/lib/core/intake/counts.mjs +143 -0
- package/lib/core/intake/index.mjs +12 -1
- package/lib/core/intake/received.mjs +78 -0
- package/lib/core/publisher/notes.mjs +4 -1
- package/lib/core/social.mjs +2 -0
- package/lib/core/storage.mjs +54 -5
- package/lib/core/wire.mjs +16 -5
- package/lib/gateway/account-agent.mjs +20 -4
- package/lib/gateway/copy.mjs +95 -5
- package/lib/gateway/front-core.mjs +18 -9
- package/lib/gateway/gateway-core.mjs +46 -14
- package/lib/gateway/held-mail.mjs +11 -4
- package/lib/gateway/httpsig.mjs +6 -1
- package/lib/gateway/keeper.mjs +9 -6
- package/lib/gateway/masto-gateway.mjs +140 -40
- package/lib/gateway/private-read.mjs +98 -0
- package/lib/gateway/quiet.mjs +4 -3
- package/lib/gateway/state-api.mjs +73 -18
- package/lib/pod/notes.mjs +25 -0
- package/lib/pod/urls.mjs +3 -0
- package/lib/server/embed.mjs +25 -0
- package/lib/surface/routes/owner.mjs +2 -2
- package/package.json +2 -2
- package/web/admin/gateway.js +8 -24
- package/web/admin/index.html +0 -16
- package/web/admin/record.js +1 -2
- package/web/admin/upkeep.js +1 -5
- package/web/app/admin-facade.mjs +1 -1
- package/web/app/agent.mjs +27 -10
- package/web/app/copy-mode.mjs +98 -13
- package/web/app/dist/boot.js +12 -3
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +582 -282
- package/web/app/dist/sw.js.map +4 -4
|
@@ -13,13 +13,21 @@
|
|
|
13
13
|
// before the owner stops the gateway keeping
|
|
14
14
|
// the account running
|
|
15
15
|
//
|
|
16
|
+
// The hold (copy.mjs): the round writes a person's copy to the pod and deletes
|
|
17
|
+
// it every fifteen minutes. A document asked for after that makes the copy
|
|
18
|
+
// again from the pod, and one asked for while it goes is answered 503, to be
|
|
19
|
+
// asked again in a moment. The copy's lease outlives it. With the hold off,
|
|
20
|
+
// no copy is made: /open answers 501, after writing to the pod and deleting
|
|
21
|
+
// any copy left from before, and the browser works on the pod.
|
|
22
|
+
//
|
|
16
23
|
// The token is the gateway's own, signed with a secret only it knows, and
|
|
17
24
|
// names the account and the WebID that asked; it lasts a day. The signing key
|
|
18
25
|
// and the passwords of accounts elsewhere are refused here: the copy does not
|
|
19
26
|
// hold them, and the browser reads them from the pod.
|
|
20
27
|
import crypto from 'node:crypto';
|
|
21
28
|
import { HttpStorage } from '../core/storage.mjs';
|
|
22
|
-
import { CopyStorage, copyMeta, fillCopy, dropCopy, forgetCopy, holds, lockCopy, kvDocFetch, podOnly, safeName, safeHandle, keptBefore
|
|
29
|
+
import { CopyStorage, copyMeta, fillCopy, dropCopy, forgetCopy, holds, lockCopy, kvDocFetch, podOnly, safeName, safeHandle, keptBefore, keptNow,
|
|
30
|
+
holdOn, holdOver, closing } from './copy.mjs';
|
|
23
31
|
|
|
24
32
|
const TOKEN_MS = 24 * 3600_000;
|
|
25
33
|
const b64 = (s) => Buffer.from(s).toString('base64url');
|
|
@@ -79,6 +87,14 @@ const noteFailure = (kv, handle, why = null) => kv.set(`${handle}/fill-failed-at
|
|
|
79
87
|
* credential has changed since. Returns { ok } or { ok: false, status, why }.
|
|
80
88
|
*/
|
|
81
89
|
export async function ensureCopy(ctx, handle, rec, log = console.log, { owner = false } = {}) {
|
|
90
|
+
if (await copyMeta(ctx.copyKv, handle)) return { ok: true };
|
|
91
|
+
const unlock = await lockCopy(ctx.copyKv, handle);
|
|
92
|
+
if (!unlock) return { ok: false, status: 503, why: 'the gateway is busy with this account; try again' };
|
|
93
|
+
try { return await makeCopy(ctx, handle, rec, log, { owner }); } finally { await unlock(); }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** ensureCopy, for a caller already holding lockCopy for the account. */
|
|
97
|
+
export async function makeCopy(ctx, handle, rec, log = console.log, { owner = false } = {}) {
|
|
82
98
|
if (await copyMeta(ctx.copyKv, handle)) return { ok: true };
|
|
83
99
|
// The owner too: it is the gateway's credential that has to change.
|
|
84
100
|
const refused = await loginRefused(ctx);
|
|
@@ -90,26 +106,41 @@ export async function ensureCopy(ctx, handle, rec, log = console.log, { owner =
|
|
|
90
106
|
}
|
|
91
107
|
const podFetch = await ctx.keeperFetch();
|
|
92
108
|
if (!podFetch) return { ok: false, status: 501, why: 'this gateway cannot reach pods for accounts' };
|
|
93
|
-
|
|
94
|
-
|
|
109
|
+
// Marked failed before it starts and cleared when it is done, so an attempt
|
|
110
|
+
// cut off part way (a request's time running out) counts as failed too.
|
|
111
|
+
await noteFailure(ctx.copyKv, handle);
|
|
112
|
+
const stateUrl = stateUrlOf(rec);
|
|
113
|
+
const made = await fillCopy(ctx.copyKv, handle, { pod: new HttpStorage(stateUrl, podFetch), podFetch, stateUrl, webId: rec.webId, log })
|
|
114
|
+
.catch((e) => ({ ok: false, why: e.message }));
|
|
115
|
+
if (made.ok) { await ctx.copyKv.delete(`${handle}/fill-failed-at`).catch(() => {}); return { ok: true }; }
|
|
116
|
+
await noteFailure(ctx.copyKv, handle, made.why);
|
|
117
|
+
log(`copy @${handle}: not made: ${made.why}`);
|
|
118
|
+
if (refusedSignIn(made.why)) {
|
|
119
|
+
await ctx.copyKv.set(LOGIN_REFUSED, JSON.stringify({ at: Date.now(), mark: ctx.keeperMark || '', why: made.why })).catch(() => {});
|
|
120
|
+
}
|
|
121
|
+
return { ok: false, status: /active on another device/.test(made.why) ? 409 : 502, why: made.why };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The end of the hold for one account now, as the round does it: the copy
|
|
126
|
+
* written to the pod and deleted. Returns { ok } or { ok: false, why }.
|
|
127
|
+
*/
|
|
128
|
+
export async function endHold(ctx, handle, rec, log = console.log, { waitMs = 8_000 } = {}) {
|
|
129
|
+
if (!await copyMeta(ctx.copyKv, handle)) return { ok: true };
|
|
130
|
+
const podFetch = await ctx.keeperFetch();
|
|
131
|
+
if (!podFetch) return { ok: false, why: 'this gateway cannot reach the pod now' };
|
|
132
|
+
const unlock = await lockCopy(ctx.copyKv, handle, { waitMs });
|
|
133
|
+
if (!unlock) return { ok: false, why: 'the gateway is busy with this account; try again' };
|
|
95
134
|
try {
|
|
96
|
-
if (await copyMeta(ctx.copyKv, handle)) return { ok: true };
|
|
97
|
-
// Marked failed before it starts and cleared when it is done, so an attempt
|
|
98
|
-
// cut off part way (a request's time running out) counts as failed too.
|
|
99
|
-
await noteFailure(ctx.copyKv, handle);
|
|
100
135
|
const stateUrl = stateUrlOf(rec);
|
|
101
|
-
|
|
102
|
-
.catch((e) => ({ ok: false, why: e.message }));
|
|
103
|
-
if (made.ok) { await ctx.copyKv.delete(`${handle}/fill-failed-at`).catch(() => {}); return { ok: true }; }
|
|
104
|
-
await noteFailure(ctx.copyKv, handle, made.why);
|
|
105
|
-
log(`copy @${handle}: not made: ${made.why}`);
|
|
106
|
-
if (refusedSignIn(made.why)) {
|
|
107
|
-
await ctx.copyKv.set(LOGIN_REFUSED, JSON.stringify({ at: Date.now(), mark: ctx.keeperMark || '', why: made.why })).catch(() => {});
|
|
108
|
-
}
|
|
109
|
-
return { ok: false, status: /active on another device/.test(made.why) ? 409 : 502, why: made.why };
|
|
136
|
+
return await holdOver(ctx.copyKv, handle, { pod: new HttpStorage(stateUrl, podFetch), podFetch, stateUrl, log });
|
|
110
137
|
} finally { await unlock(); }
|
|
111
138
|
}
|
|
112
139
|
|
|
140
|
+
// Whether the copy is made again when it is asked for: the hold on, and the
|
|
141
|
+
// account still kept here under this identity.
|
|
142
|
+
const remakes = (ctx, rec) => holdOn(ctx) && keptNow(ctx, rec) && !keptBefore(ctx, rec) && !rec.movedTo && !rec.closedAt;
|
|
143
|
+
|
|
113
144
|
/** Write the copy to the pod and give it up. Returns { ok } or { ok: false, why }. */
|
|
114
145
|
export async function leaveCopy(ctx, handle, rec, log = console.log) {
|
|
115
146
|
if (!await copyMeta(ctx.copyKv, handle)) return { ok: true };
|
|
@@ -155,6 +186,13 @@ export async function routeStateApi(request, pathname, ctx, deps) {
|
|
|
155
186
|
// owner's rules name it.
|
|
156
187
|
const before = keptBefore(ctx, rec);
|
|
157
188
|
if (before && !await copyMeta(ctx.copyKv, handle)) return j(409, { error: 'kept under the gateway\'s former identity', moving: true });
|
|
189
|
+
// The hold off: no copy. One left from before goes to the pod first, so
|
|
190
|
+
// the browser works on a pod that has everything.
|
|
191
|
+
if (!before && !holdOn(ctx)) {
|
|
192
|
+
const ended = await endHold(ctx, handle, rec).catch((e) => ({ ok: false, why: e.message }));
|
|
193
|
+
if (!ended.ok) return j(503, { error: `the account's copy could not be written to the pod: ${ended.why}` });
|
|
194
|
+
return j(501, { error: 'this gateway keeps no copies of accounts', hold: false });
|
|
195
|
+
}
|
|
158
196
|
const made = before ? { ok: true } : await ensureCopy(ctx, handle, rec, console.log, { owner: true });
|
|
159
197
|
if (!made.ok) return j(made.status, { error: made.why, busy: made.status === 409 });
|
|
160
198
|
const origin = new URL(request.url).origin;
|
|
@@ -177,15 +215,28 @@ export async function routeStateApi(request, pathname, ctx, deps) {
|
|
|
177
215
|
// by somebody else is not theirs any more.
|
|
178
216
|
const owned = await ctx.lookup(handle);
|
|
179
217
|
if (!owned || owned.webId !== who.webId) return j(401, { error: 'this token is not for this account' });
|
|
180
|
-
if (!await copyMeta(ctx.copyKv, handle)) return j(404, { error: 'the gateway keeps no copy of this account now', gone: true });
|
|
181
218
|
const kv = ctx.copyKv;
|
|
182
219
|
const noStore = { 'cache-control': 'no-store' };
|
|
220
|
+
const gone = () => j(404, { error: 'the gateway keeps no copy of this account now', gone: true });
|
|
183
221
|
|
|
222
|
+
// No copy, or none now, is nothing to give up.
|
|
184
223
|
if (name === 'leave') {
|
|
185
224
|
if (request.method !== 'POST') return j(405, { error: 'POST' });
|
|
186
225
|
const left = await leaveCopy(ctx, handle, owned).catch((e) => ({ ok: false, why: e.message }));
|
|
187
226
|
return left.ok ? j(200, { ok: true }) : j(502, { error: `the copy could not be written to the pod: ${left.why}` });
|
|
188
227
|
}
|
|
228
|
+
// The copy's lease outlives each copy the hold makes.
|
|
229
|
+
if (name === 'lease.json' && !await copyMeta(kv, handle) && !remakes(ctx, owned)) return gone();
|
|
230
|
+
// Being written to the pod and deleted: asked again in a moment, it is made anew.
|
|
231
|
+
const comeAgain = () => j(503, { error: 'the account\'s copy is being written to the pod; try again in a moment' });
|
|
232
|
+
if (name !== 'lease.json') {
|
|
233
|
+
if (await closing(kv, handle)) return comeAgain();
|
|
234
|
+
if (!await copyMeta(kv, handle)) {
|
|
235
|
+
if (!remakes(ctx, owned)) return gone();
|
|
236
|
+
const made = await ensureCopy(ctx, handle, owned, console.log, { owner: true });
|
|
237
|
+
if (!made.ok) return j(made.status === 409 ? 503 : made.status, { error: made.why });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
189
240
|
// The owner's browser has written the copy to the pod itself (a copy kept
|
|
190
241
|
// under the gateway's former identity, which cannot write it back now).
|
|
191
242
|
// Only the lease's holder may say so, and only for such a copy.
|
|
@@ -229,11 +280,15 @@ export async function routeStateApi(request, pathname, ctx, deps) {
|
|
|
229
280
|
const w = await copy.write(name, await request.text());
|
|
230
281
|
if (w.lost) return j(409, { error: w.why });
|
|
231
282
|
if (!w.ok) return j(503, { error: w.why });
|
|
283
|
+
// Landed as the round began to write the copy to the pod: perhaps after
|
|
284
|
+
// it read this document. Sent again, it lands in the copy made next.
|
|
285
|
+
if (await closing(kv, handle)) return comeAgain();
|
|
232
286
|
return { status: 204, headers: { ...noStore, etag: w.etag }, body: null };
|
|
233
287
|
}
|
|
234
288
|
if (request.method === 'DELETE') {
|
|
235
289
|
if (!holder) return j(400, { error: 'x-fedipod-holder is required' });
|
|
236
|
-
|
|
290
|
+
if (!await copy.remove(name)) return j(409, { error: 'another agent holds this account now' });
|
|
291
|
+
return (await closing(kv, handle)) ? comeAgain() : { status: 204, headers: noStore, body: null };
|
|
237
292
|
}
|
|
238
293
|
return j(405, { error: 'GET, PUT or DELETE' });
|
|
239
294
|
}
|
package/lib/pod/notes.mjs
CHANGED
|
@@ -44,6 +44,31 @@ export const writeReplies = (pod, repliesId, doc) => pod.putJson(repliesId, doc)
|
|
|
44
44
|
/** One published note, as the pod currently serves it. */
|
|
45
45
|
export const read = (pod, noteId) => pod.getJson(noteId);
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* A note as the pod serves it, with the version the pod gave it.
|
|
49
|
+
*
|
|
50
|
+
* For a write that must not undo somebody else's: an edit or a deletion that
|
|
51
|
+
* lands between this read and the write below wins, and the writer tries
|
|
52
|
+
* again later. A pod that names no version gets an unconditional write.
|
|
53
|
+
*/
|
|
54
|
+
export async function readVersioned(pod, noteId) {
|
|
55
|
+
if (typeof pod.fetch !== 'function') return { doc: await pod.getJson(noteId), version: null };
|
|
56
|
+
const res = await pod.fetch(noteId, { headers: { accept: 'application/activity+json' } });
|
|
57
|
+
if (res.status === 404 || res.status === 410) return { doc: null, version: null };
|
|
58
|
+
if (res.status >= 400) throw new Error(`GET ${noteId} → ${res.status}`);
|
|
59
|
+
return { doc: await res.json().catch(() => null), version: res.headers?.get?.('etag') || null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Write the note only over the version read. False when it changed meanwhile. */
|
|
63
|
+
export async function writeIfUnchanged(pod, noteId, doc, version) {
|
|
64
|
+
if (!version || typeof pod.fetch !== 'function') { await pod.putJson(noteId, doc); return true; }
|
|
65
|
+
const res = await pod.fetch(noteId, { method: 'PUT',
|
|
66
|
+
headers: { 'content-type': 'application/activity+json', 'if-match': version }, body: JSON.stringify(doc) });
|
|
67
|
+
if (res.status === 412) return false;
|
|
68
|
+
if (res.status >= 400) throw new Error(`PUT ${noteId} → ${res.status}`);
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
47
72
|
/**
|
|
48
73
|
* Replace a note with a Tombstone, and keep it readable.
|
|
49
74
|
*
|
package/lib/pod/urls.mjs
CHANGED
|
@@ -52,6 +52,9 @@ export function apUrls(remotePod, root, { publicBase = null } = {}) {
|
|
|
52
52
|
// reads them there, with a credential the pod checks.
|
|
53
53
|
ownOutbox: home + 'ap/private/outbox',
|
|
54
54
|
liked: home + 'ap/private/liked',
|
|
55
|
+
// The inbox as its owner reads it (§5.2): what the account received,
|
|
56
|
+
// kept by the drain as a paged collection beside the archive.
|
|
57
|
+
ownInbox: home + 'ap/private/inbox',
|
|
55
58
|
profileHtml: face + 'ap/profile.html',
|
|
56
59
|
// Media stays on the pod even when fronted: attachment urls are not
|
|
57
60
|
// identity-checked by remotes, and proxying blobs would be pure cost.
|
package/lib/server/embed.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { Agent } from '../core/agent.mjs';
|
|
|
20
20
|
import { RemotePod } from '../core/remote.mjs';
|
|
21
21
|
import { apUrls, DEFAULT_ROOT } from '../core/wire.mjs';
|
|
22
22
|
import { handleDelivery } from '../gateway/gateway-core.mjs';
|
|
23
|
+
import { signedReader, mayRead } from '../gateway/private-read.mjs';
|
|
23
24
|
import { writeJsonAtomic } from '../shared/files.mjs';
|
|
24
25
|
import { buildAdminSurface } from '../surface/index.mjs';
|
|
25
26
|
import { FixedAuthorities } from '../shared/guard.mjs';
|
|
@@ -260,6 +261,30 @@ export async function deliverToInbox(agent, request, { podPut, gatewayWebId = nu
|
|
|
260
261
|
return handleDelivery(request, ident, { podPut, fetchImpl });
|
|
261
262
|
}
|
|
262
263
|
|
|
264
|
+
/**
|
|
265
|
+
* A signed read of a followers-only or direct post at its address on this
|
|
266
|
+
* pod (ActivityPub §3.2), answered to a server the post was sent to. The
|
|
267
|
+
* rule is lib/gateway/private-read.mjs's; here the post is read through the
|
|
268
|
+
* server's own store and the followers come from the identity's live state.
|
|
269
|
+
* Returns { status, reason, body }: 200 with the document, else 404 with why.
|
|
270
|
+
*/
|
|
271
|
+
export async function readPrivateForSigner(agent, request, { read, fetchImpl = fetch } = {}) {
|
|
272
|
+
const u = agent.urls;
|
|
273
|
+
const toPod = (x) => (u.toPod ? u.toPod(x) : x);
|
|
274
|
+
const url = request.url.split('?')[0];
|
|
275
|
+
const { actor, reason } = await signedReader(request, { fetchImpl, origin: new URL(request.url).origin });
|
|
276
|
+
if (!actor) return { status: 404, reason: `refused — ${reason}` };
|
|
277
|
+
const text = await Promise.resolve().then(() => read(url)).catch(() => null);
|
|
278
|
+
let doc = null;
|
|
279
|
+
try { doc = text ? JSON.parse(text) : null; } catch { doc = null; }
|
|
280
|
+
if (!doc) return { status: 404, reason: `refused — nothing at ${url} (${actor})` };
|
|
281
|
+
const followers = (agent.store.getContacts().followers || []).map((f) => f?.actor).filter(Boolean);
|
|
282
|
+
if (!mayRead(doc, actor, { followersUrls: [u.followers, toPod(u.followers)], followers })) {
|
|
283
|
+
return { status: 404, reason: `refused — ${actor} is not in its audience` };
|
|
284
|
+
}
|
|
285
|
+
return { status: 200, reason: `served to ${actor}`, body: text };
|
|
286
|
+
}
|
|
287
|
+
|
|
263
288
|
/**
|
|
264
289
|
* Bring one identity up inside the server.
|
|
265
290
|
*
|
|
@@ -70,7 +70,7 @@ export async function get(p, ctx, req, res, url) { // eslint-disable-line no-u
|
|
|
70
70
|
image: cfg.image || null, fields: cfg.fields || [],
|
|
71
71
|
approveJoins: !!cfg.approveJoins, review: !!cfg.review,
|
|
72
72
|
aliases: cfg.aliases || [],
|
|
73
|
-
autoAcceptFollows:
|
|
73
|
+
autoAcceptFollows: cfg.autoAcceptFollows !== false,
|
|
74
74
|
// never the record itself
|
|
75
75
|
hasUiPassword: !!cfg.uiPassword,
|
|
76
76
|
quiescedAt: cfg.quiescedAt || null, movedTo: cfg.movedTo || null,
|
|
@@ -255,7 +255,7 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
|
|
|
255
255
|
name: cfg.name || null, summary: cfg.summary || null, icon: cfg.icon || null,
|
|
256
256
|
image: cfg.image || null, fields: cfg.fields || [],
|
|
257
257
|
approveJoins: !!cfg.approveJoins, review: !!cfg.review,
|
|
258
|
-
autoAcceptFollows:
|
|
258
|
+
autoAcceptFollows: cfg.autoAcceptFollows !== false,
|
|
259
259
|
...(isGroup() ? { moderators: cfg.moderators || [] } : {}),
|
|
260
260
|
hasUiPassword: !!cfg.uiPassword,
|
|
261
261
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.47.0",
|
|
4
4
|
"description": "Standalone single-actor ActivityPub agent whose wire face, RDF truth and state all live on a Solid pod (CSS). Bundles a Phanpy UI and a Mastodon client-API facade.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"install-service": "node bin/fedipod.mjs install-service",
|
|
56
56
|
"uninstall-service": "node bin/fedipod.mjs uninstall-service",
|
|
57
57
|
"prepublishOnly": "npm test",
|
|
58
|
-
"test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/copy-mode-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node packages/fedipod-bb/scripts/link-fedipod.mjs && node --test packages/fedipod-bb/test/*.test.mjs claude/smoke-tests/forum-signup-form.test.mjs && node scripts/check-sizes.mjs",
|
|
58
|
+
"test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/copy-mode-test.mjs && node claude/smoke-tests/hold-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node packages/fedipod-bb/scripts/link-fedipod.mjs && node --test packages/fedipod-bb/test/*.test.mjs claude/smoke-tests/forum-signup-form.test.mjs claude/smoke-tests/door-verify.test.mjs && node scripts/check-sizes.mjs && node claude/validation/browser-agent/all.mjs",
|
|
59
59
|
"verify-ui": "node scripts/verify-ui.mjs",
|
|
60
60
|
"build:sw": "node scripts/build-app.mjs --entry web/app/sw-src.mjs --out web/app/dist/sw.js",
|
|
61
61
|
"build:boot": "node scripts/build-app.mjs --entry web/app/boot.mjs --out web/app/dist/boot.js",
|
package/web/admin/gateway.js
CHANGED
|
@@ -19,43 +19,27 @@ async function refreshGateway() {
|
|
|
19
19
|
GW_OPEN_ATTACH.hidden = true;
|
|
20
20
|
GW_OPEN_DETACH.hidden = false;
|
|
21
21
|
// The account's standing at the gateway — paused, closed. The browser
|
|
22
|
-
// build's agent reports it; a DeviceAgent's does not, and the
|
|
23
|
-
//
|
|
22
|
+
// build's agent reports it; a DeviceAgent's does not, and the keep
|
|
23
|
+
// control stays hidden.
|
|
24
24
|
const st = g.standing;
|
|
25
25
|
const known = !!st && (st.status === 200 || st.status === 410);
|
|
26
|
-
$('gateway-pause').hidden = !known || !!st.closed || !!st.paused;
|
|
27
|
-
$('gateway-resume').hidden = !known || !!st.closed || !st.paused;
|
|
28
|
-
$('gateway-close').hidden = !known || !!st.closed;
|
|
29
26
|
if (known && st.closed) GATEWAY_WORD.textContent += ' — this address is closed';
|
|
30
27
|
else if (known && st.paused) GATEWAY_WORD.textContent += st.pausedBy === 'owner' ? ' — paused by you' : ' — paused';
|
|
31
|
-
//
|
|
28
|
+
// Kept running while FediPod is closed; offered only to an account that is not.
|
|
32
29
|
const kp = g.keeper;
|
|
33
30
|
$('gateway-keep-on').hidden = !kp || !known || !!st.closed || kp.on;
|
|
34
|
-
$('gateway-keep-off').hidden = !kp || !known || !!st.closed || !kp.on;
|
|
35
|
-
if (kp?.on && known && !st.closed) GATEWAY_WORD.textContent += ' — kept running while you are away';
|
|
36
31
|
} else {
|
|
37
32
|
GATEWAY_WORD.textContent = '';
|
|
38
33
|
GW_OPEN_ATTACH.hidden = false;
|
|
39
34
|
GW_OPEN_DETACH.hidden = true;
|
|
40
|
-
|
|
35
|
+
$('gateway-keep-on').hidden = true;
|
|
41
36
|
}
|
|
42
37
|
}
|
|
43
|
-
|
|
44
|
-
const r = await write('/gateway/
|
|
45
|
-
|
|
46
|
-
: 'resumed — posts sent to you are kept again');
|
|
38
|
+
$('gateway-keep-on').addEventListener('click', async () => {
|
|
39
|
+
const r = await write('/gateway/keep', { on: true },
|
|
40
|
+
'kept running — follows, deliveries to try again and scheduled posts are handled while FediPod is closed');
|
|
47
41
|
if (r) refreshGateway();
|
|
48
|
-
};
|
|
49
|
-
$('gateway-pause').addEventListener('click', () => setPausedAtGateway(true));
|
|
50
|
-
$('gateway-resume').addEventListener('click', () => setPausedAtGateway(false));
|
|
51
|
-
const setKept = async (on) => {
|
|
52
|
-
const r = await write('/gateway/keep', { on },
|
|
53
|
-
on ? 'kept running — follows, deliveries to try again and scheduled posts are handled while FediPod is closed'
|
|
54
|
-
: 'stopped — your account acts only while FediPod is open');
|
|
55
|
-
if (r) refreshGateway();
|
|
56
|
-
};
|
|
57
|
-
$('gateway-keep-on').addEventListener('click', () => setKept(true));
|
|
58
|
-
$('gateway-keep-off').addEventListener('click', () => setKept(false));
|
|
42
|
+
});
|
|
59
43
|
const gwShape = () => document.querySelector('input[name=gwShape]:checked')?.value || 'pod';
|
|
60
44
|
function gwPreviews() {
|
|
61
45
|
$('gw-pod-preview').textContent = config?.address || `@${config?.handle || 'you'}@your.pod`;
|
package/web/admin/index.html
CHANGED
|
@@ -340,16 +340,8 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
|
|
|
340
340
|
title=" Go back to your pod's own inbox">Detach from gateway</button>
|
|
341
341
|
<!-- Shown only when the agent reports the account's standing at the gateway
|
|
342
342
|
(the browser build does; a DeviceAgent's admin does not). -->
|
|
343
|
-
<button type="button" id="gateway-pause" class="inline" hidden
|
|
344
|
-
title=" Stop keeping the posts sent to you until you resume; follows still arrive">Pause my account</button>
|
|
345
|
-
<button type="button" id="gateway-resume" class="inline" hidden
|
|
346
|
-
title=" Keep the posts sent to you again">Resume my account</button>
|
|
347
343
|
<button type="button" id="gateway-keep-on" class="inline" hidden
|
|
348
344
|
title=" Let this gateway accept follows, try again what failed to go out and publish scheduled posts while FediPod is closed">Keep my account running while I'm away</button>
|
|
349
|
-
<button type="button" id="gateway-keep-off" class="inline" hidden
|
|
350
|
-
title=" Act only while FediPod is open; the rule naming this gateway comes off your pod">Stop keeping it running</button>
|
|
351
|
-
<button type="button" id="gateway-close" class="inline danger" hidden data-confirm="close-address"
|
|
352
|
-
title=" Close your address at this gateway for good; nothing on your pod is touched">Close this address</button>
|
|
353
345
|
</span>
|
|
354
346
|
|
|
355
347
|
<section id="pane-group" hidden>
|
|
@@ -545,14 +537,6 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
|
|
|
545
537
|
<label for="confirm-handle">Type the handle to confirm</label>
|
|
546
538
|
<input type="text" id="confirm-handle" autocomplete="off">
|
|
547
539
|
</div>
|
|
548
|
-
<div id="warn-close-address" class="warn" hidden>
|
|
549
|
-
<p><strong>This cannot be undone.</strong> Your address at the gateway is closed for good:
|
|
550
|
-
mail to it is turned away, other servers drop the account the next time they look, and
|
|
551
|
-
nobody can take the name. Nothing on your pod is touched — your posts, key and data stay
|
|
552
|
-
where they are.</p>
|
|
553
|
-
<label for="confirm-handle-close">Type the handle to confirm</label>
|
|
554
|
-
<input type="text" id="confirm-handle-close" autocomplete="off">
|
|
555
|
-
</div>
|
|
556
540
|
<!-- "Move private data" (POST /state-move) had a panel here and no way to
|
|
557
541
|
open it: nothing in this page has ever carried data-confirm="move-state",
|
|
558
542
|
so the panel was only ever hidden. Removed 2026-09-09 rather than left as
|
package/web/admin/record.js
CHANGED
|
@@ -94,7 +94,6 @@ function resetConfirm() {
|
|
|
94
94
|
for (const k of Object.keys(LIFECYCLE)) $(`warn-${k}`)?.hidden !== undefined && ($(`warn-${k}`).hidden = true);
|
|
95
95
|
$('confirm-handle').value = '';
|
|
96
96
|
$('confirm-handle-move').value = '';
|
|
97
|
-
$('confirm-handle-close').value = '';
|
|
98
97
|
$('move-target').value = '';
|
|
99
98
|
}
|
|
100
99
|
function closePanels(keep = null) {
|
|
@@ -325,7 +324,7 @@ function render() {
|
|
|
325
324
|
dd.textContent = '';
|
|
326
325
|
dd.append(FOLLOWS_CTL);
|
|
327
326
|
FOLLOWS_CTL.hidden = false;
|
|
328
|
-
FOLLOWS_PICK.value = config.autoAcceptFollows ? 'auto' : 'approve';
|
|
327
|
+
FOLLOWS_PICK.value = config.autoAcceptFollows !== false ? 'auto' : 'approve';
|
|
329
328
|
}
|
|
330
329
|
if (k === 'gateway') {
|
|
331
330
|
dd.textContent = '';
|
package/web/admin/upkeep.js
CHANGED
|
@@ -76,9 +76,6 @@ const LIFECYCLE = {
|
|
|
76
76
|
retire: { path: '/retire', title: 'Retire this identity', go: 'Retire it', danger: true, done: (r) => `retired ${r.deletedAt}: Delete delivered to ${r.inboxes} inbox(es)` },
|
|
77
77
|
move: { path: '/move', title: 'Transfer this account away', go: 'Transfer it', focus: 'move-target',
|
|
78
78
|
done: (r) => `transferred to ${r.target}: Move delivered to ${r.inboxes} inbox(es), unfollowed ${r.unfollowed}/${r.following}` },
|
|
79
|
-
// The address at the gateway, not the identity: the pod keeps everything.
|
|
80
|
-
'close-address': { path: '/gateway/close', title: 'Close this address', go: 'Close it', danger: true, focus: 'confirm-handle-close',
|
|
81
|
-
done: (r) => (r.closed ? 'closed — this address is gone for good; everything on your pod is untouched' : 'not closed') },
|
|
82
79
|
};
|
|
83
80
|
let pending = null;
|
|
84
81
|
|
|
@@ -122,8 +119,7 @@ $('confirm-form').addEventListener('submit', async (ev) => {
|
|
|
122
119
|
const what = pending;
|
|
123
120
|
const body = what === 'retire' ? { confirm: $('confirm-handle').value.trim() }
|
|
124
121
|
: what === 'move' ? { target: $('move-target').value.trim(), confirm: $('confirm-handle-move').value.trim() }
|
|
125
|
-
:
|
|
126
|
-
: {};
|
|
122
|
+
: {};
|
|
127
123
|
if (what === 'move' && !body.target) { say('name the account to transfer to', 'err'); return; }
|
|
128
124
|
$('confirm-go').disabled = true;
|
|
129
125
|
say(`${what} — this talks to the pod and to other servers, so it takes a moment`);
|
package/web/app/admin-facade.mjs
CHANGED
|
@@ -120,7 +120,7 @@ export class AdminFacade {
|
|
|
120
120
|
name: cfg.name || null, summary: cfg.summary || null, icon: cfg.icon || null,
|
|
121
121
|
image: cfg.image || null, fields: cfg.fields || [],
|
|
122
122
|
aliases: cfg.aliases || [],
|
|
123
|
-
autoAcceptFollows:
|
|
123
|
+
autoAcceptFollows: cfg.autoAcceptFollows !== false,
|
|
124
124
|
hasUiPassword: !!cfg.uiPassword,
|
|
125
125
|
quiescedAt: cfg.quiescedAt || null, movedTo: cfg.movedTo || null,
|
|
126
126
|
mode: a.status().mode,
|
package/web/app/agent.mjs
CHANGED
|
@@ -13,7 +13,6 @@ import { findAccount } from '../../lib/core/place.mjs';
|
|
|
13
13
|
import * as containers from '../../lib/pod/containers.mjs';
|
|
14
14
|
import * as podState from '../../lib/pod/state.mjs';
|
|
15
15
|
import { PodStore } from '../../lib/core/store.mjs';
|
|
16
|
-
import { HttpStorage } from '../../lib/core/storage.mjs';
|
|
17
16
|
import { Publisher } from '../../lib/core/publisher/index.mjs';
|
|
18
17
|
import { Intake } from '../../lib/core/intake/index.mjs';
|
|
19
18
|
import { C2S } from '../../lib/client/c2s.mjs';
|
|
@@ -34,7 +33,8 @@ import { AcctFeed } from '../../lib/connections/acctfeed.mjs';
|
|
|
34
33
|
import { followActor, unfollowActor, resolveHandle } from '../../lib/core/social.mjs';
|
|
35
34
|
import { podBaseOfWebId } from '../../lib/pod/urls.mjs';
|
|
36
35
|
import { ImportWorker } from '../../lib/connections/import.mjs';
|
|
37
|
-
import { openCopy, copyStorage, copyLeaseOf, moveIntoCopy, leaveCopy, renewCopyToken, standDown, ensureCopyLease, handOverCopy, forgetFailedOpen
|
|
36
|
+
import { openCopy, copyStorage, copyLeaseOf, moveIntoCopy, leaveCopy, renewCopyToken, standDown, ensureCopyLease, handOverCopy, forgetFailedOpen,
|
|
37
|
+
podStorageOf, fencePod, followHold } from './copy-mode.mjs';
|
|
38
38
|
|
|
39
39
|
// The authorities this identity answers on: exactly one, this origin. The Node
|
|
40
40
|
// agent gets this from lib/guard.mjs, which is not in the browser bundle and
|
|
@@ -129,13 +129,19 @@ export class BrowserAgent {
|
|
|
129
129
|
|
|
130
130
|
configured() { return !!this.store?.getConfig(); }
|
|
131
131
|
|
|
132
|
+
// Whether the gateway acts on this account's state too: its copy there, or
|
|
133
|
+
// with the gateway's hold off, its pod (copy-mode.mjs). An action here then
|
|
134
|
+
// makes sure of the lease first (requestTakeover).
|
|
135
|
+
get sharesState() { return !!(this.copy || this._fenced); }
|
|
136
|
+
|
|
132
137
|
// Asked by every write / destructive path before it acts. If we already hold
|
|
133
138
|
// the lease, proceed. If we are a viewer, the owner acting HERE outranks the
|
|
134
139
|
// idle active device: claim the lease outright and become active.
|
|
135
140
|
async requestTakeover() {
|
|
136
|
-
// Working from the copy,
|
|
137
|
-
// since this browser last looked: asked
|
|
138
|
-
|
|
141
|
+
// Working from the copy, or on the pod with the hold off, an app at the
|
|
142
|
+
// gateway may have taken the lease since this browser last looked: asked
|
|
143
|
+
// now, before acting (copy-mode.mjs).
|
|
144
|
+
if (!this.viewer && this.sharesState) await ensureCopyLease(this);
|
|
139
145
|
if (!this.viewer) return true;
|
|
140
146
|
if (!(await this.lease.takeover())) return false;
|
|
141
147
|
clearTimeout(this._viewerTimer); this._viewerTimer = null;
|
|
@@ -182,6 +188,7 @@ export class BrowserAgent {
|
|
|
182
188
|
const said = await this.tellGateway('keeper', { handle: this.doorKey, on: false });
|
|
183
189
|
if (said?.status !== 200) return said || { status: 502 };
|
|
184
190
|
this._keeper.kept = false;
|
|
191
|
+
fencePod(this);
|
|
185
192
|
if (this.masto) this.masto.scheduling = false;
|
|
186
193
|
}
|
|
187
194
|
this.remote.keepers = on ? [webId] : [];
|
|
@@ -194,6 +201,7 @@ export class BrowserAgent {
|
|
|
194
201
|
// A copy that could not be had while the account was not kept may be now.
|
|
195
202
|
await forgetFailedOpen(this, this.frontOrigin);
|
|
196
203
|
this._keeper.kept = true;
|
|
204
|
+
fencePod(this);
|
|
197
205
|
if (this.masto) this.masto.scheduling = true;
|
|
198
206
|
this.log('the gateway keeps this account running while the app is closed');
|
|
199
207
|
}
|
|
@@ -211,6 +219,10 @@ export class BrowserAgent {
|
|
|
211
219
|
async hereAtGateway() {
|
|
212
220
|
const said = await this.tellGateway('here', { handle: this.doorKey, nextAt: this.store ? nextDue(this.store) : null });
|
|
213
221
|
if (said?.status === 200) saveMeta(this.webId, { hereAt: Date.now() }).catch(() => {});
|
|
222
|
+
// The gateway's admin may have turned the hold off or on (copy-mode.mjs).
|
|
223
|
+
if (said?.status === 200 && typeof said.hold === 'boolean') {
|
|
224
|
+
await followHold(this, this.frontOrigin, said.hold).catch((e) => this.log(`the gateway's hold: ${e.message}`));
|
|
225
|
+
}
|
|
214
226
|
return said;
|
|
215
227
|
}
|
|
216
228
|
|
|
@@ -244,9 +256,10 @@ export class BrowserAgent {
|
|
|
244
256
|
// Reading here is being here: the gateway hears so once an hour.
|
|
245
257
|
clearInterval(this._openTimer);
|
|
246
258
|
this._openTimer = setInterval(() => { this.checkInAtGateway(); }, BrowserAgent.OPEN_EVERY_MS);
|
|
247
|
-
// Working from the copy,
|
|
248
|
-
// it acts, and this browser takes it
|
|
249
|
-
|
|
259
|
+
// Working from the copy, or on the pod with the hold off, an app at the
|
|
260
|
+
// gateway takes the lease only while it acts, and this browser takes it
|
|
261
|
+
// back (copy-mode.mjs).
|
|
262
|
+
this.lease.onLost = () => (this.sharesState ? standDown(this) : this.demote());
|
|
250
263
|
this.lease.startRenewal();
|
|
251
264
|
try {
|
|
252
265
|
// Forced, not revalidated, when this device WATCHED first: the active
|
|
@@ -441,7 +454,8 @@ export class BrowserAgent {
|
|
|
441
454
|
// the deletion deny-list that every other write on this agent observes.
|
|
442
455
|
// The Node agent has always passed remote.fetch here.
|
|
443
456
|
const podFetch = (u, i) => this.remote.fetch(u, i);
|
|
444
|
-
|
|
457
|
+
// Fenced once the gateway is known to act on the pod too (fencePod below).
|
|
458
|
+
const podStateStorage = podStorageOf(this);
|
|
445
459
|
this.store = new PodStore({ storage: this.copy ? copyStorage(this, podStateStorage) : podStateStorage, log: this.log });
|
|
446
460
|
keepInStep(this.store, webId, this.log);
|
|
447
461
|
// Single-active-agent lease (lib/lease.mjs): the inbox drain is a
|
|
@@ -545,8 +559,11 @@ export class BrowserAgent {
|
|
|
545
559
|
// The gateway's own pod identity, when it can act for this account while
|
|
546
560
|
// the app is closed (lib/gateway/keeper.mjs). Unless the owner turned that
|
|
547
561
|
// off, every rule this agent writes names it beside the owner.
|
|
548
|
-
|
|
562
|
+
// `hold`: whether it keeps a copy of the account at all (copy-mode.mjs).
|
|
563
|
+
this._keeper = standing?.keeper
|
|
564
|
+
? { webId: standing.keeper, kept: !!standing.kept, keptBy: standing.keptBy || null, hold: standing.hold !== false } : null;
|
|
549
565
|
if (this._keeper && !this.store.getConfig()?.keeperOff) this.remote.keepers = [this._keeper.webId];
|
|
566
|
+
fencePod(this);
|
|
550
567
|
|
|
551
568
|
this.publisher = new Publisher({
|
|
552
569
|
config: this.store.getConfig(), remote: this.remote, store: this.store,
|