fedipod 1.29.2 → 1.30.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/device-agent.md +9 -2
- package/gateway.md +6 -4
- 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/package.json +1 -1
- package/run-agent.mjs +5 -0
- package/web/app/dist/sw.js +10 -2
- package/web/app/dist/sw.js.map +2 -2
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,12 @@ 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).
|
|
153
155
|
|
|
154
156
|
## What the gateway can see
|
|
155
157
|
|
|
@@ -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 ---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.30.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",
|
package/run-agent.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import { apRoot, rootOf, writeJsonAtomic } from './lib/device/home.mjs';
|
|
|
33
33
|
import { ensureTrustedTls } from './lib/device/certs.mjs';
|
|
34
34
|
import { storageFor } from './lib/core/storage.mjs';
|
|
35
35
|
import { resolveKeys } from './lib/core/keys.mjs';
|
|
36
|
+
import { completeGatewayMove } from './lib/device/gateway-move.mjs';
|
|
36
37
|
import { RemotePod } from './lib/device/remote.mjs';
|
|
37
38
|
import { Deliverer } from './lib/core/deliver.mjs';
|
|
38
39
|
import { Publisher } from './lib/core/publisher/index.mjs';
|
|
@@ -547,6 +548,10 @@ export class Agent {
|
|
|
547
548
|
return; // renewal is already running, above
|
|
548
549
|
}
|
|
549
550
|
await this.intake.start();
|
|
551
|
+
// An address that moved here from another gateway, whose move was left
|
|
552
|
+
// pending (the old gateway could not be told): try again now that this
|
|
553
|
+
// agent acts and the actor is published.
|
|
554
|
+
completeGatewayMove(this).catch(e => this.log(`gateway move: ${e.message}`));
|
|
550
555
|
// Reused, not replaced: demote() stops this instance but does not clear it,
|
|
551
556
|
// so constructing a new one over the top orphaned the old chain beyond the
|
|
552
557
|
// reach of any later stop().
|
package/web/app/dist/sw.js
CHANGED
|
@@ -9700,6 +9700,7 @@ __export(wire_exports, {
|
|
|
9700
9700
|
DEFAULT_ROOT: () => DEFAULT_ROOT,
|
|
9701
9701
|
FOLLOWERS_PAGE_SIZE: () => FOLLOWERS_PAGE_SIZE,
|
|
9702
9702
|
HASHTAG_RE: () => HASHTAG_RE,
|
|
9703
|
+
LEMMY_CTX: () => LEMMY_CTX,
|
|
9703
9704
|
MENTION_RE: () => MENTION_RE,
|
|
9704
9705
|
OUTBOX_PAGE_SIZE: () => OUTBOX_PAGE_SIZE,
|
|
9705
9706
|
POLICY_CTX: () => POLICY_CTX,
|
|
@@ -9812,7 +9813,8 @@ function actorDoc({
|
|
|
9812
9813
|
inbox = null,
|
|
9813
9814
|
outbox = null,
|
|
9814
9815
|
oauthAuthorize = null,
|
|
9815
|
-
oauthToken = null
|
|
9816
|
+
oauthToken = null,
|
|
9817
|
+
postingRestrictedToMods = null
|
|
9816
9818
|
}) {
|
|
9817
9819
|
const context = [AS_CTX, SEC_CTX];
|
|
9818
9820
|
context.push({ toot: "http://joinmastodon.org/ns#", featured: { "@id": "toot:featured", "@type": "@id" } });
|
|
@@ -9835,6 +9837,7 @@ function actorDoc({
|
|
|
9835
9837
|
});
|
|
9836
9838
|
}
|
|
9837
9839
|
if (webId || aliases.length) context.push({ alsoKnownAs: { "@id": "as:alsoKnownAs", "@type": "@id" } });
|
|
9840
|
+
if (kind === "group" && postingRestrictedToMods !== null) context.push(LEMMY_CTX);
|
|
9838
9841
|
if (fields.length) {
|
|
9839
9842
|
context.push({ schema: "http://schema.org#", PropertyValue: "schema:PropertyValue", value: "schema:value" });
|
|
9840
9843
|
}
|
|
@@ -9853,6 +9856,7 @@ function actorDoc({
|
|
|
9853
9856
|
}] } : {},
|
|
9854
9857
|
...movedTo ? { movedTo } : {},
|
|
9855
9858
|
...webId || aliases.length ? { alsoKnownAs: [...webId ? [webId] : [], ...aliases] } : {},
|
|
9859
|
+
...kind === "group" && postingRestrictedToMods !== null ? { postingRestrictedToMods: !!postingRestrictedToMods } : {},
|
|
9856
9860
|
preferredUsername: handle7,
|
|
9857
9861
|
name: name || handle7,
|
|
9858
9862
|
// The bio, and the avatar. For a group, `summary` is where what the group
|
|
@@ -10375,7 +10379,7 @@ function addRemoveActivity({ urls, type, object, target, serial }) {
|
|
|
10375
10379
|
target
|
|
10376
10380
|
};
|
|
10377
10381
|
}
|
|
10378
|
-
var import_sanitize_html, AS_CTX, SEC_CTX, PUBLIC, DEFAULT_ROOT, assertionKeyId, OUTBOX_PAGE_SIZE, outboxPageId, outboxPageCount, outboxItemId, outboxWireItem, outboxLocalItem, FOLLOWERS_PAGE_SIZE, followersPageId, followersPageCount, ALLOWED_TAGS, ALLOWED_ATTRS, MAX_ATTACHMENTS, MAX_ATTACHMENT_URL, attachmentUrl, HTML_ESCAPES2, MENTION_RE, HASHTAG_RE, QUOTE_CTX, POLICY_CTX;
|
|
10382
|
+
var import_sanitize_html, AS_CTX, LEMMY_CTX, SEC_CTX, PUBLIC, DEFAULT_ROOT, assertionKeyId, OUTBOX_PAGE_SIZE, outboxPageId, outboxPageCount, outboxItemId, outboxWireItem, outboxLocalItem, FOLLOWERS_PAGE_SIZE, followersPageId, followersPageCount, ALLOWED_TAGS, ALLOWED_ATTRS, MAX_ATTACHMENTS, MAX_ATTACHMENT_URL, attachmentUrl, HTML_ESCAPES2, MENTION_RE, HASHTAG_RE, QUOTE_CTX, POLICY_CTX;
|
|
10379
10383
|
var init_wire = __esm({
|
|
10380
10384
|
"lib/core/wire.mjs"() {
|
|
10381
10385
|
init_urls();
|
|
@@ -10383,6 +10387,7 @@ var init_wire = __esm({
|
|
|
10383
10387
|
init_profile_page();
|
|
10384
10388
|
import_sanitize_html = __toESM(require_sanitize_html(), 1);
|
|
10385
10389
|
AS_CTX = "https://www.w3.org/ns/activitystreams";
|
|
10390
|
+
LEMMY_CTX = "https://join-lemmy.org/context.json";
|
|
10386
10391
|
SEC_CTX = "https://w3id.org/security/v1";
|
|
10387
10392
|
PUBLIC = "https://www.w3.org/ns/activitystreams#Public";
|
|
10388
10393
|
DEFAULT_ROOT = "fedipod/";
|
|
@@ -57180,6 +57185,9 @@ var Publisher = class {
|
|
|
57180
57185
|
fields: this.config.fields || [],
|
|
57181
57186
|
webId: this.remote.webId || null,
|
|
57182
57187
|
aliases: this.config.aliases || [],
|
|
57188
|
+
// A group says whether only its moderators may open posts (Lemmy's
|
|
57189
|
+
// term); a person's actor carries nothing of the kind.
|
|
57190
|
+
postingRestrictedToMods: this.config.kind === "group" ? !!this.config.postingRestrictedToMods : null,
|
|
57183
57191
|
moderators,
|
|
57184
57192
|
pendingFollowers: priv ? urls.pendingFollowers : null,
|
|
57185
57193
|
pendingFollowing: priv ? urls.pendingFollowing : null,
|