fedipod 1.36.6 → 1.40.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 +29 -8
- package/architecture.md +8 -0
- package/bin/fedipod.mjs +6 -0
- package/cli.md +7 -1
- package/device-agent.md +3 -3
- package/gateway.md +69 -8
- package/groups.md +2 -1
- package/gui.md +3 -1
- package/lib/client/c2s.mjs +97 -43
- package/lib/client/masto/accounts.mjs +1 -0
- package/lib/client/masto/bridge.mjs +61 -0
- package/lib/client/masto/index.mjs +4 -1
- package/lib/client/masto/oauth.mjs +1 -1
- package/lib/client/masto/statuses.mjs +3 -0
- package/lib/connections/acctfeed.mjs +13 -9
- package/lib/connections/bskyfeed.mjs +13 -9
- package/lib/connections/tagfeed.mjs +14 -9
- package/lib/core/deliver.mjs +17 -1
- package/lib/core/intake/activities.mjs +18 -2
- package/lib/core/intake/group.mjs +3 -1
- package/lib/core/intake/index.mjs +226 -48
- package/lib/core/intake/notes.mjs +12 -5
- package/lib/core/intake/verify.mjs +11 -0
- package/lib/core/lease.mjs +15 -1
- package/lib/core/place.mjs +82 -0
- package/lib/core/pod-only.mjs +4 -0
- package/lib/core/publisher/collections.mjs +37 -2
- package/lib/core/publisher/index.mjs +25 -1
- package/lib/core/publisher/notes.mjs +73 -7
- package/lib/core/publisher/own.mjs +143 -0
- package/lib/core/publisher/questions.mjs +5 -3
- package/lib/core/scheduled.mjs +39 -0
- package/lib/core/social.mjs +62 -32
- package/lib/core/storage.mjs +67 -0
- package/lib/core/store.mjs +77 -5
- package/lib/core/wire.mjs +51 -15
- package/lib/device/admin/routes/lifecycle.mjs +1 -1
- package/lib/device/admin/routes/setup.mjs +17 -1
- package/lib/device/cli/commands/setup.mjs +50 -8
- package/lib/device/cli/context.mjs +1 -1
- package/lib/device/migrate.mjs +1 -1
- package/lib/device/setup.mjs +45 -8
- package/lib/gateway/account-agent.mjs +111 -0
- package/lib/gateway/copy.mjs +381 -0
- package/lib/gateway/front-core.mjs +74 -63
- package/lib/gateway/gateway-core.mjs +107 -9
- package/lib/gateway/held-mail.mjs +198 -0
- package/lib/gateway/keeper-due.mjs +39 -0
- package/lib/gateway/keeper-session.mjs +10 -0
- package/lib/gateway/keeper.mjs +72 -0
- package/lib/gateway/masto-gateway.mjs +510 -0
- package/lib/gateway/quiet.mjs +7 -2
- package/lib/gateway/relay-extras.mjs +89 -0
- package/lib/gateway/state-api.mjs +207 -0
- package/lib/gateway/token-claims.mjs +16 -0
- package/lib/pod/containers.mjs +17 -0
- package/lib/pod/location.mjs +52 -0
- package/lib/pod/notes.mjs +2 -4
- package/lib/pod/transport.mjs +214 -24
- package/lib/pod/type-index.mjs +101 -0
- package/lib/pod/urls.mjs +6 -0
- package/lib/server/embed.mjs +7 -6
- package/lib/session/README.md +5 -5
- package/lib/session/demo.html +1 -1
- package/lib/session/fedi-account.mjs +19 -10
- package/lib/session/package.json +2 -2
- package/package.json +2 -2
- package/run-agent.mjs +6 -16
- package/scripts/stage-site.mjs +17 -4
- package/web/admin/actors.js +2 -0
- package/web/admin/gateway.js +14 -1
- package/web/admin/index.html +13 -0
- package/web/admin/oauth-signin.mjs +1 -1
- package/web/admin/record.js +4 -1
- package/web/admin/setup/index.html +17 -1
- package/web/admin/setup/setup.js +18 -5
- package/web/app/README.md +2 -2
- package/web/app/admin-facade.mjs +12 -2
- package/web/app/agent.mjs +228 -51
- package/web/app/boot.mjs +75 -32
- package/web/app/copy-mode.mjs +223 -0
- package/web/app/dist/boot.js +498 -88
- package/web/app/dist/boot.js.map +4 -4
- package/web/app/dist/sw.js +4016 -2539
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/index.html +16 -0
- package/web/app/signup.mjs +65 -26
- package/web/app/sw-src.mjs +15 -53
- package/web/app/update.js +3 -2
- package/web/app/warm-start.mjs +115 -0
- package/web/app-signin/app-signin.mjs +78 -0
- package/web/app-signin/index.html +41 -0
- package/web/front/run.html +7 -1
- package/web/front/run.js +30 -4
package/lib/core/wire.mjs
CHANGED
|
@@ -83,7 +83,8 @@ export const assertionKeyId = (urls) => urls.actor + '#ed25519-key';
|
|
|
83
83
|
|
|
84
84
|
export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null, movedTo = null, kind = 'person',
|
|
85
85
|
approveJoins = false, summary = null, icon = null, image = null, fields = [],
|
|
86
|
-
webId = null, aliases = [], moderators = null, pendingFollowers = null, pendingFollowing = null,
|
|
86
|
+
webId = null, aliases = [], moderators = null, pendingFollowers = null, pendingFollowing = null, liked = null,
|
|
87
|
+
ownerOutbox = null,
|
|
87
88
|
blocked = null, inbox = null, outbox = null, oauthAuthorize = null, oauthToken = null,
|
|
88
89
|
postingRestrictedToMods = null }) {
|
|
89
90
|
// manuallyApprovesFollowers is NOT in the base AS2 context, so it is declared
|
|
@@ -119,6 +120,12 @@ export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null
|
|
|
119
120
|
blocked: { '@id': 'bl:blocked', '@type': '@id' },
|
|
120
121
|
});
|
|
121
122
|
}
|
|
123
|
+
if (ownerOutbox) {
|
|
124
|
+
context.push({
|
|
125
|
+
fedipod: 'https://fedipod.net/ns#',
|
|
126
|
+
ownerOutbox: { '@id': 'fedipod:ownerOutbox', '@type': '@id' },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
122
129
|
// The actor's other identities: the account's WebID, plus any aliases —
|
|
123
130
|
// old accounts elsewhere whose servers check for their own URL here before
|
|
124
131
|
// they will send a Move. Declared inline exactly as Mastodon declares it.
|
|
@@ -193,6 +200,12 @@ export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null
|
|
|
193
200
|
featured: urls.featured,
|
|
194
201
|
followers: urls.followers,
|
|
195
202
|
following: urls.following,
|
|
203
|
+
// What this actor has liked (§5.5). Its owner's to read, like the pending
|
|
204
|
+
// lists below.
|
|
205
|
+
...(liked ? { liked } : {}),
|
|
206
|
+
// Every message this actor produced, as its owner reads it (§5.1), at an
|
|
207
|
+
// address the owner's own credential reaches.
|
|
208
|
+
...(ownerOutbox ? { ownerOutbox } : {}),
|
|
196
209
|
// FEP-1b12: attributedTo names the moderators collection; recipients
|
|
197
210
|
// validate a group's announced moderation against it.
|
|
198
211
|
...(moderators ? { attributedTo: moderators } : {}),
|
|
@@ -252,12 +265,12 @@ export function tombstoneDoc(urls, deletedAt, kind = 'person') {
|
|
|
252
265
|
// A deleted note is left as a Tombstone at its own URL — kept fetchable — so a
|
|
253
266
|
// peer that dereferences it learns the post was DELETED rather than getting a
|
|
254
267
|
// bare 404 it cannot tell from "never existed" (§7.4).
|
|
255
|
-
export function noteTombstone(noteId, deletedAt) {
|
|
268
|
+
export function noteTombstone(noteId, deletedAt, formerType = 'Note') {
|
|
256
269
|
return {
|
|
257
270
|
'@context': AS_CTX,
|
|
258
271
|
id: noteId,
|
|
259
272
|
type: 'Tombstone',
|
|
260
|
-
formerType
|
|
273
|
+
formerType,
|
|
261
274
|
deleted: deletedAt,
|
|
262
275
|
};
|
|
263
276
|
}
|
|
@@ -418,25 +431,29 @@ export const FOLLOWERS_PAGE_SIZE = 20;
|
|
|
418
431
|
export const followersPageId = (id, n) => `${id}-${n}`;
|
|
419
432
|
export const followersPageCount = (total) => Math.max(1, Math.ceil(total / FOLLOWERS_PAGE_SIZE));
|
|
420
433
|
|
|
434
|
+
// Newest first, as every OrderedCollection is (ActivityPub §5): `first` is the
|
|
435
|
+
// page the newest names were added to and `next` walks back in time, and each
|
|
436
|
+
// page lists its names newest first. The pages themselves are still filled
|
|
437
|
+
// from the oldest end, so a new name rewrites one page.
|
|
421
438
|
export function followersHead(id, total, pageCount = followersPageCount(total)) {
|
|
422
439
|
const pages = Math.max(1, pageCount);
|
|
423
440
|
return {
|
|
424
441
|
'@context': AS_CTX,
|
|
425
442
|
id, type: 'OrderedCollection',
|
|
426
443
|
totalItems: total,
|
|
427
|
-
first: followersPageId(id,
|
|
428
|
-
last: followersPageId(id,
|
|
444
|
+
first: followersPageId(id, pages),
|
|
445
|
+
last: followersPageId(id, 1),
|
|
429
446
|
};
|
|
430
447
|
}
|
|
431
448
|
|
|
432
|
-
export function followersPage(id, n, items, pageCount) {
|
|
449
|
+
export function followersPage(id, n, items, pageCount) { // eslint-disable-line no-unused-vars
|
|
433
450
|
return {
|
|
434
451
|
'@context': AS_CTX,
|
|
435
452
|
id: followersPageId(id, n),
|
|
436
453
|
type: 'OrderedCollectionPage',
|
|
437
454
|
partOf: id,
|
|
438
|
-
orderedItems: items,
|
|
439
|
-
...(n
|
|
455
|
+
orderedItems: [...items].reverse(),
|
|
456
|
+
...(n > 1 ? { next: followersPageId(id, n - 1) } : {}),
|
|
440
457
|
};
|
|
441
458
|
}
|
|
442
459
|
|
|
@@ -775,12 +792,14 @@ export function followActivity({ urls, targetActor, serial }) {
|
|
|
775
792
|
};
|
|
776
793
|
}
|
|
777
794
|
|
|
778
|
-
|
|
795
|
+
// Addressed to the author of what is liked, the one it is delivered to.
|
|
796
|
+
export function likeActivity({ urls, noteId, serial, to = null }) {
|
|
779
797
|
return {
|
|
780
798
|
'@context': AS_CTX,
|
|
781
799
|
id: urls.actor + '#like-' + serial,
|
|
782
800
|
type: 'Like',
|
|
783
801
|
actor: urls.actor,
|
|
802
|
+
...(to ? { to: [to] } : {}),
|
|
784
803
|
object: noteId,
|
|
785
804
|
};
|
|
786
805
|
}
|
|
@@ -793,7 +812,7 @@ export function announceActivity({ urls, object, serial, published = new Date().
|
|
|
793
812
|
audience = null }) {
|
|
794
813
|
return {
|
|
795
814
|
'@context': AS_CTX,
|
|
796
|
-
id: urls
|
|
815
|
+
id: announceActivityId(urls, serial),
|
|
797
816
|
type: 'Announce',
|
|
798
817
|
actor: urls.actor,
|
|
799
818
|
published,
|
|
@@ -831,10 +850,20 @@ export function updateActorActivity({ urls, actor, serial, published = new Date(
|
|
|
831
850
|
// `serial` names the activity when the note is not being edited — a quote
|
|
832
851
|
// authorization arriving on it changes what the note says about its quote,
|
|
833
852
|
// not what it says.
|
|
853
|
+
// An edit's Update is named by when the edit was made, so whoever made it can
|
|
854
|
+
// say its id before it exists; a restatement that is not an edit has no stamp.
|
|
855
|
+
// Each is a document of its own beside the post, so it can be fetched by its
|
|
856
|
+
// id (§3.1) once it is on the public record; a fragment of another document
|
|
857
|
+
// could not be.
|
|
858
|
+
export const updateActivityId = (noteId, updated) => noteId + '-update-' + String(updated).replace(/[^0-9TZ]/g, '');
|
|
859
|
+
export const deleteActivityId = (noteId) => noteId + '-delete';
|
|
860
|
+
export const announceActivityId = (urls, serial) => urls.notes + 'announce-' + serial;
|
|
861
|
+
export const undoActivityId = (urls, serial) => urls.notes + 'undo-' + serial;
|
|
862
|
+
|
|
834
863
|
export function updateActivity(note, urls, { serial = null } = {}) {
|
|
835
864
|
return {
|
|
836
865
|
'@context': AS_CTX,
|
|
837
|
-
id: note.
|
|
866
|
+
id: note.updated ? updateActivityId(note.id, note.updated) : note.id + '-update-q' + (serial ?? Date.now()),
|
|
838
867
|
type: 'Update',
|
|
839
868
|
actor: urls.actor,
|
|
840
869
|
to: note.to, cc: note.cc,
|
|
@@ -842,23 +871,30 @@ export function updateActivity(note, urls, { serial = null } = {}) {
|
|
|
842
871
|
};
|
|
843
872
|
}
|
|
844
873
|
|
|
845
|
-
|
|
874
|
+
// Addressed as the post was, so a followers-only or direct post's deletion
|
|
875
|
+
// reaches the people who had it and is not announced to anyone else.
|
|
876
|
+
export function deleteActivity({ urls, noteId, to = [PUBLIC], cc = null }) {
|
|
846
877
|
return {
|
|
847
878
|
'@context': AS_CTX,
|
|
848
|
-
id: noteId
|
|
879
|
+
id: deleteActivityId(noteId),
|
|
849
880
|
type: 'Delete',
|
|
850
881
|
actor: urls.actor,
|
|
851
|
-
to
|
|
882
|
+
to,
|
|
883
|
+
...(cc?.length ? { cc } : {}),
|
|
852
884
|
object: { id: noteId, type: 'Tombstone' },
|
|
853
885
|
};
|
|
854
886
|
}
|
|
855
887
|
|
|
888
|
+
// Addressed as what it takes back was: an Undo goes to whoever had that.
|
|
856
889
|
export function undoActivity({ urls, activity, serial }) {
|
|
890
|
+
const to = activity?.to ?? (activity?.type === 'Follow' && typeof activity.object === 'string' ? [activity.object] : null);
|
|
857
891
|
return {
|
|
858
892
|
'@context': AS_CTX,
|
|
859
|
-
id: urls
|
|
893
|
+
id: undoActivityId(urls, serial),
|
|
860
894
|
type: 'Undo',
|
|
861
895
|
actor: urls.actor,
|
|
896
|
+
...(to ? { to } : {}),
|
|
897
|
+
...(activity?.cc ? { cc: activity.cc } : {}),
|
|
862
898
|
object: activity,
|
|
863
899
|
};
|
|
864
900
|
}
|
|
@@ -111,7 +111,7 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
|
|
|
111
111
|
// A Move target nobody can resolve is a landing pad nobody lands on.
|
|
112
112
|
if (!webfingerHost(urls.base) && !cfg.gateway?.frontActor) {
|
|
113
113
|
return json(res, 400, {
|
|
114
|
-
error: 'this pod is
|
|
114
|
+
error: 'this pod is suffixed, so WebFinger cannot answer for it '
|
|
115
115
|
+ '— other servers could never resolve this account as a Move target',
|
|
116
116
|
});
|
|
117
117
|
}
|
|
@@ -13,6 +13,7 @@ import { copyPrivateHalf, isCurrent, CURRENT_LAYOUT } from '../../migrate.mjs';
|
|
|
13
13
|
import { insecureUrlReason } from '../../../shared/safefetch.mjs';
|
|
14
14
|
import { newRun, preflight, runSetup, setupInputError, hasCredential, credentialPath } from '../../setup.mjs';
|
|
15
15
|
import { podLayout } from '../../../pod/root.mjs';
|
|
16
|
+
import { chosenRoot, publicIndexKnown } from '../../../core/place.mjs';
|
|
16
17
|
import { portFree, freePortFrom } from '../../ports.mjs';
|
|
17
18
|
import { yieldDirectory } from '../../../gateway/directory.mjs';
|
|
18
19
|
import { localFetch } from '../../../client/localapi.mjs';
|
|
@@ -115,7 +116,7 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
|
|
|
115
116
|
}
|
|
116
117
|
// ---- setup, driven by the page at /admin/setup/ ----
|
|
117
118
|
case '/setup/check': {
|
|
118
|
-
const pre = preflight(body);
|
|
119
|
+
const pre = { ...preflight(body), ...await placeCheck(body) };
|
|
119
120
|
// For a new pod, ask the provider where it puts pods, so the page can
|
|
120
121
|
// show the Gateway address before the pod exists. A provider on paths
|
|
121
122
|
// means the address must live at the Gateway; the run decides for real.
|
|
@@ -410,3 +411,18 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
|
|
|
410
411
|
default: return false;
|
|
411
412
|
}
|
|
412
413
|
}
|
|
414
|
+
|
|
415
|
+
// Where the data will go and whether the type index question has to be asked:
|
|
416
|
+
// a pod you bring is read as a stranger would (its profile is public); a new
|
|
417
|
+
// pod has no index, so the question is always asked for one.
|
|
418
|
+
async function placeCheck(body) {
|
|
419
|
+
if (body.mode === 'new') return { typeIndex: false };
|
|
420
|
+
let base;
|
|
421
|
+
try { base = new URL(String(body.pod || '')).href; } catch { return {}; }
|
|
422
|
+
if (!base.endsWith('/')) base += '/';
|
|
423
|
+
const place = chosenRoot(base, body.container);
|
|
424
|
+
return {
|
|
425
|
+
typeIndex: await publicIndexKnown(fetch, base),
|
|
426
|
+
...(place.problem ? { placeProblem: place.problem } : { place: base + place.root }),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
@@ -18,7 +18,10 @@ export async function setup() {
|
|
|
18
18
|
if (process.stdin.isTTY && !has('cli') && !IDENTITY_FLAGS.some(f => args.includes('--' + f))) {
|
|
19
19
|
return runBrowserSetup();
|
|
20
20
|
}
|
|
21
|
-
|
|
21
|
+
// Where on the pod: --root names it outright (scripts); otherwise the person
|
|
22
|
+
// names the container that holds `fedipod/`, asked below once the pod is known.
|
|
23
|
+
let root = flag('root') || null;
|
|
24
|
+
const place = await import(new URL('../../../../lib/core/place.mjs', import.meta.url));
|
|
22
25
|
const kind = has('group') ? 'group' : 'person';
|
|
23
26
|
const approveJoins = has('group') && has('approve-joins');
|
|
24
27
|
const summary = flag('summary');
|
|
@@ -48,10 +51,20 @@ if (!newAccount && !pod) {
|
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
53
|
if (!newAccount && !pod) { console.error('no pod given'); process.exit(2); }
|
|
54
|
+
const CONTAINER_Q = 'Store your data in a container named fedipod, inside this container';
|
|
55
|
+
let container = flag('in');
|
|
51
56
|
if (!newAccount) {
|
|
57
|
+
const podBase = pod.endsWith('/') ? pod : pod + '/';
|
|
58
|
+
if (!root) {
|
|
59
|
+
if (container == null) container = interactive ? await ask(`${CONTAINER_Q} (empty: ${new URL(podBase).pathname})`, '') : '';
|
|
60
|
+
const chosen = place.chosenRoot(podBase, container);
|
|
61
|
+
if (chosen.problem) { console.error(`Where to store it: ${chosen.problem}.`); process.exit(2); }
|
|
62
|
+
root = chosen.root;
|
|
63
|
+
}
|
|
52
64
|
const { resourceExists } = await import(new URL('../../../../lib/pod/root.mjs', import.meta.url));
|
|
53
65
|
const { apUrls, DEFAULT_ROOT: DR } = await import(new URL('../../../../lib/core/wire.mjs', import.meta.url));
|
|
54
|
-
|
|
66
|
+
// An account at the chosen place, or an older one where accounts used to go.
|
|
67
|
+
if (await resourceExists(fetch, apUrls(pod, root).actor) || await resourceExists(fetch, apUrls(pod, DR).actor)) {
|
|
55
68
|
if (String(flag('address') || 'pod').toLowerCase() !== 'front') {
|
|
56
69
|
console.error('The pod already hosts a FediPod account. If you want a second account, put it on a different pod.');
|
|
57
70
|
console.error('To move an account whose address is at another gateway to this one, add --address front.');
|
|
@@ -115,8 +128,8 @@ if (fronted && kind === 'group') {
|
|
|
115
128
|
// warning is their call to accept. Nobody could ever find this group, and the
|
|
116
129
|
// people it would fail are not the operator reading the warning.
|
|
117
130
|
if (kind === 'group' && !newAccount && !wfHost) {
|
|
118
|
-
console.error(`${pod} is a
|
|
119
|
-
console.error('WebFinger is answered only
|
|
131
|
+
console.error(`${pod} is a suffixed pod, not a subdomained one.`);
|
|
132
|
+
console.error('WebFinger is answered only for a subdomained pod, so nobody could find this group.');
|
|
120
133
|
console.error('Give the group a pod of its own: fedipod setup --group --new-account');
|
|
121
134
|
process.exit(2);
|
|
122
135
|
}
|
|
@@ -133,6 +146,26 @@ if (fronted) {
|
|
|
133
146
|
console.log(`pods at ${issuerHost}/${podName}/ instead cannot answer WebFinger for an address,`);
|
|
134
147
|
console.log(`so setup takes an address at ${gatewayHost} for you and says so.\n`);
|
|
135
148
|
}
|
|
149
|
+
// Where the account lives is recorded in the pod's public type index. A new
|
|
150
|
+
// pod has none; a pod you bring is asked. With none, only a yes goes on.
|
|
151
|
+
let createIndex = has('create-type-index');
|
|
152
|
+
if (!createIndex) {
|
|
153
|
+
const known = newAccount ? false : await place.publicIndexKnown(fetch, pod.endsWith('/') ? pod : pod + '/');
|
|
154
|
+
if (known !== true) {
|
|
155
|
+
const why = newAccount ? 'A new pod has no public type index' : 'Your pod has no public type index';
|
|
156
|
+
if (!interactive) {
|
|
157
|
+
console.error(`${why}. FediPod records where your account lives in one; add --create-type-index to make it.`);
|
|
158
|
+
process.exit(2);
|
|
159
|
+
}
|
|
160
|
+
const yes = await ask(`${why}, the list Solid apps use to find your things. FediPod needs one to record `
|
|
161
|
+
+ 'where your account lives. Create it? It will be readable by anyone, like your profile. (y/n)', 'n');
|
|
162
|
+
if (!/^y/i.test(yes)) { console.log('Setup stopped. Nothing was written to your pod.'); process.exit(0); }
|
|
163
|
+
createIndex = true;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (newAccount && !root && container == null) {
|
|
167
|
+
container = await ask(`${CONTAINER_Q} (empty: the new pod's root)`, '');
|
|
168
|
+
}
|
|
136
169
|
console.log('The display name can be changed later; the handle and pod cannot.');
|
|
137
170
|
const go = await ask(newAccount
|
|
138
171
|
? (kind === 'group' ? 'create pod and group? (y/n)' : 'create pod and Fediverse account? (y/n)')
|
|
@@ -147,16 +180,21 @@ if (newAccount) {
|
|
|
147
180
|
const made = await createAccountWithPod({ issuer, email, password, podName });
|
|
148
181
|
pod = made.pod;
|
|
149
182
|
console.log(`account + pod created: ${pod}`);
|
|
183
|
+
if (!root) {
|
|
184
|
+
const chosen = place.chosenRootInNewPod(pod.endsWith('/') ? pod : pod + '/', container);
|
|
185
|
+
if (chosen.problem) { console.error(`Where to store it: ${chosen.problem}. The pod was made; nothing else was.`); process.exit(2); }
|
|
186
|
+
root = chosen.root;
|
|
187
|
+
}
|
|
150
188
|
if (!webfingerHost(pod)) {
|
|
151
189
|
if (kind === 'group') {
|
|
152
|
-
console.log(`\n${issuerHost} created the pod at ${pod} — a
|
|
153
|
-
console.log('WebFinger is answered only
|
|
190
|
+
console.log(`\n${issuerHost} created the pod at ${pod} — a suffixed pod.`);
|
|
191
|
+
console.log('WebFinger is answered only for a subdomained pod, so nobody could find this group.');
|
|
154
192
|
console.log('The pod exists; no actor was published.');
|
|
155
193
|
process.exit(0);
|
|
156
194
|
}
|
|
157
195
|
// A path pod's address lives at the gateway; nothing to warn about.
|
|
158
196
|
fronted = true;
|
|
159
|
-
console.log(`${issuerHost}
|
|
197
|
+
console.log(`${issuerHost} makes suffixed pods, so your address will be @${handle}@${gatewayHost}.`);
|
|
160
198
|
}
|
|
161
199
|
}
|
|
162
200
|
|
|
@@ -207,11 +245,15 @@ await agent.bootstrap({ handle, name, root, kind, approveJoins, summary, icon, g
|
|
|
207
245
|
await agent.connect({ repair: false }); // publishProfile below is the publish
|
|
208
246
|
await agent.publisher.publishProfile();
|
|
209
247
|
await agent.store.flush();
|
|
248
|
+
// Where the account lives, in the pod's public type index — made first on the yes.
|
|
249
|
+
await place.recordPlace(agent.remote, rec.remotePod, `${rec.remotePod}${root}ap/actor`, { create: createIndex })
|
|
250
|
+
.then(() => console.log('recorded where the account lives in your public type index'))
|
|
251
|
+
.catch((e) => { console.error(`could not record where the account lives in your type index: ${e.message}`); process.exitCode = 1; });
|
|
210
252
|
const finalHost = gatewayCfg ? gatewayHost : webfingerHost(rec.remotePod);
|
|
211
253
|
const what = kind === 'group' ? 'group' : 'actor';
|
|
212
254
|
console.log(finalHost
|
|
213
255
|
? `${what} published: @${handle}@${finalHost}`
|
|
214
|
-
: `${what} published, but not reachable as a handle \u2014 ${rec.remotePod} is
|
|
256
|
+
: `${what} published, but not reachable as a handle \u2014 ${rec.remotePod} is a suffixed pod`);
|
|
215
257
|
|
|
216
258
|
// Straight into serving — setup ends with a working client in the browser.
|
|
217
259
|
const { startAdmin } = await import(new URL('../../../../lib/device/admin/index.mjs', import.meta.url));
|
|
@@ -215,7 +215,7 @@ export async function finish(agent, code = 0) {
|
|
|
215
215
|
// unpack-and-go line depend on that. At a terminal with none of them, setup
|
|
216
216
|
// asks the two things it needs to open a browser and asks the rest there.
|
|
217
217
|
export const IDENTITY_FLAGS = ['new-account', 'pod', 'issuer', 'email', 'name', 'pod-name',
|
|
218
|
-
'group', 'approve-joins', 'summary', 'icon', 'root', 'keys', 'rotate-key'];
|
|
218
|
+
'group', 'approve-joins', 'summary', 'icon', 'root', 'in', 'create-type-index', 'keys', 'rotate-key'];
|
|
219
219
|
|
|
220
220
|
// Refuse before anything is asked, let alone typed: setup used to overwrite
|
|
221
221
|
// credential.json in place, and a minted credential is only shown once — so
|
package/lib/device/migrate.mjs
CHANGED
|
@@ -65,7 +65,7 @@ export const MOVED_STATE_DOCS = new Set([
|
|
|
65
65
|
'config.json', 'queue.json', 'blocklist.json', 'contacts.json', 'muted.json',
|
|
66
66
|
'pending.json', 'requests.json', 'deadletter.json', 'statuses.json',
|
|
67
67
|
'notifications.json', 'media.json', 'actors.json', 'ids.json', 'published.json',
|
|
68
|
-
'outbox.json', 'outbox-removed.json', 'parked.json', 'tagfeed.json',
|
|
68
|
+
'outbox.json', 'outbox-removed.json', 'outbox-own.json', 'liked.json', 'c2s-seen.json', 'parked.json', 'tagfeed.json',
|
|
69
69
|
'intake-attempts.json', 'inbox-channel.json', 'masto-tokens.json', 'masto-markers.json',
|
|
70
70
|
'lists.json', 'filters.json', 'scheduled.json', 'webpush.json',
|
|
71
71
|
'import-state.json',
|
package/lib/device/setup.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { insecureUrlReason } from '../shared/safefetch.mjs';
|
|
|
22
22
|
import { resourceExists } from '../pod/root.mjs';
|
|
23
23
|
import { CURRENT_LAYOUT, isCurrent } from './migrate.mjs';
|
|
24
24
|
import { completeGatewayMove } from './gateway-move.mjs';
|
|
25
|
+
import { chosenRoot, chosenRootInNewPod, publicIndexKnown, recordPlace, needsIndex } from '../core/place.mjs';
|
|
25
26
|
|
|
26
27
|
const SOLID = $rdf.Namespace('http://www.w3.org/ns/solid/terms#');
|
|
27
28
|
|
|
@@ -234,10 +235,14 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
234
235
|
mode, issuer, email, password, handle, name, podName,
|
|
235
236
|
kind = 'person', approveJoins = false, summary, icon, keys, uiPassword,
|
|
236
237
|
gateway = null, shape = 'pod', gatewayOrigin = 'https://fedipod.net',
|
|
238
|
+
// The container that holds `fedipod/` (blank: the pod's root), and the
|
|
239
|
+
// person's yes to a new public type index when their profile names none.
|
|
240
|
+
container = '', createIndex = false,
|
|
237
241
|
} = answers;
|
|
238
242
|
let { pod, root } = answers;
|
|
239
|
-
|
|
240
|
-
|
|
243
|
+
const rootGiven = !!root;
|
|
244
|
+
if (!root) root = 'fedipod/'; // until the pod is known; a resuming run
|
|
245
|
+
// overwrites this with the credential's own root below.
|
|
241
246
|
let accountWebId = null; // what createAccountWithPod reported, when it ran
|
|
242
247
|
// The private half always starts here, beside the credential and the keys —
|
|
243
248
|
// not on the pod. Every activity you receive would otherwise cost the pod
|
|
@@ -265,6 +270,9 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
265
270
|
skip('account', 'the credential is already on disk');
|
|
266
271
|
} else if (mode === 'new') {
|
|
267
272
|
begin('account');
|
|
273
|
+
// A new pod has no public type index: without a yes to making one,
|
|
274
|
+
// nothing is created at all.
|
|
275
|
+
if (!createIndex) throw needsIndex('A new pod has no public type index.');
|
|
268
276
|
const made = await createAccount({ issuer, email, password, podName: podName || handle });
|
|
269
277
|
pod = made.pod;
|
|
270
278
|
accountWebId = made.webId || null;
|
|
@@ -273,10 +281,15 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
273
281
|
// nobody could ever find this group. A person was warned before we got
|
|
274
282
|
// here and chose to continue; a group cannot.
|
|
275
283
|
if (kind === 'group' && !webfingerHost(pod)) {
|
|
276
|
-
throw new Error(`${issuer} created the pod at ${pod} — a
|
|
277
|
-
+ '
|
|
284
|
+
throw new Error(`${issuer} created the pod at ${pod} — a suffixed pod. `
|
|
285
|
+
+ 'WebFinger is only answered for a subdomained pod, so nobody '
|
|
278
286
|
+ 'could find this group. The pod exists; no actor was published.');
|
|
279
287
|
}
|
|
288
|
+
if (!rootGiven) {
|
|
289
|
+
const place = chosenRootInNewPod(pod.endsWith('/') ? pod : pod + '/', container);
|
|
290
|
+
if (place.problem) throw new Error(`Where to store it: ${place.problem}.`);
|
|
291
|
+
root = place.root;
|
|
292
|
+
}
|
|
280
293
|
done('account', pod);
|
|
281
294
|
} else {
|
|
282
295
|
// A pod you bring must be there and carry a usable WebID before a
|
|
@@ -284,7 +297,15 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
284
297
|
// reachable, and no silent 401 later on a pod whose profile is empty.
|
|
285
298
|
const usable = await checkPod(pod);
|
|
286
299
|
if (!usable.ok) throw new Error(usable.error);
|
|
287
|
-
const
|
|
300
|
+
const podBase = pod.endsWith('/') ? pod : pod + '/';
|
|
301
|
+
if (!rootGiven) {
|
|
302
|
+
const place = chosenRoot(podBase, container);
|
|
303
|
+
if (place.problem) throw new Error(`Where to store it: ${place.problem}.`);
|
|
304
|
+
root = place.root;
|
|
305
|
+
}
|
|
306
|
+
// An account at the chosen place, or an older one where accounts used to go.
|
|
307
|
+
const exists = (r) => (deps.resourceExists || resourceExists)(deps.fetch || fetch, apUrls(pod, r).actor);
|
|
308
|
+
const held = await exists(root) || (root !== DEFAULT_ROOT && await exists(DEFAULT_ROOT));
|
|
288
309
|
if (held && !frontedAddress({ pod, shape })) {
|
|
289
310
|
throw new Error('The pod already hosts a FediPod account. If you want a second account, put it on a different pod. '
|
|
290
311
|
+ 'To move an account whose address is at another gateway to this one, ask for an address at this gateway.');
|
|
@@ -292,6 +313,12 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
292
313
|
if (held && kind === 'group') throw new Error('The pod already hosts a FediPod account, and a group cannot move between gateways.');
|
|
293
314
|
movingIn = held;
|
|
294
315
|
if (movingIn) { privateRoot = null; }
|
|
316
|
+
// Asked before anything is minted: a profile with no public type index
|
|
317
|
+
// gets one only on its person's yes.
|
|
318
|
+
if (!movingIn && !createIndex
|
|
319
|
+
&& await (deps.publicIndexKnown || publicIndexKnown)(deps.fetch || fetch, podBase) !== true) {
|
|
320
|
+
throw needsIndex('Your profile names no public type index.');
|
|
321
|
+
}
|
|
295
322
|
skip('account', movingIn ? 'the pod already holds the account — its address moves here' : 'using the pod you already have');
|
|
296
323
|
}
|
|
297
324
|
|
|
@@ -366,8 +393,8 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
366
393
|
let gatewayCfg = gateway;
|
|
367
394
|
if (!gatewayCfg && frontedAddress({ pod, shape })) {
|
|
368
395
|
if (kind === 'group') {
|
|
369
|
-
throw new Error(`a group needs a pod
|
|
370
|
-
+ (webfingerHost(pod) ? '' : `; ${pod} is
|
|
396
|
+
throw new Error(`a group needs a subdomained pod — a Gateway address for a group is not supported yet`
|
|
397
|
+
+ (webfingerHost(pod) ? '' : `; ${pod} is suffixed`) + '.');
|
|
371
398
|
}
|
|
372
399
|
const urls = apUrls(pod, root || DEFAULT_ROOT);
|
|
373
400
|
const cred = JSON.parse(fs.readFileSync(credPath, 'utf8'));
|
|
@@ -414,6 +441,16 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
414
441
|
}
|
|
415
442
|
const published = await agent.publisher.publishProfile();
|
|
416
443
|
await agent.store.flush();
|
|
444
|
+
// Where the account lives, in the owner's public type index — made first
|
|
445
|
+
// if they said yes to one.
|
|
446
|
+
if (!movingIn) {
|
|
447
|
+
const urlsAt = apUrls(pod, root);
|
|
448
|
+
try {
|
|
449
|
+
await (deps.recordPlace || recordPlace)(agent.publisher?.remote || agent.remote, urlsAt.base, urlsAt.home + 'ap/actor', { create: !!createIndex });
|
|
450
|
+
} catch (e) {
|
|
451
|
+
throw new Error(`could not record where the account lives in your type index (${e.message})`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
417
454
|
if (movingIn) {
|
|
418
455
|
// Tell the old gateway and the followers. Not fatal: left pending, it
|
|
419
456
|
// is tried again when the agent next starts acting.
|
|
@@ -448,7 +485,7 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
448
485
|
log(wfHost
|
|
449
486
|
? `${kind === 'group' ? 'group' : 'actor'} published: @${handle}@${wfHost}`
|
|
450
487
|
: `${kind === 'group' ? 'group' : 'actor'} published, but not reachable as a handle — `
|
|
451
|
-
+ `${pod} is
|
|
488
|
+
+ `${pod} is a suffixed pod`);
|
|
452
489
|
} catch (e) {
|
|
453
490
|
for (const s of run.steps) if (s.state === 'running') s.state = 'error';
|
|
454
491
|
run.error = scrub(e.message);
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// account-agent.mjs — an account's agent, put together at the gateway for as
|
|
2
|
+
// long as one piece of work takes: a keeper run (keeper.mjs), or a request from
|
|
3
|
+
// a Mastodon app (masto-gateway.mjs).
|
|
4
|
+
//
|
|
5
|
+
// It works with the gateway's own pod identity and nothing else, which the
|
|
6
|
+
// owner's app named in the rules on the account's folder. The account's state
|
|
7
|
+
// is its copy at the gateway when there is one (copy.mjs), and the pod when
|
|
8
|
+
// there is not. The signing key is read from the pod when it is needed, never
|
|
9
|
+
// kept, never made.
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import { PodTransport } from '../pod/transport.mjs';
|
|
12
|
+
import { podBaseOfWebId } from '../pod/urls.mjs';
|
|
13
|
+
import { apUrls } from '../core/wire.mjs';
|
|
14
|
+
import { PodStore } from '../core/store.mjs';
|
|
15
|
+
import { HttpStorage } from '../core/storage.mjs';
|
|
16
|
+
import { Lease } from '../core/lease.mjs';
|
|
17
|
+
import { Deliverer } from '../core/deliver.mjs';
|
|
18
|
+
import { Publisher } from '../core/publisher/index.mjs';
|
|
19
|
+
import { Intake } from '../core/intake/index.mjs';
|
|
20
|
+
import { C2S } from '../client/c2s.mjs';
|
|
21
|
+
import { keeperSession } from './keeper-session.mjs';
|
|
22
|
+
import { CopyStorage, copyMeta, copyLease, keptBefore, GATEWAY_HOLDER } from './copy.mjs';
|
|
23
|
+
|
|
24
|
+
const RSA_ALG = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' };
|
|
25
|
+
|
|
26
|
+
// The account's signing key, as its owner's pod holds it. A key the pod does
|
|
27
|
+
// not hold in the open (a sealed one from before 1.28.0) cannot be used here.
|
|
28
|
+
async function podKeys(store) {
|
|
29
|
+
const rec = store.read('keys.json', null);
|
|
30
|
+
if (!rec?.rsa?.privatePem || !rec.rsa.publicPem) return null;
|
|
31
|
+
const der = crypto.createPrivateKey(rec.rsa.privatePem).export({ type: 'pkcs8', format: 'der' });
|
|
32
|
+
return {
|
|
33
|
+
rsaPrivate: await crypto.subtle.importKey('pkcs8', der, RSA_ALG, true, ['sign']),
|
|
34
|
+
rsaPublicPem: rec.rsa.publicPem,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Where the account is, and a transport to its pod as the gateway. Returns
|
|
40
|
+
* { remote, podFetch, urls, inCopy } or { skipped } when this gateway cannot
|
|
41
|
+
* act for it.
|
|
42
|
+
*/
|
|
43
|
+
export async function reachAccount(ctx, handle, rec, { log = console.log, session = null } = {}) {
|
|
44
|
+
if (!rec?.keeper || !ctx.keeperWebId || !(session || ctx.keeperCredential)) return { skipped: 'not kept' };
|
|
45
|
+
if (keptBefore(ctx, rec)) return { skipped: 'kept under the gateway\'s former identity; the owner\'s FediPod moves it over on its next start' };
|
|
46
|
+
if (!rec.webId || !rec.podHome) return { skipped: 'the row names no owner or pod' };
|
|
47
|
+
const remote = new PodTransport(session || keeperSession(ctx.keeperCredential), {
|
|
48
|
+
webId: ctx.keeperWebId, log, role: 'keeper', runtime: 'node', cooldownMode: 'refuse',
|
|
49
|
+
});
|
|
50
|
+
// Any rule it writes names the owner, and itself beside them.
|
|
51
|
+
remote.aclOwner = rec.webId;
|
|
52
|
+
remote.keepers = [ctx.keeperWebId];
|
|
53
|
+
remote.aclIfChanged = true;
|
|
54
|
+
const pod = podBaseOfWebId(rec.webId);
|
|
55
|
+
const root = rec.podHome.startsWith(pod) ? rec.podHome.slice(pod.length) : null;
|
|
56
|
+
if (root === null) return { skipped: 'the row\'s folder is not on the owner\'s pod' };
|
|
57
|
+
const inCopy = !!(ctx.copyKv && await copyMeta(ctx.copyKv, handle));
|
|
58
|
+
return { remote, podFetch: (u, i) => remote.fetch(u, i), pod, root, urls: apUrls(pod, root), inCopy };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The account's state store and lease, as `holder`: over the copy when there
|
|
63
|
+
* is one, else over the pod.
|
|
64
|
+
*/
|
|
65
|
+
export function stateAndLease(ctx, handle, at, { log = console.log, holder = GATEWAY_HOLDER } = {}) {
|
|
66
|
+
const podState = new HttpStorage(at.urls.state, at.podFetch);
|
|
67
|
+
if (at.inCopy) {
|
|
68
|
+
return {
|
|
69
|
+
// Of what stays on the pod, only the key: one read per piece of work.
|
|
70
|
+
store: new PodStore({ storage: new CopyStorage(ctx.copyKv, handle, { holder, pod: podState, podNames: ['keys.json'] }), log }),
|
|
71
|
+
lease: copyLease(ctx.copyKv, handle, { id: holder, log }),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
store: new PodStore({ storage: podState, log }),
|
|
76
|
+
lease: new Lease({ url: at.urls.state + 'lease.json', fetchImpl: at.podFetch, log, id: `keeper:${ctx.keeperWebId}` }),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The acting pieces over a loaded store: the same ids the owner's app uses
|
|
82
|
+
* (a fronted account advertises the gateway's, and every request is mapped
|
|
83
|
+
* back onto the pod), the key from the pod, a deliverer that sends only when
|
|
84
|
+
* asked, a publisher, and the intake. Returns the agent, or { skipped }.
|
|
85
|
+
*/
|
|
86
|
+
export async function actingAgent(at, store, lease, { log = console.log, push = false } = {}) {
|
|
87
|
+
const config = store.getConfig();
|
|
88
|
+
if (!config) return { skipped: 'no account on the pod' };
|
|
89
|
+
const publicBase = config.gateway?.frontActor ? config.gateway.frontActor.replace(/ap\/actor\/?$/u, '') : null;
|
|
90
|
+
const urls = apUrls(at.pod, config.root || at.root, { publicBase });
|
|
91
|
+
if (urls.toPod) at.remote.setUrlMap(urls.toPod);
|
|
92
|
+
const keys = await podKeys(store);
|
|
93
|
+
if (!keys) return { skipped: 'the signing key is not readable on the pod' };
|
|
94
|
+
const deliverer = new Deliverer({
|
|
95
|
+
store, rsaPrivate: keys.rsaPrivate, keyId: urls.actor + '#main-key', actorId: urls.actor, log, passive: true,
|
|
96
|
+
});
|
|
97
|
+
const publisher = new Publisher({ config, remote: at.remote, store, deliverer, publicKeyPem: keys.rsaPublicPem, log });
|
|
98
|
+
deliverer.onGone = () => publisher.publishCollections({ followers: true });
|
|
99
|
+
// What the owner's own posts, taken at the outbox door, need of an agent.
|
|
100
|
+
const agent = {
|
|
101
|
+
store, publisher, deliverer, remote: at.remote, urls, config, lease, intake: null, viewer: false,
|
|
102
|
+
configured: () => true, requestTakeover: async () => true,
|
|
103
|
+
};
|
|
104
|
+
const c2s = new C2S({ agent, log });
|
|
105
|
+
agent.intake = new Intake({
|
|
106
|
+
config, urls, remote: at.remote, store, deliverer, publisher, log, lease, push,
|
|
107
|
+
ownerPost: (a, o) => c2s.dispatch(a, o),
|
|
108
|
+
});
|
|
109
|
+
publisher.resolveActor = (u) => agent.intake.fetchAP(u);
|
|
110
|
+
return agent;
|
|
111
|
+
}
|