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 +1 -1
- package/device-agent.md +9 -2
- package/gateway.md +43 -4
- package/gui.md +2 -2
- package/lib/core/publisher/index.mjs +3 -0
- package/lib/core/wire.mjs +10 -1
- package/lib/device/cli/commands/setup.mjs +6 -2
- package/lib/device/gateway-move.mjs +58 -0
- package/lib/device/migrate.mjs +3 -1
- package/lib/device/setup.mjs +67 -7
- package/lib/gateway/front-core.mjs +34 -14
- package/lib/gateway/gateway-core.mjs +15 -6
- package/lib/gateway/notices.mjs +84 -0
- package/lib/gateway/quiet.mjs +184 -0
- package/package.json +1 -1
- package/run-agent.mjs +5 -0
- package/scripts/stage-site.mjs +6 -3
- package/web/admin/bar.css +44 -10
- package/web/admin/client/index.html +29 -2
- package/web/admin/gateway.js +19 -0
- package/web/admin/index.html +46 -3
- package/web/admin/notices-bar.js +90 -0
- package/web/admin/record.js +1 -0
- package/web/admin/setup/index.html +29 -2
- package/web/admin/upkeep.js +5 -1
- package/web/app/admin-facade.mjs +24 -0
- package/web/app/agent.mjs +48 -0
- package/web/app/boot.mjs +2 -0
- package/web/app/dist/boot.js +3 -1
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +96 -3
- package/web/app/dist/sw.js.map +3 -3
- package/web/front/admin.html +3 -1
- package/web/front/notices.html +69 -0
- package/web/front/notices.js +107 -0
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
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
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
|
+
}
|
package/lib/device/migrate.mjs
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/lib/device/setup.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
|
413
|
-
//
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
841
|
-
|
|
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
|
|
850
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
68
|
-
|
|
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
|
-
|
|
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.
|