fedipod 1.29.2 → 1.32.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 CHANGED
@@ -72,7 +72,7 @@ client-to-server, dokieli for one, can post as you. It sends to the outbox
72
72
  address in your actor document, which your WebID profile also names, signed in
73
73
  at your pod. The post goes out the next time you open fedipod.net.
74
74
 
75
- **The manage page.** `manage account` in the bar opens it: your profile,
75
+ **The manage page.** `manage`, in the bar's account group, opens it: your profile,
76
76
  aliases, the gateway, key rotation, recovering posts, parking, moving to
77
77
  another server, retiring, and clearing a backlog. It is the same interface
78
78
  the DeviceAgent has, described in [the admin interface](gui.md).
package/device-agent.md CHANGED
@@ -10,18 +10,25 @@ connect to it; and it can host a [group](groups.md).
10
10
  ## Requirements
11
11
 
12
12
  - Node 20 or newer.
13
- <!-- CLAUDE 2026-09-23 — any pod; delete these markers when done -->
14
13
  - A Solid pod, such as `https://alice.solidcommunity.net/` or
15
14
  `https://server.example/alice/`. The address is `@handle@yourpod`, or
16
15
  `@handle@fedipod.net` when the pod is on a path of a shared host, since
17
16
  the shared host cannot answer for the handle; the posts, key and data
18
17
  stay on the pod either way.
19
- <!-- /CLAUDE -->
20
18
  - Followers-only and direct posts need a pod that enforces WAC access control;
21
19
  on one that does not, the composer refuses those two and says why.
22
20
  - While the agent is off, your mail waits on your pod's host. Run it as a
23
21
  service, or attach to a gateway, so it does not pile up there.
24
22
 
23
+ Moving in from another gateway: a pod that already holds an account whose
24
+ address lives at a gateway (a browser account at fedipod.net, say) can be set
25
+ up here with an address at a different gateway, and the account moves rather
26
+ than being refused. Setup keeps the account's state and key on the pod,
27
+ attaches at the new gateway, publishes the new address with the old one as an
28
+ alias, tells the old gateway, and sends a Move to every follower from the old
29
+ address. The old gateway then serves the old actor as a stub saying where it
30
+ went. An address on the pod itself has nothing to move; it is a sign-in.
31
+
25
32
  ## Installing
26
33
 
27
34
  ```
package/gateway.md CHANGED
@@ -120,7 +120,6 @@ not a note, an annotation say, is kept as the app sent it, under your name.
120
120
 
121
121
  ## Moving to another gateway
122
122
 
123
- <!-- CLAUDE 2026-09-23 — new in 1.29.0, browser build; delete these markers when done -->
124
123
  An address that lives at a gateway, `@you@gateway-a`, can move to another
125
124
  one and keep its pod, its posts, its followers and its key. Open the new
126
125
  gateway, choose **create an account**, and sign in with the same pod. The
@@ -147,9 +146,49 @@ What happens then, in order:
147
146
  The old gateway keeps the stub for as long as its row stands; the owner can
148
147
  remove the row from the roster later. An address on the pod, `@you@yourpod`,
149
148
  needs none of this: it detaches from one mail door and attaches to another.
150
- Only the browser build moves in this way for now; the DeviceAgent's setup
151
- does not yet read a pod that already holds an account.
152
- <!-- /CLAUDE -->
149
+
150
+ A DeviceAgent moves the same way: set it up with the pod you already have
151
+ and an address at the new gateway (`--address front`, or "At the gateway"
152
+ on the setup page). Setup reads the account on the pod, keeps its state and
153
+ key there, attaches at the new gateway, and completes the move when the
154
+ agent first acts. See [the DeviceAgent](device-agent.md).
155
+
156
+ ## Accounts that go quiet
157
+
158
+ A gateway holds no mail. Every delivery it accepts is written into your pod
159
+ inbox, and your agent reads it from there. A BrowserAgent reads only while
160
+ its page is open, so an account nobody opens grows on its pod without limit
161
+ and comes back to a drain of everything at once. fedipod.net keeps two facts
162
+ about each browser account — when its owner last signed in or posted, and
163
+ how much content has arrived since — and acts on them.
164
+
165
+ **Paused.** After about 5,000 posts, replies, likes, boosts and edits since
166
+ you were last here, or when you say so on the manage page, the door accepts
167
+ content and discards it. Follows, unfollows, account moves, deletions and
168
+ blocks still reach your pod. Signing in ends an automatic pause by itself; a
169
+ pause you set lasts until you lift it.
170
+
171
+ **Closed.** After six months without a sign-in, or when you say so on the
172
+ manage page, the address is closed for good: its handle, its actor and its
173
+ door answer 410 Gone, other servers drop the account the next time they look,
174
+ and nobody can take the name. Nothing on your pod is touched. An address that
175
+ had already moved to another gateway keeps answering as moved.
176
+
177
+ Only accounts opened from a browser are counted. A DeviceAgent behind the
178
+ gateway drains its own inbox as it runs, and is never paused or closed by
179
+ time. Accounts from before this was built are counted from their next
180
+ sign-in. A gateway operator sets the cap and the window with
181
+ `FEDIPOD_PAUSE_ITEMS` and `FEDIPOD_CLOSE_DAYS`.
182
+
183
+ ## Notices
184
+
185
+ Whoever runs the gateway can write notices to everyone with an account
186
+ there. A bell at the right end of the bar, on the record page and in the
187
+ client, shows how many this browser has not opened yet; the list gives the
188
+ titles and each one opens on its own. The operator writes, changes and
189
+ removes them at `/notices`, signed in as the admin the way the roster is.
190
+ A notice is a title and plain text: a blank line starts a paragraph, and a
191
+ web address becomes a link.
153
192
 
154
193
  ## What the gateway can see
155
194
 
package/gui.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # The Admin interface
2
2
 
3
- In the browser version at fedipod.net, `manage account` in the bar opens this
3
+ In the browser version at fedipod.net, `manage`, in the bar's account group, opens this
4
4
  same page for your account. The rest of this page describes it as the
5
5
  DeviceAgent serves it; the controls are the same, minus the manual inbox
6
6
  drain and the local log, which a browser does not have.
7
7
 
8
- Open `https://localhost:8030/` while any agent is running — it forwards you to the agent — then choose `manage account` and select the actor you want from the local actors dropdown.
8
+ Open `https://localhost:8030/` while any agent is running — it forwards you to the agent — then choose `manage` in the bar's account group and select the actor you want from the local actors dropdown.
9
9
  Picking an actor marked "(stopped)" starts its agent, then opens its page.
