fedipod 1.42.0 → 1.43.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/lib/core/deliver.mjs +11 -1
- package/lib/core/intake/group.mjs +10 -11
- package/lib/gateway/copy.mjs +5 -2
- package/lib/gateway/front-core.mjs +10 -1
- package/lib/gateway/gateway-core.mjs +1 -1
- package/lib/gateway/held-mail.mjs +3 -0
- package/lib/gateway/keeper.mjs +3 -0
- package/package.json +2 -2
- package/run-agent.mjs +10 -15
- package/scripts/check-netlify-bundles.mjs +4 -0
package/lib/core/deliver.mjs
CHANGED
|
@@ -61,9 +61,14 @@ export class Deliverer {
|
|
|
61
61
|
// passive: signing-only (viewer-mode agents) — no queue drain timer, so a
|
|
62
62
|
// read-only agent never mutates shared delivery state. startQueue() flips
|
|
63
63
|
// it live when a viewer is promoted to active.
|
|
64
|
+
// defer: nothing is sent now; every delivery goes on the queue, signed, for
|
|
65
|
+
// drainQueue() to make. For a process with seconds to answer — a door
|
|
66
|
+
// placing a post as it lands — which records what to send and leaves the
|
|
67
|
+
// sending to the run it starts.
|
|
64
68
|
constructor({ store, keyId, rsaPrivate, actorId = null, edPrivate = null, proofKeyId = null,
|
|
65
|
-
log = console.log, passive = false, onGone = null }) {
|
|
69
|
+
log = console.log, passive = false, defer = false, onGone = null }) {
|
|
66
70
|
this.store = store;
|
|
71
|
+
this.defer = defer;
|
|
67
72
|
// Called after followers at a gone inbox are dropped: the agent republishes
|
|
68
73
|
// its followers collection.
|
|
69
74
|
this.onGone = onGone;
|
|
@@ -205,6 +210,7 @@ export class Deliverer {
|
|
|
205
210
|
// Proved before anything else, so the copy that goes on the queue is the
|
|
206
211
|
// copy that was signed — a retry days later must not post a bare activity.
|
|
207
212
|
const signed = await this.proofed(activity);
|
|
213
|
+
if (this.defer) { this._enqueue({ inbox, activity: signed, attempts: 0, nextAt: Date.now() }); return; }
|
|
208
214
|
if (this._queueIfCooling(inbox, signed)) return;
|
|
209
215
|
const [result] = await this.deliverManyNow([{ inbox, activity: signed }]);
|
|
210
216
|
await this._settle(inbox, signed, result);
|
|
@@ -273,6 +279,10 @@ export class Deliverer {
|
|
|
273
279
|
// by the next.
|
|
274
280
|
const targets = [...new Set(inboxes)].filter((inbox) => !this._blockedInbox(inbox))
|
|
275
281
|
.map((inbox) => ({ inbox, activity: signed }));
|
|
282
|
+
if (this.defer) {
|
|
283
|
+
for (const t of targets) this._enqueue({ inbox: t.inbox, activity: signed, attempts: 0, nextAt: Date.now() });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
276
286
|
for (let i = 0; i < targets.length; i += this.batchSize) {
|
|
277
287
|
const chunk = targets.slice(i, i + this.batchSize).filter((t) => !this._queueIfCooling(t.inbox, signed));
|
|
278
288
|
if (!chunk.length) continue;
|
|
@@ -171,6 +171,16 @@ export async function amplify(intake, noteId, { approved = false, activity = nul
|
|
|
171
171
|
serial: intake.serial++,
|
|
172
172
|
audience: intake.urls.actor,
|
|
173
173
|
});
|
|
174
|
+
// What the group is about to carry is what the group's conversation holds.
|
|
175
|
+
// A forum places the post in its topic here, BEFORE the carry: the topic is
|
|
176
|
+
// what the category vouches for (FEP-7888), and a place that will not take
|
|
177
|
+
// the post — a locked topic — answers false and nothing is carried. Both
|
|
178
|
+
// the Announce and what its author sent: a forum reads the author's own
|
|
179
|
+
// activity — the name they gave the topic they were opening is on that one.
|
|
180
|
+
if (intake.onCarry) {
|
|
181
|
+
const placed = await intake.onCarry({ noteId, actor: s.actor, activity: act, sent: activity });
|
|
182
|
+
if (placed === false) { intake.log(`not amplified — ${noteId} has no place here`); return; }
|
|
183
|
+
}
|
|
174
184
|
await intake.deliverer.deliverToAll(inboxes, act);
|
|
175
185
|
// Marked carried before recorded: a failed outbox write costs one missing
|
|
176
186
|
// entry, a failed status write would carry the same post twice.
|
|
@@ -180,17 +190,6 @@ export async function amplify(intake, noteId, { approved = false, activity = nul
|
|
|
180
190
|
if (!intake.config.private) await intake.publisher.recordOutbox(act);
|
|
181
191
|
intake.store.setPending(intake.store.getPending().filter(p => p.noteId !== noteId));
|
|
182
192
|
intake.log(`amplified ${noteId} → ${inboxes.length} inbox(es)`);
|
|
183
|
-
// What the group carried is what the group's conversation holds. A forum
|
|
184
|
-
// places the post in its topic here — after the carry, so only what the
|
|
185
|
-
// category vouched for is in the topic (FEP-7888 ownership). Its failure
|
|
186
|
-
// never undoes the carry.
|
|
187
|
-
if (intake.onCarried) {
|
|
188
|
-
// Both: what the group said (the Announce) and what its author sent. A
|
|
189
|
-
// forum reads the author's own activity — the name they gave the topic
|
|
190
|
-
// they were opening is on that one, and on no other.
|
|
191
|
-
await intake.onCarried({ noteId, actor: s.actor, activity: act, sent: activity })
|
|
192
|
-
.catch(e => intake.log(`after the carry of ${noteId}: ${e.message}`));
|
|
193
|
-
}
|
|
194
193
|
// The same carry, shown natively to the group's Bluesky followers.
|
|
195
194
|
await intake.bskyGroup?.mirrorCarry(s)
|
|
196
195
|
.catch(e => intake.log(`bluesky mirror of the carry failed: ${e.message}`));
|
package/lib/gateway/copy.mjs
CHANGED
|
@@ -149,9 +149,12 @@ export async function holds(kv, h, holder) {
|
|
|
149
149
|
export class CopyStorage {
|
|
150
150
|
// `podNames`: which of the pod-only documents to list (all, by default). The
|
|
151
151
|
// gateway's own agents need only the key.
|
|
152
|
-
|
|
152
|
+
// `fence`: the copy whose lease decides who may write this one. A forum's
|
|
153
|
+
// categories are fenced by the forum's copy, since one lease runs the forum.
|
|
154
|
+
constructor(kv, handle, { holder, pod = null, onRefused = null, podNames = null, fence = null }) {
|
|
153
155
|
this.kv = kv;
|
|
154
156
|
this.h = account(handle);
|
|
157
|
+
this.fence = fence ? account(fence) : this.h;
|
|
155
158
|
this.holder = holder;
|
|
156
159
|
this.pod = pod;
|
|
157
160
|
this.onRefused = onRefused;
|
|
@@ -186,7 +189,7 @@ export class CopyStorage {
|
|
|
186
189
|
|
|
187
190
|
// A refusal is an answer, not a hiccup: the lease is someone else's.
|
|
188
191
|
async _fenced() {
|
|
189
|
-
if (await holds(this.kv, this.
|
|
192
|
+
if (await holds(this.kv, this.fence, this.holder)) return null;
|
|
190
193
|
this.onRefused?.();
|
|
191
194
|
return { ok: false, retry: false, why: 'another agent holds this account now', lost: true };
|
|
192
195
|
}
|
|
@@ -813,13 +813,22 @@ async function route(request, ctx) {
|
|
|
813
813
|
const standing = await accountState(ctx, up.handle, rec);
|
|
814
814
|
// A browser account's mail waits here while its app is closed (held-mail.mjs).
|
|
815
815
|
const held = holdsMail(ctx, rec) && !(await isPresent(ctx, up.handle));
|
|
816
|
-
const { status, reason, content, bytes, type, notifies } = await handleDelivery(request, identFor(rec, policy),
|
|
816
|
+
const { status, reason, content, bytes, type, notifies, name, raw, receipt } = await handleDelivery(request, identFor(rec, policy),
|
|
817
817
|
{ podPut: held ? holdingPut(ctx, up.handle, rec) : (u, b, ct) => ctx.podPut(up.handle, u, b, ct),
|
|
818
818
|
fetchImpl: ctx.fetchImpl, paused: standing.paused });
|
|
819
819
|
// One line per delivery, so "did it arrive at the door" has an answer.
|
|
820
820
|
console.log(`door @${up.handle}: delivery → ${status} (${reason}${held && status === 202 ? ', held' : ''})`);
|
|
821
821
|
if (status === 202 && content) await noteReceived(ctx, up.handle, rec, bytes);
|
|
822
822
|
if (held && status === 202) await afterHeld(ctx, up.handle, rec, { type, notifies });
|
|
823
|
+
// A forum fedipod.net keeps: the delivery is placed now, in this request,
|
|
824
|
+
// and the run that carries it is started. Whatever could not be placed
|
|
825
|
+
// here is still in the inbox for that run.
|
|
826
|
+
if (status === 202 && name && ctx.forum?.isForumRow(rec) && rec.keeper) {
|
|
827
|
+
const placed = await ctx.forum.placeAtDoor(ctx, up.handle, rec, { name, raw, receipt })
|
|
828
|
+
.catch((e) => ({ placed: false, why: e.message }));
|
|
829
|
+
console.log(`door @${up.handle}: ${placed.placed ? 'placed' : `not placed (${placed.why})`}`);
|
|
830
|
+
if (placed.forum && ctx.startKeeper) await ctx.startKeeper(placed.forum).catch((e) => console.log(`keeper @${placed.forum}: not started (${e.message})`));
|
|
831
|
+
}
|
|
823
832
|
return { status, headers: {}, body: '' };
|
|
824
833
|
}
|
|
825
834
|
|
|
@@ -138,7 +138,7 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
|
|
|
138
138
|
if (!okA) return { status: 502, reason: 'pod inbox write failed' };
|
|
139
139
|
if (receipt) await inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt);
|
|
140
140
|
return { status: 202, reason: v.verified ? 'verified' : 'buffered-unverified', content, bytes: Buffer.byteLength(raw),
|
|
141
|
-
type: activity.type, notifies: notifies(activity, ident) };
|
|
141
|
+
type: activity.type, notifies: notifies(activity, ident), name: hash, raw, receipt };
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
// The outbox door: the owner's own post, taken on their behalf.
|
|
@@ -166,6 +166,9 @@ export async function routeKeeperApi(request, pathname, ctx, deps) {
|
|
|
166
166
|
const next = { ...rec };
|
|
167
167
|
if (on) next.keeper = { webId: ctx.keeperWebId, at: new Date().toISOString() };
|
|
168
168
|
else delete next.keeper;
|
|
169
|
+
// A forum's category names the forum it belongs to, so a delivery to the
|
|
170
|
+
// category finds the forum's copy and lease.
|
|
171
|
+
if (typeof body.forum === 'string' && /^[a-z0-9][a-z0-9._-]{0,60}$/u.test(body.forum)) next.forum = body.forum;
|
|
169
172
|
await ctx.putDirectory(handle, next);
|
|
170
173
|
console.log(`keeper @${handle}: ${on ? 'on' : 'off'}, at the owner's word`);
|
|
171
174
|
return j(200, { ok: true, handle, kept: on, keeper: ctx.keeperWebId || null });
|
package/lib/gateway/keeper.mjs
CHANGED
|
@@ -33,6 +33,9 @@ export { keeperSession };
|
|
|
33
33
|
* drain reads it, as before.
|
|
34
34
|
*/
|
|
35
35
|
export async function keepOnce(ctx, handle, rec, { log = console.log, session = null } = {}) {
|
|
36
|
+
// A forum, or one of its categories, is run whole by the forum's own code
|
|
37
|
+
// (ctx.forum: the forum package's gateway module), not by an account's.
|
|
38
|
+
if (ctx.forum?.isForumRow(rec)) return ctx.forum.keepOnce(ctx, handle, rec, { log, session });
|
|
36
39
|
const say = (m) => log(`keeper @${handle}: ${m}`);
|
|
37
40
|
const at = await reachAccount(ctx, handle, rec, { log, session });
|
|
38
41
|
if (at.skipped) return { skipped: at.skipped };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.43.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 --test packages/fedipod-bb/test/*.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/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 && node scripts/check-sizes.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/run-agent.mjs
CHANGED
|
@@ -52,22 +52,17 @@ export async function startAgent({
|
|
|
52
52
|
handle = null,
|
|
53
53
|
} = {}) {
|
|
54
54
|
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
55
|
-
// A home can hold a FORUM rather than a person or a group
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
// The forum package is beside this file in a checkout and absent from an
|
|
60
|
-
// installed fedipod; a home that holds a forum needs it, a person's does not.
|
|
61
|
-
let forum = null;
|
|
55
|
+
// A home can hold a FORUM rather than a person or a group; its credential
|
|
56
|
+
// says so. A forum is run by its own package (`fedipod-bb start`), and
|
|
57
|
+
// running this agent over its home would publish a person where a forum
|
|
58
|
+
// lives.
|
|
62
59
|
try {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return forum.runForum({ home, port, log: (...a) => console.log(`[bb:${port}]`, ...a) });
|
|
70
|
-
}
|
|
60
|
+
const cred = JSON.parse(fs.readFileSync(path.join(home, 'credential.json'), 'utf8'));
|
|
61
|
+
if (String(cred.root || '').replace(/\/$/u, '') === 'fedipod-bb') {
|
|
62
|
+
console.error(`${home} holds a forum — run it with: fedipod-bb start --home ${home}`);
|
|
63
|
+
process.exit(2);
|
|
64
|
+
}
|
|
65
|
+
} catch { /* no credential yet, or not a forum */ }
|
|
71
66
|
// connect() sets this from pod state a moment later, but the browser may
|
|
72
67
|
// already be opening — seed it from what setup recorded so the named origin
|
|
73
68
|
// works from the first request.
|
|
@@ -18,6 +18,10 @@ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fn-bundles-'));
|
|
|
18
18
|
let fails = 0;
|
|
19
19
|
const check = (ok, msg) => { console.log(`${ok ? 'PASS' : 'FAIL'} ${msg}`); if (!ok) fails++; };
|
|
20
20
|
try {
|
|
21
|
+
// The forum the front names (netlify/forum-support.mjs) imports fedipod by
|
|
22
|
+
// name; in this checkout that must be the checkout itself, or the bundle
|
|
23
|
+
// carries a registry copy beside the Gateway's own code.
|
|
24
|
+
execFileSync(process.execPath, [path.join(root, 'packages/fedipod-bb/scripts/link-fedipod.mjs')], { cwd: root, stdio: 'inherit' });
|
|
21
25
|
execFileSync('netlify', ['functions:build', '--src', path.join(root, 'netlify/functions'), '--functions', path.join(tmp, 'zips')],
|
|
22
26
|
{ cwd: root, stdio: 'ignore' });
|
|
23
27
|
const manifest = JSON.parse(fs.readFileSync(path.join(tmp, 'zips/manifest.json'), 'utf8'));
|