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.
@@ -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}`));
@@ -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
- constructor(kv, handle, { holder, pod = null, onRefused = null, podNames = null }) {
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.h, this.holder)) return null;
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 });
@@ -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.42.0",
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: same profiles
56
- // directory, same unit, same startup — a different thing to run. Its
57
- // credential says so, and running the agent over it would publish a person
58
- // where a forum lives.
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
- forum = await import('./packages/fedipod-bb/src/run.mjs');
64
- } catch (e) {
65
- if (e?.code !== 'ERR_MODULE_NOT_FOUND') throw e;
66
- }
67
- if (forum?.isForumHome(home)) {
68
- console.log(`[bb:${port}] this home holds a forum — running it`);
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'));