10
10
 
11
11
  The software row names the version the agent is running. When the copy on the machine is further ahead — after an update, or after pulling a checkout — it says so and asks for a restart, because an agent goes on serving the code it started with until it is restarted.
@@ -106,6 +106,9 @@ export class Publisher {
106
106
  image: this.config.image || null, fields: this.config.fields || [],
107
107
  webId: this.remote.webId || null,
108
108
  aliases: this.config.aliases || [],
109
+ // A group says whether only its moderators may open posts (Lemmy's
110
+ // term); a person's actor carries nothing of the kind.
111
+ postingRestrictedToMods: this.config.kind === 'group' ? !!this.config.postingRestrictedToMods : null,
109
112
  moderators,
110
113
  pendingFollowers: priv ? urls.pendingFollowers : null,
111
114
  pendingFollowing: priv ? urls.pendingFollowing : null,
package/lib/core/wire.mjs CHANGED
@@ -3,6 +3,10 @@
3
3
  // protocol documents, not our RDF.
4
4
 
5
5
  export const AS_CTX = 'https://www.w3.org/ns/activitystreams';
6
+ // Lemmy's own context, held locally (lib/core/contexts/join-lemmy.json). It
7
+ // names the three terms a Lemmy reader looks for on a community and its
8
+ // posts: postingRestrictedToMods, stickied, commentsEnabled.
9
+ export const LEMMY_CTX = 'https://join-lemmy.org/context.json';
6
10
  export const SEC_CTX = 'https://w3id.org/security/v1';
7
11
  export const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
8
12
 
@@ -80,7 +84,8 @@ export const assertionKeyId = (urls) => urls.actor + '#ed25519-key';
80
84
  export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null, movedTo = null, kind = 'person',
81
85
  approveJoins = false, summary = null, icon = null, image = null, fields = [],
82
86
  webId = null, aliases = [], moderators = null, pendingFollowers = null, pendingFollowing = null,
83
- blocked = null, inbox = null, outbox = null, oauthAuthorize = null, oauthToken = null }) {
87
+ blocked = null, inbox = null, outbox = null, oauthAuthorize = null, oauthToken = null,
88
+ postingRestrictedToMods = null }) {
84
89
  // manuallyApprovesFollowers is NOT in the base AS2 context, so it is declared
85
90
  // inline exactly as Mastodon declares it — and only when we actually use it.
86
91
  // It is what makes a client show "Request to follow" rather than "Follow" and
@@ -118,6 +123,9 @@ export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null
118
123
  // old accounts elsewhere whose servers check for their own URL here before
119
124
  // they will send a Move. Declared inline exactly as Mastodon declares it.
120
125
  if (webId || aliases.length) context.push({ alsoKnownAs: { '@id': 'as:alsoKnownAs', '@type': '@id' } });
126
+ // A community says whether only its moderators may open posts in it, in
127
+ // Lemmy's term, declared through Lemmy's context. Groups only.
128
+ if (kind === 'group' && postingRestrictedToMods !== null) context.push(LEMMY_CTX);
121
129
  if (fields.length) {
122
130
  context.push({ schema: 'http://schema.org#', PropertyValue: 'schema:PropertyValue', value: 'schema:value' });
123
131
  }
@@ -134,6 +142,7 @@ export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null
134
142
  }] } : {}),
