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.
Files changed (94) hide show
  1. package/README.md +29 -8
  2. package/architecture.md +8 -0
  3. package/bin/fedipod.mjs +6 -0
  4. package/cli.md +7 -1
  5. package/device-agent.md +3 -3
  6. package/gateway.md +69 -8
  7. package/groups.md +2 -1
  8. package/gui.md +3 -1
  9. package/lib/client/c2s.mjs +97 -43
  10. package/lib/client/masto/accounts.mjs +1 -0
  11. package/lib/client/masto/bridge.mjs +61 -0
  12. package/lib/client/masto/index.mjs +4 -1
  13. package/lib/client/masto/oauth.mjs +1 -1
  14. package/lib/client/masto/statuses.mjs +3 -0
  15. package/lib/connections/acctfeed.mjs +13 -9
  16. package/lib/connections/bskyfeed.mjs +13 -9
  17. package/lib/connections/tagfeed.mjs +14 -9
  18. package/lib/core/deliver.mjs +17 -1
  19. package/lib/core/intake/activities.mjs +18 -2
  20. package/lib/core/intake/group.mjs +3 -1
  21. package/lib/core/intake/index.mjs +226 -48
  22. package/lib/core/intake/notes.mjs +12 -5
  23. package/lib/core/intake/verify.mjs +11 -0
  24. package/lib/core/lease.mjs +15 -1
  25. package/lib/core/place.mjs +82 -0
  26. package/lib/core/pod-only.mjs +4 -0
  27. package/lib/core/publisher/collections.mjs +37 -2
  28. package/lib/core/publisher/index.mjs +25 -1
  29. package/lib/core/publisher/notes.mjs +73 -7
  30. package/lib/core/publisher/own.mjs +143 -0
  31. package/lib/core/publisher/questions.mjs +5 -3
  32. package/lib/core/scheduled.mjs +39 -0
  33. package/lib/core/social.mjs +62 -32
  34. package/lib/core/storage.mjs +67 -0
  35. package/lib/core/store.mjs +77 -5
  36. package/lib/core/wire.mjs +51 -15
  37. package/lib/device/admin/routes/lifecycle.mjs +1 -1
  38. package/lib/device/admin/routes/setup.mjs +17 -1
  39. package/lib/device/cli/commands/setup.mjs +50 -8
  40. package/lib/device/cli/context.mjs +1 -1
  41. package/lib/device/migrate.mjs +1 -1
  42. package/lib/device/setup.mjs +45 -8
  43. package/lib/gateway/account-agent.mjs +111 -0
  44. package/lib/gateway/copy.mjs +381 -0
  45. package/lib/gateway/front-core.mjs +74 -63
  46. package/lib/gateway/gateway-core.mjs +107 -9
  47. package/lib/gateway/held-mail.mjs +198 -0
  48. package/lib/gateway/keeper-due.mjs +39 -0
  49. package/lib/gateway/keeper-session.mjs +10 -0
  50. package/lib/gateway/keeper.mjs +72 -0
  51. package/lib/gateway/masto-gateway.mjs +510 -0
  52. package/lib/gateway/quiet.mjs +7 -2
  53. package/lib/gateway/relay-extras.mjs +89 -0
  54. package/lib/gateway/state-api.mjs +207 -0
  55. package/lib/gateway/token-claims.mjs +16 -0
  56. package/lib/pod/containers.mjs +17 -0
  57. package/lib/pod/location.mjs +52 -0
  58. package/lib/pod/notes.mjs +2 -4
  59. package/lib/pod/transport.mjs +214 -24
  60. package/lib/pod/type-index.mjs +101 -0
  61. package/lib/pod/urls.mjs +6 -0
  62. package/lib/server/embed.mjs +7 -6
  63. package/lib/session/README.md +5 -5
  64. package/lib/session/demo.html +1 -1
  65. package/lib/session/fedi-account.mjs +19 -10
  66. package/lib/session/package.json +2 -2
  67. package/package.json +2 -2
  68. package/run-agent.mjs +6 -16
  69. package/scripts/stage-site.mjs +17 -4
  70. package/web/admin/actors.js +2 -0
  71. package/web/admin/gateway.js +14 -1
  72. package/web/admin/index.html +13 -0
  73. package/web/admin/oauth-signin.mjs +1 -1
  74. package/web/admin/record.js +4 -1
  75. package/web/admin/setup/index.html +17 -1
  76. package/web/admin/setup/setup.js +18 -5
  77. package/web/app/README.md +2 -2
  78. package/web/app/admin-facade.mjs +12 -2
  79. package/web/app/agent.mjs +228 -51
  80. package/web/app/boot.mjs +75 -32
  81. package/web/app/copy-mode.mjs +223 -0
  82. package/web/app/dist/boot.js +498 -88
  83. package/web/app/dist/boot.js.map +4 -4
  84. package/web/app/dist/sw.js +4016 -2539
  85. package/web/app/dist/sw.js.map +4 -4
  86. package/web/app/index.html +16 -0
  87. package/web/app/signup.mjs +65 -26
  88. package/web/app/sw-src.mjs +15 -53
  89. package/web/app/update.js +3 -2
  90. package/web/app/warm-start.mjs +115 -0
  91. package/web/app-signin/app-signin.mjs +78 -0
  92. package/web/app-signin/index.html +41 -0
  93. package/web/front/run.html +7 -1
  94. 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: 'Note',
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, 1),
428
- last: followersPageId(id, pages),
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 < pageCount ? { next: followersPageId(id, n + 1) } : {}),
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
- export function likeActivity({ urls, noteId, serial }) {
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.actor + '#announce-' + serial,
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.id + '#update-' + (note.updated ? String(note.updated).replace(/[^0-9TZ]/g, '') : 'q' + (serial ?? Date.now())),
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
- export function deleteActivity({ urls, noteId }) {
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 + '#delete',
879
+ id: deleteActivityId(noteId),
849
880
  type: 'Delete',
850
881
  actor: urls.actor,
851
- to: [PUBLIC],
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.actor + '#undo-' + serial,
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 a suffix-based host, so WebFinger cannot answer for it '
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
- const root = flag('root') || 'fedipod/'; // new installs default to the fedipod/ container
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
- if (await resourceExists(fetch, apUrls(pod, DR).actor)) {
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 path on ${new URL(pod).host}, not the root of its own host.`);
119
- console.error('WebFinger is answered only at a host root, so nobody could find this group.');
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 suffix-based host.`);
153
- console.log('WebFinger is answered only at a host root, so nobody could find this group.');
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} puts pods on paths, so your address will be @${handle}@${gatewayHost}.`);
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 not a host root`);
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
@@ -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',
@@ -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
- if (!root) root = 'fedipod/'; // new installs default to the fedipod/ container; a
240
- // resuming run overwrites this with the credential's own root below.
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 suffix-based host, `
277
- + 'not a host root. WebFinger is only answered at a host root, so nobody '
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 held = await (deps.resourceExists || resourceExists)(deps.fetch || fetch, apUrls(pod, root || DEFAULT_ROOT).actor);
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 at its own host — a Gateway address for a group is not supported yet`
370
- + (webfingerHost(pod) ? '' : `; ${pod} is a suffix-based host`) + '.');
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 not a host root`);
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
+ }