135
143
  ...(movedTo ? { movedTo } : {}),
136
144
  ...(webId || aliases.length ? { alsoKnownAs: [...(webId ? [webId] : []), ...aliases] } : {}),
145
+ ...(kind === 'group' && postingRestrictedToMods !== null ? { postingRestrictedToMods: !!postingRestrictedToMods } : {}),
137
146
  preferredUsername: handle,
138
147
  name: name || handle,
139
148
  // The bio, and the avatar. For a group, `summary` is where what the group
@@ -52,8 +52,12 @@ if (!newAccount) {
52
52
  const { resourceExists } = await import(new URL('../../../../lib/pod/root.mjs', import.meta.url));
53
53
  const { apUrls, DEFAULT_ROOT: DR } = await import(new URL('../../../../lib/core/wire.mjs', import.meta.url));
54
54
  if (await resourceExists(fetch, apUrls(pod, DR).actor)) {
55
- console.error('The pod already hosts a FediPod account. If you want a second account, put it on a different pod.');
56
- process.exit(2);
55
+ if (String(flag('address') || 'pod').toLowerCase() !== 'front') {
56
+ console.error('The pod already hosts a FediPod account. If you want a second account, put it on a different pod.');
57
+ console.error('To move an account whose address is at another gateway to this one, add --address front.');
58
+ process.exit(2);
59
+ }
60
+ console.log('The pod already holds an account; if its address is at another gateway, setup moves it here.');
57
61
  }
58
62
  }
59
63
 
@@ -0,0 +1,58 @@
1
+ // gateway-move.mjs — the second half of a DeviceAgent moving an address from
2
+ // one gateway to another (lib/device/setup.mjs does the first, when a pod
3
+ // that already holds an account at another gateway is set up with an address
4
+ // at this one). The browser build has the same two halves in
5
+ // web/app/gateway-move.mjs; this is the Node one, which signs and delivers
6
+ // itself instead of going through a relay.
7
+ //
8
+ // 1. The old gateway is told the address moved (POST /api/move, proved with
9
+ // the pod session as attach was). From then on it serves the old actor
10
+ // as a moved stub — same key, old id, `movedTo` the new one.
11
+ // 2. A Move goes to every follower, FROM the old actor, signed under the
12
+ // old key id: the only signature a follower's server can verify against
13
+ // the old actor. Same key material; only the key id differs.
14
+ //
15
+ // Runs when the agent starts acting, and is safe to run again: a move left
16
+ // pending (the old gateway unreachable) is tried at the next start.
17
+ import { Deliverer } from '../core/deliver.mjs';
18
+ import { moveActivity } from '../core/wire.mjs';
19
+
20
+ export async function completeGatewayMove(agent, { makeDeliverer = (o) => new Deliverer(o) } = {}) {
21
+ const cfg = agent.store.getConfig();
22
+ const mv = cfg?.movedFrom;
23
+ if (!mv || mv.completedAt) return null;
24
+ const log = agent.log || (() => {});
25
+ const newActor = agent.urls.actor;
26
+
27
+ // 1. the old gateway
28
+ let told;
29
+ try {
30
+ told = await agent.remote.session.fetch(`${mv.gateway}/api/move`, {
31
+ method: 'POST', headers: { 'content-type': 'application/json' },
32
+ body: JSON.stringify({ handle: mv.handle, movedTo: newActor }),
33
+ });
34
+ } catch (e) { told = { status: 0, statusText: e.message }; }
35
+ if (told.status !== 200) {
36
+ log(`gateway move: ${mv.gateway} did not mark @${mv.handle} as moved (${told.status || told.statusText}) — will retry at the next start`);
37
+ return { told: false };
38
+ }
39
+
40
+ // 2. the Move, from the old address
41
+ const contacts = agent.store.getContacts();
42
+ const inboxes = [...new Set(contacts.followers.map((f) => f.sharedInbox || f.inbox).filter(Boolean))];
43
+ const activity = moveActivity({ actor: mv.actor }, newActor, Date.now());
44
+ const sender = makeDeliverer({
45
+ store: agent.store, rsaPrivate: agent.deliverer.rsaPrivate,
46
+ keyId: `${mv.actor}#main-key`, actorId: mv.actor, log, passive: true,
47
+ });
48
+ let sent = 0; let failed = 0;
49
+ for (const inbox of inboxes) {
50
+ try { await sender.deliverNow(inbox, activity); sent++; }
51
+ catch (e) { failed++; log(`gateway move: Move to ${inbox} failed: ${e.message}`); }
52
+ }
53
+ const done = { ...mv, completedAt: new Date().toISOString(), moveSent: sent, moveFailed: failed };
54
+ agent.store.setConfig({ ...agent.store.getConfig(), movedFrom: done });
55
+ await agent.store.flush?.();
56
+ log(`moved from ${mv.actor} to ${newActor}: Move sent to ${sent} inbox(es)${failed ? `, ${failed} failed` : ''}`);
57
+ return done;
58
+ }
@@ -27,7 +27,9 @@ export const layoutOf = (cred) => Number(cred?.layout) || 0;
27
27
  // deliberately, with `--private-root`, has said where they want it; a migration
28
28
  // that overrode that would be moving data on its own initiative.
29
29
  export function needsStateMove(cred) {
30
- return !cred?.privateRoot;
30
+ // `stateOnPod` is a move-in from a browser account (setup.mjs): the state
31
+ // is on the pod because that is where the account already lives.
32
+ return !cred?.privateRoot && !cred?.stateOnPod;
31
33
  }
32
34
 
33
35
  export function pendingSteps(cred) {
@@ -21,6 +21,7 @@ import { rootOf, recordLastUsed, writeJsonAtomic } from './home.mjs';
21
21
  import { insecureUrlReason } from '../shared/safefetch.mjs';
22
22
  import { resourceExists } from '../pod/root.mjs';
23
23
  import { CURRENT_LAYOUT, isCurrent } from './migrate.mjs';
24
+ import { completeGatewayMove } from './gateway-move.mjs';
24
25
 
25
26
  const SOLID = $rdf.Namespace('http://www.w3.org/ns/solid/terms#');
26
27
 
@@ -125,6 +126,18 @@ export function frontedAddress({ pod, shape }) {
125
126
  return pathPod || shape === 'front';
126
127
  }
127
128
 
129
+ // The account a pod already holds, read as its owner: the config and the key
130
+ // record in its state container. Both null when there is none, or when the
131
+ // credential is not the owner's. Injected for tests.
132
+ export async function readPodAccount({ home, credential, pod, root, log = () => {} }) {
133
+ const remote = new RemotePod(credential, { home, log });
134
+ await remote.warmup?.();
135
+ const urls = apUrls(pod, root || DEFAULT_ROOT);
136
+ const config = await remote.getJson(urls.state + 'config.json').catch(() => null);
137
+ const keys = await remote.getJson(urls.state + 'keys.json').catch(() => null);
138
+ return { config, keys };
139
+ }
140
+
128
141
  // Take an address at a Gateway: attach the pod to it, fronted, and return the
129
142
  // gateway config bootstrap writes and connect reads. The pod session proves
130
143
  // the pod — no password reaches the Gateway. Injected for tests.
@@ -231,8 +244,13 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
231
244
  // several writes, and that pod is a server somebody runs. `state --to` moves
232
245
  // it afterwards; starting on the pod and moving later is strictly worse,
233
246
  // because the copy left behind was on the pod the whole time.
234
- const privateRoot = answers.privateRoot
247
+ let privateRoot = answers.privateRoot
235
248
  || pathToFileURL(path.join(home, 'private')).href + '/';
249
+ // A pod that already holds an account at ANOTHER gateway, set up with an
250
+ // address at this one: not a second account but the same one moving in.
251
+ // Its state and key are on the pod, and stay there.
252
+ let movingIn = false;
253
+ let movedFrom = null;
236
254
  // Everything that leaves this function — notes, errors, the log — goes
237
255
  // through here. Nothing today echoes a password back, but /setup/progress is
238
256
  // polled repeatedly and would re-serve one forever if anything ever did.
@@ -266,10 +284,15 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
266
284
  // reachable, and no silent 401 later on a pod whose profile is empty.
267
285
  const usable = await checkPod(pod);
268
286
  if (!usable.ok) throw new Error(usable.error);
269
- if (await (deps.resourceExists || resourceExists)(deps.fetch || fetch, apUrls(pod, root || DEFAULT_ROOT).actor)) {
270
- throw new Error('The pod already hosts a FediPod account. If you want a second account, put it on a different pod.');
287
+ const held = await (deps.resourceExists || resourceExists)(deps.fetch || fetch, apUrls(pod, root || DEFAULT_ROOT).actor);
288
+ if (held && !frontedAddress({ pod, shape })) {
289
+ throw new Error('The pod already hosts a FediPod account. If you want a second account, put it on a different pod. '
290
+ + 'To move an account whose address is at another gateway to this one, ask for an address at this gateway.');
271
291
  }
272
- skip('account', 'using the pod you already have');
292
+ if (held && kind === 'group') throw new Error('The pod already hosts a FediPod account, and a group cannot move between gateways.');
293
+ movingIn = held;
294
+ if (movingIn) { privateRoot = null; }
295
+ skip('account', movingIn ? 'the pod already holds the account — its address moves here' : 'using the pod you already have');
273
296
  }
274
297
 
275
298
  // --- credential: the point of no return, and the durability boundary ---
@@ -295,12 +318,14 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
295
318
  remotePod: pod.endsWith('/') ? pod : pod + '/',
296
319
  createdAt: new Date().toISOString(),
297
320
  ...(root ? { root } : {}),
298
- ...(keys === 'pod' ? { keysMode: 'pod' } : {}),
299
- // Where the private half lives. Per-machine, like keysMode.
321
+ ...(keys === 'pod' || movingIn ? { keysMode: 'pod' } : {}),
322
+ // Where the private half lives. Per-machine, like keysMode. A move-in
323
+ // keeps it on the pod, where the account already is, and says so.
300
324
  ...(privateRoot ? { privateRoot } : {}),
325
+ ...(movingIn ? { stateOnPod: true } : {}),
301
326
  // What shape this install is, so `upgrade` can tell an old one from a
302
327
  // new one without guessing from the fields.
303
- ...(isCurrent({ privateRoot }) ? { layout: CURRENT_LAYOUT } : {}),
328
+ ...(isCurrent({ privateRoot, stateOnPod: movingIn }) ? { layout: CURRENT_LAYOUT } : {}),
304
329
  };
305
330
  fs.mkdirSync(home, { recursive: true, mode: 0o700 });
306
331
  writeJsonAtomic(credPath, rec);
@@ -316,6 +341,24 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
316
341
  done('credential', credPath);
317
342
  }
318
343
 
344
+ // --- a move-in: the account on the pod, read with the credential just
345
+ // minted, has to be one whose address lives at another gateway ---
346
+ if (movingIn) {
347
+ const cred = JSON.parse(fs.readFileSync(credPath, 'utf8'));
348
+ const account = await (deps.readPodAccount || readPodAccount)({ home, credential: cred, pod, root: root || DEFAULT_ROOT, log });
349
+ if (!account?.config) throw new Error('the pod holds an account, but its record could not be read with this credential — is this account the pod\'s owner?');
350
+ const frontActor = account.config.gateway?.frontActor || null;
351
+ const gwHost = new URL(gatewayOrigin).host;
352
+ if (!frontActor) throw new Error('The pod already hosts a FediPod account whose address lives on the pod: nothing to move. Sign in to it instead.');
353
+ if (new URL(frontActor).host === gwHost) throw new Error(`The pod already hosts a FediPod account at ${gwHost}: sign in to it instead of setting up again.`);
354
+ if ((account.config.kind || 'person') === 'group') throw new Error('a group cannot move between gateways');
355
+ if (!account.keys?.rsa || account.keys.ct) {
356
+ throw new Error(`this account's key is still under a password from an earlier version — sign in once at ${new URL(frontActor).host} first, then set up here`);
357
+ }
358
+ movedFrom = { actor: frontActor, gateway: new URL(frontActor).origin, handle: account.config.handle, at: new Date().toISOString() };
359
+ log(`moving @${account.config.handle}@${new URL(frontActor).host} here, as @${handle}@${gwHost}`);
360
+ }
361
+
319
362
  // --- take an address at the Gateway, if this pod needs one or asked for
320
363
  // one --- before bootstrap, so the config it writes carries the Gateway
321
364
  // ids and the key connect mints below is stamped to the Gateway actor from
@@ -346,6 +389,17 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
346
389
  if (!ready.ok) throw new Error(ready.error);
347
390
  }
348
391
  await agent.bootstrap({ handle, name: name || handle, root, kind, approveJoins, summary, icon, gateway: gatewayCfg });
392
+ if (movingIn && movedFrom) {
393
+ // The old actor becomes an alias, which is what a server checks on the
394
+ // new actor before it honours a Move; the Move itself is pending until
395
+ // the new actor is published (completeGatewayMove, below and at every
396
+ // start). The key stays; its stamp follows the actor.
397
+ const cfg = agent.store.getConfig() || {};
398
+ agent.store.setConfig({ ...cfg, aliases: [...new Set([...(cfg.aliases || []), movedFrom.actor])], movedFrom });
399
+ const keyRec = agent.store.read?.('keys.json', null);
400
+ if (keyRec?.mintedFor && gatewayCfg?.frontActor) agent.store.write?.('keys.json', { ...keyRec, mintedFor: gatewayCfg.frontActor });
401
+ await agent.store.flush();
402
+ }
349
403
  done('bootstrap');
350
404
 
351
405
  begin('connect');
@@ -360,6 +414,12 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
360
414
  }
361
415
  const published = await agent.publisher.publishProfile();
362
416
  await agent.store.flush();
417
+ if (movingIn) {
418
+ // Tell the old gateway and the followers. Not fatal: left pending, it
419
+ // is tried again when the agent next starts acting.
420
+ const moved = await (deps.completeGatewayMove || completeGatewayMove)(agent).catch((e) => { log(`gateway move: ${e.message}`); return null; });
421
+ if (moved?.completedAt) log(`Move sent from ${movedFrom.actor} to ${moved.moveSent} inbox(es)`);
422
+ }
363
423
  done('publish');
364
424
 
365
425
  // --- and say plainly whether the world can actually see it ---
@@ -21,6 +21,8 @@ import { readCapped, safeFetch, isLoopbackHost } from '../shared/safefetch.mjs';
21
21
  import * as podRoot from '../pod/root.mjs';
22
22
  import * as podPolicy from '../pod/policy.mjs';
23
23
  import { podBaseOfWebId } from '../pod/urls.mjs';
24
+ import { routeQuietApi, noteOpened, noteReceived, closedState, accountState, closedAnswer } from './quiet.mjs';
25
+ import { routeNoticesApi } from './notices.mjs';
24
26
 
25
27
  // The one WebFinger document, spelled out here rather than imported from
26
28
  // wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
@@ -276,7 +278,7 @@ const sameOriginRequest = (request, origin) => {
276
278
 
277
279
  // Top-level paths a handle may not take, so a name never shadows a route.
278
280
  const RESERVED = new Set(['u', 'api', 'signup', 'new-account', 'run', 'admin', 'roster', 'gateway',
279
- 'gw', 'well-known', 'inbox', 'outbox', 'actor', 'install']);
281
+ 'gw', 'well-known', 'inbox', 'outbox', 'actor', 'install', 'notices']);
280
282
 
281
283
  // Why a handle is unusable, or null when it is fine. Lowercase letters, digits
282
284
  // and hyphens; 2–30 chars; not edge-hyphenated; not a reserved route.
@@ -332,6 +334,7 @@ function identFor(rec, policy = null) {
332
334
  // listDirectory() -> { handle: record } every row, for the admin roster
333
335
  // removeDirectory(handle) -> boolean drop a row; false when a seeded row remains
334
336
  // podPut(url, body, ct) -> boolean append to a user's pod (per-user cred inside)
337
+ // readReceived / writeReceived / dropReceived, pauseItems, closeDays — see quiet.mjs
335
338
  // podGet(url, { podHome }) -> Response read a user's pod (public reads; plain fetch
336
339
  // is fine). `podHome` is the row's own pod, for
337
340
  // an adapter that reads a store directly and so
@@ -409,14 +412,13 @@ async function route(request, ctx) {
409
412
  return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' }, body: ctx.runPage };
410
413
  }
411
414
 
412
- // The roster page: the host reading who has accounts here. The page signs in
413
- // and calls the roster API below; a deploy with no admin supplies no page.
414
- // Off /admin, which is the owner's own record page on every agent.
415
- if (pathname === '/roster') {
416
- if (request.method !== 'GET' && request.method !== 'HEAD') return { status: 405, headers: {}, body: '' };
417
- if (!ctx.adminPage) return notFound();
418
- return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: ctx.adminPage };
419
- }
415
+ // The roster page (/roster, off /admin, which is the owner's own record
416
+ // page on every agent) is served with the notices page, in notices.mjs.
417
+
418
+ // Notices from the operator to every account here, and the page they are
419
+ // written on (notices.mjs).
420
+ const notices = await routeNoticesApi(request, pathname, ctx, { j, verifyPodToken, publicFor, notFound });
421
+ if (notices) return notices;
420
422
 
421
423
  // The roster: every directory row, secrets stripped, for the host's own
422
424
  // eyes. The reader proves themself the way attach proves a pod — a
@@ -432,6 +434,7 @@ async function route(request, ctx) {
432
434
  handle: r.handle, kind: r.kind || 'person', fronted: !r.inboxOnly,
433
435
  podHome: r.podHome, webId: r.webId || null, actorUrl: r.actorUrl,
434
436
  address: r.address || `@${r.handle}@${ctx.host}`,
437
+ openedAt: r.openedAt || null, pausedAt: r.pausedAt || null, closedAt: r.closedAt || null,
435
438
  }))
436
439
  .sort((a, b) => (a.address || a.handle).localeCompare(b.address || b.handle));
437
440
  return j(200, { host: ctx.host, accounts });
@@ -639,6 +642,10 @@ async function route(request, ctx) {
639
642
  return withApiCors(j(200, { ok: true, handle, movedTo, movedAt }));
640
643
  }
641
644
 
645
+ // The owner's say over an account that goes quiet (quiet.mjs).
646
+ const quiet = await routeQuietApi(request, pathname, ctx, { j, verifyPodToken, webidUnderPod });
647
+ if (quiet) return quiet;
648
+
642
649
  // The relay: the front sends requests a browser has already signed. A page
643
650
  // may not set the Date or Host header, and both are inside an HTTP
644
651
  // signature, so a browser-run agent signs and hands the request here; the
@@ -659,6 +666,8 @@ async function route(request, ctx) {
659
666
  if (!owner) { console.log(`relay @${handle}: token is for ${webid}, not this account's pod`); return withApiCors(j(403, { error: "the token proves a different pod than this account's" })); }
660
667
  const items = Array.isArray(body.requests) ? body.requests : [];
661
668
  if (!items.length) return withApiCors(j(400, { error: 'requests must be a non-empty list' }));
669
+ // Acting through the relay is being here. Hourly at most; see noteOpened.
670
+ await noteOpened(ctx, handle, rec).catch((e) => console.log(`relay @${handle}: stamp not written: ${e?.message || e}`));
662
671
  if (items.length > RELAY_MAX_REQUESTS) return withApiCors(j(400, { error: `at most ${RELAY_MAX_REQUESTS} requests per call` }));
663
672
  const results = await Promise.all(items.map((it) => relayOne(it, rec, ctx.fetchImpl || fetch)));
664
673
  // One line per relayed request, so a lookup that fails on the far side is
@@ -767,7 +776,7 @@ async function route(request, ctx) {
767
776
  //
768
777
  // The name is matched against a literal list, so nothing about the request
769
778
  // chooses a file.
770
- const mPageScript = /^\/(new-account|run|admin)\.js$/u.exec(pathname);
779
+ const mPageScript = /^\/(new-account|run|admin|notices)\.js$/u.exec(pathname);
771
780
  if (mPageScript) {
772
781
  const body = ctx.pageScripts?.[`${mPageScript[1]}.js`];
773
782
  if (!body) return notFound();
@@ -797,6 +806,9 @@ async function route(request, ctx) {
797
806
  if (!m || m[2] !== ctx.host) return notFound();
798
807
  const rec = await ctx.lookup(m[1]);
799
808
  if (!rec) return notFound();
809
+ // A closed address is gone, and says so rather than pretending never to
810
+ // have existed: the name stays taken.
811
+ if ((await closedState(ctx, m[1], rec)).closed) return closedAnswer({ 'access-control-allow-origin': '*' });
800
812
  // A fronted identity's documents live on its pod; the pod's own actor id
801
813
  // is the alias, so a client signing in by the fronted address can find
802
814
  // the pod (and its login) without a lookup only the host could answer.
@@ -837,19 +849,25 @@ async function route(request, ctx) {
837
849
  // An account that moved to another gateway (see /api/move): where its ids
838
850
  // live now. The actor below answers as a stub; everything else redirects.
839
851
  const movedBase = rec.movedTo ? rec.movedTo.replace(/ap\/actor$/u, '') : null;
840
- const gone = (headers = {}) => ({ status: 410, headers: { ...headers, 'content-type': 'application/json', 'cache-control': 'no-store' },
841
- body: JSON.stringify({ error: `this account has moved to ${rec.movedTo}` }) });
852
+ // A closed address (see "accounts that go quiet" above): every id under it
853
+ // is gone. Asked after moved, which a close does not undo.
854
+ const closed = movedBase ? false : (await closedState(ctx, up.handle, rec)).closed;
855
+ const gone = (headers = {}, why = `this account has moved to ${rec.movedTo}`) => closedAnswer(headers, why);
856
+ const CLOSED = 'this address is closed';
842
857
 
843
858
  // Inbox: verify at the door, forward to the user's pod inbox. This is the
844
859
  // gateway, per user.
845
860
  if (up.rest === 'ap/inbox/' || up.rest === 'ap/inbox') {
846
861
  if (request.method !== 'POST') return { status: 405, headers: {}, body: '' };
847
862
  if (movedBase) { console.log(`door @${up.handle}: delivery → 410 (moved)`); return gone(); }
863
+ if (closed) { console.log(`door @${up.handle}: delivery → 410 (closed)`); return gone({}, CLOSED); }
848
864
  const policy = await policyFor(rec, ctx.fetchImpl || fetch);
849
- const { status, reason } = await handleDelivery(request, identFor(rec, policy),
850
- { podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), fetchImpl: ctx.fetchImpl });
865
+ const standing = await accountState(ctx, up.handle, rec);
866
+ const { status, reason, content, bytes } = await handleDelivery(request, identFor(rec, policy),
867
+ { podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), fetchImpl: ctx.fetchImpl, paused: standing.paused });
851
868
  // One line per delivery, so "did it arrive at the door" has an answer.
852
869
  console.log(`door @${up.handle}: delivery → ${status} (${reason})`);
870
+ if (status === 202 && content) await noteReceived(ctx, up.handle, rec, bytes);
853
871
  return { status, headers: {}, body: '' };
854
872
  }
855
873
 
@@ -876,6 +894,7 @@ async function route(request, ctx) {
876
894
  'accept-post': 'application/ld+json, application/activity+json' }, body: null };
877
895
  }
878
896
  if (movedBase && request.method === 'POST') return gone(cors);
897
+ if (closed && request.method === 'POST') return gone(cors, CLOSED);
879
898
  if (movedBase && (request.method === 'GET' || request.method === 'HEAD')) {
880
899
  return { status: 301, headers: { ...cors, location: movedBase + up.rest, 'cache-control': 'no-store' }, body: '' };
881
900
  }
@@ -916,6 +935,7 @@ async function route(request, ctx) {
916
935
  if (movedBase && up.rest !== 'ap/actor') {
917
936
  return { status: 301, headers: { ...open, location: movedBase + up.rest, 'cache-control': 'no-store' }, body: '' };
918
937
  }
938
+ if (closed) return gone(open, CLOSED);
919
939
  const podTarget = rec.podHome + up.rest;
920
940
  // Media stays on the pod (lib/pod/urls.mjs keeps `media` off the front), but
921
941
  // the id rewrite below turns media links onto the front like every other
@@ -19,9 +19,11 @@ import * as inbox from '../pod/inbox.mjs';
19
19
  const DEFAULT_MAX_BYTES = 512 * 1024; // mirror intake.mjs MAX_ITEM_BYTES
20
20
 
21
21
  // Control activities are the message itself and must always pass — you want
22
- // Follows from strangers. Only CONTENT is subject to the concerns-us drop.
23
- const CONTROL = new Set(['Follow', 'Undo', 'Accept', 'Reject', 'Delete', 'Move',
22
+ // Follows from strangers. Only CONTENT is subject to the concerns-us drop,
23
+ // and to a pause: an account nobody is reading still takes its follows.
24
+ export const CONTROL = new Set(['Follow', 'Undo', 'Accept', 'Reject', 'Delete', 'Move',
24
25
  'Add', 'Remove', 'Block']);
26
+ export const isControl = (type) => CONTROL.has(type);
25
27
 
26
28
  const idOf = (v) => (typeof v === 'string' ? v : v?.id) || null;
27
29
  const sha256hex = (s) => crypto.createHash('sha256').update(s).digest('hex');
@@ -64,8 +66,13 @@ function concernsUsAtEdge(activity, ident) {
64
66
  // resolved identity policy: { inboxUrl, actorUrl, followersUrl, notesPrefix,
65
67
  // following, blocklist, kind, gatewayWebId, hmacSecret }. `podPut(url, body,
66
68
  // contentType) → boolean` appends to the pod with the gateway's credential.
67
- // Returns { status, reason } — the adapter turns it into an HTTP response.
68
- export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch, maxBytes = DEFAULT_MAX_BYTES } = {}) {
69
+ // `paused` says nobody is reading this inbox: content is accepted and
70
+ // discarded (the same quiet 202 a blocked sender gets, so nothing retries
71
+ // and nothing counts a failure against this host), control still lands.
72
+ // Returns { status, reason } — the adapter turns it into an HTTP response —
73
+ // and, for a delivery that reached the pod, `content` (whether it was
74
+ // content rather than control) and `bytes`, so the caller can keep count.
75
+ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch, maxBytes = DEFAULT_MAX_BYTES, paused = false } = {}) {
69
76
  // Read the body once from a clone; the original, unconsumed, goes to the
70
77
  // verifier (which needs the body for the Digest check).
71
78
  let raw;
@@ -80,7 +87,9 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
80
87
  // Edge drops — silent 202 so a rejected sender does not retry a delivery we
81
88
  // will never accept. None of these becomes a pod write.
82
89
  if (isBlocked(actor, ident.blocklist)) return { status: 202, reason: 'blocked' };
83
- if (!CONTROL.has(activity.type) && !concernsUsAtEdge(activity, ident)) {
90
+ const content = !CONTROL.has(activity.type);
91
+ if (content && paused) return { status: 202, reason: 'paused' };
92
+ if (content && !concernsUsAtEdge(activity, ident)) {
84
93
  return { status: 202, reason: 'does not concern us' };
85
94
  }
86
95
 
@@ -107,7 +116,7 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
107
116
  // holding any state.
108
117
  if (!okA) return { status: 502, reason: 'pod inbox write failed' };
109
118
  if (receipt) await inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt);
110
- return { status: 202, reason: v.verified ? 'verified' : 'buffered-unverified' };
119
+ return { status: 202, reason: v.verified ? 'verified' : 'buffered-unverified', content, bytes: Buffer.byteLength(raw) };
111
120
  }
112
121
 
113
122
  // The outbox door: the owner's own post, taken on their behalf.