fedipod-server 0.11.0 → 0.12.1

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 (142) hide show
  1. package/README.md +15 -6
  2. package/dist/handler.d.ts +6 -0
  3. package/dist/handler.js +23 -10
  4. package/dist/handler.jsonld +4 -0
  5. package/dist/store-pod.js +18 -4
  6. package/lib/{c2s.mjs → client/c2s.mjs} +8 -3
  7. package/lib/{localapi.mjs → client/localapi.mjs} +2 -2
  8. package/lib/client/masto/accounts.mjs +264 -0
  9. package/lib/client/masto/body.mjs +69 -0
  10. package/lib/client/masto/index.mjs +183 -0
  11. package/lib/client/masto/instance.mjs +104 -0
  12. package/lib/client/masto/media.mjs +133 -0
  13. package/lib/client/masto/oauth.mjs +599 -0
  14. package/lib/client/masto/render.mjs +459 -0
  15. package/lib/client/masto/statuses.mjs +331 -0
  16. package/lib/client/masto/timelines.mjs +316 -0
  17. package/lib/{streaming.mjs → client/streaming.mjs} +1 -1
  18. package/lib/{acctfeed.mjs → connections/acctfeed.mjs} +1 -1
  19. package/lib/{atproto.mjs → connections/atproto.mjs} +15 -16
  20. package/lib/{bskygroup.mjs → connections/bskygroup.mjs} +1 -1
  21. package/lib/{fediacct.mjs → connections/fediacct.mjs} +31 -35
  22. package/lib/{import.mjs → connections/import.mjs} +1 -1
  23. package/lib/{tagfeed.mjs → connections/tagfeed.mjs} +3 -3
  24. package/lib/connections/vault.mjs +114 -0
  25. package/lib/core/as2.mjs +124 -0
  26. package/lib/core/contexts/activitystreams.json +379 -0
  27. package/lib/core/contexts/did-v1.json +57 -0
  28. package/lib/core/contexts/fep-5711.json +36 -0
  29. package/lib/core/contexts/gotosocial.json +86 -0
  30. package/lib/core/contexts/identity-v1.json +152 -0
  31. package/lib/core/contexts/index.mjs +45 -0
  32. package/lib/core/contexts/join-lemmy.json +33 -0
  33. package/lib/core/contexts/joinmastodon.json +28 -0
  34. package/lib/core/contexts/map.json +16 -0
  35. package/lib/core/contexts/miscellany.json +19 -0
  36. package/lib/core/contexts/schemaorg.json +8845 -0
  37. package/lib/core/contexts/security-data-integrity-v1.json +78 -0
  38. package/lib/core/contexts/security-data-integrity-v2.json +81 -0
  39. package/lib/core/contexts/security-multikey-v1.json +35 -0
  40. package/lib/core/contexts/security-v1.json +74 -0
  41. package/lib/core/contexts/webfinger.json +10 -0
  42. package/lib/{deliver.mjs → core/deliver.mjs} +2 -2
  43. package/lib/core/intake/activities.mjs +437 -0
  44. package/lib/core/intake/activity.mjs +240 -0
  45. package/lib/core/intake/channel.mjs +144 -0
  46. package/lib/core/intake/group.mjs +222 -0
  47. package/lib/core/intake/index.mjs +629 -0
  48. package/lib/core/intake/notes.mjs +288 -0
  49. package/lib/core/intake/verify.mjs +141 -0
  50. package/lib/{keys.mjs → core/keys.mjs} +1 -1
  51. package/lib/core/publisher/collections.mjs +229 -0
  52. package/lib/core/publisher/index.mjs +421 -0
  53. package/lib/core/publisher/notes.mjs +188 -0
  54. package/lib/core/publisher/questions.mjs +233 -0
  55. package/lib/core/publisher/restore.mjs +196 -0
  56. package/lib/core/shapes/activitystreams.ttl +129 -0
  57. package/lib/core/shapes/index.mjs +107 -0
  58. package/lib/core/shapes/shapes-text.mjs +13 -0
  59. package/lib/{social.mjs → core/social.mjs} +2 -2
  60. package/lib/{store.mjs → core/store.mjs} +4 -0
  61. package/lib/{wire.mjs → core/wire.mjs} +2 -2
  62. package/lib/device/admin/index.mjs +13 -0
  63. package/lib/device/admin/origins.mjs +35 -0
  64. package/lib/device/admin/routes/connections.mjs +144 -0
  65. package/lib/device/admin/routes/gateway.mjs +199 -0
  66. package/lib/device/admin/routes/lifecycle.mjs +191 -0
  67. package/lib/device/admin/routes/owner.mjs +322 -0
  68. package/lib/device/admin/routes/setup.mjs +393 -0
  69. package/lib/device/admin/routes/social.mjs +188 -0
  70. package/lib/device/admin/server.mjs +95 -0
  71. package/lib/device/admin/static.mjs +244 -0
  72. package/lib/device/admin/surface.mjs +274 -0
  73. package/lib/device/cli/commands/account.mjs +586 -0
  74. package/lib/device/cli/commands/run.mjs +278 -0
  75. package/lib/device/cli/commands/service.mjs +221 -0
  76. package/lib/device/cli/commands/setup.mjs +410 -0
  77. package/lib/device/cli/commands/state.mjs +559 -0
  78. package/lib/device/cli/context.mjs +288 -0
  79. package/lib/{migrate.mjs → device/migrate.mjs} +1 -1
  80. package/lib/{remote.mjs → device/remote.mjs} +3 -3
  81. package/lib/{setup.mjs → device/setup.mjs} +3 -3
  82. package/lib/{update.mjs → device/update.mjs} +1 -1
  83. package/lib/{directory.mjs → gateway/directory.mjs} +1 -1
  84. package/lib/{front-core.mjs → gateway/front-core.mjs} +3 -3
  85. package/lib/{gateway-core.mjs → gateway/gateway-core.mjs} +1 -1
  86. package/lib/{httpsig.mjs → gateway/httpsig.mjs} +1 -1
  87. package/lib/{embed.mjs → server/embed.mjs} +149 -22
  88. package/lib/{links.mjs → shared/links.mjs} +1 -1
  89. package/lib/{ua.mjs → shared/ua.mjs} +1 -1
  90. package/package.json +1 -1
  91. package/run-agent.mjs +33 -25
  92. package/web/admin/actors.js +145 -0
  93. package/web/admin/common.js +23 -0
  94. package/web/admin/connections.js +112 -0
  95. package/web/admin/gateway.js +111 -0
  96. package/web/admin/group.js +258 -0
  97. package/web/admin/index.html +7 -1
  98. package/web/admin/record.js +378 -0
  99. package/web/admin/setup/index.html +1 -0
  100. package/web/admin/setup/setup.js +2 -13
  101. package/web/admin/upkeep.js +170 -0
  102. package/web/app/README.md +6 -6
  103. package/web/app/admin-facade.mjs +3 -3
  104. package/web/app/agent.mjs +12 -12
  105. package/web/app/atproto-browser.mjs +1 -1
  106. package/web/app/deliver-relay.mjs +1 -1
  107. package/web/app/dist/sw.js +21684 -5415
  108. package/web/app/dist/sw.js.map +4 -4
  109. package/web/app/fediacct-browser.mjs +1 -1
  110. package/web/app/shims/shapes-text.mjs +8 -0
  111. package/web/app/site/admin/actors.js +145 -0
  112. package/web/app/site/admin/common.js +23 -0
  113. package/web/app/site/admin/connections.js +112 -0
  114. package/web/app/site/admin/gateway.js +111 -0
  115. package/web/app/site/admin/group.js +258 -0
  116. package/web/app/site/admin/index.html +7 -1
  117. package/web/app/site/admin/record.js +378 -0
  118. package/web/app/site/admin/setup/index.html +1 -0
  119. package/web/app/site/admin/setup/setup.js +2 -13
  120. package/web/app/site/admin/upkeep.js +170 -0
  121. package/web/app/site/sw.js +21684 -5415
  122. package/web/app/sw-src.mjs +17 -2
  123. package/lib/admin.mjs +0 -1913
  124. package/lib/intake.mjs +0 -1981
  125. package/lib/mastoapi.mjs +0 -2284
  126. package/lib/publisher.mjs +0 -1192
  127. package/web/admin/admin.js +0 -1181
  128. package/web/app/site/admin/admin.js +0 -1181
  129. /package/lib/{oidc-auth.mjs → client/oidc-auth.mjs} +0 -0
  130. /package/lib/{webpush.mjs → client/webpush.mjs} +0 -0
  131. /package/lib/{bskyfeed.mjs → connections/bskyfeed.mjs} +0 -0
  132. /package/lib/{lease.mjs → core/lease.mjs} +0 -0
  133. /package/lib/{polls.mjs → core/polls.mjs} +0 -0
  134. /package/lib/{proof.mjs → core/proof.mjs} +0 -0
  135. /package/lib/{storage.mjs → core/storage.mjs} +0 -0
  136. /package/lib/{account.mjs → device/account.mjs} +0 -0
  137. /package/lib/{certs.mjs → device/certs.mjs} +0 -0
  138. /package/lib/{export-collections.mjs → device/export-collections.mjs} +0 -0
  139. /package/lib/{home.mjs → device/home.mjs} +0 -0
  140. /package/lib/{ports.mjs → device/ports.mjs} +0 -0
  141. /package/lib/{guard.mjs → shared/guard.mjs} +0 -0
  142. /package/lib/{safefetch.mjs → shared/safefetch.mjs} +0 -0
@@ -7,10 +7,8 @@
7
7
  // was connected for — the keys.json rules). The app password is kept so an
8
8
  // expired refresh token re-logins instead of demanding a reconnect.
9
9
 
10
- import fs from 'node:fs';
11
- import path from 'node:path';
12
- import { writeJsonAtomic } from './home.mjs';
13
- import { safeFetch, retryAfterMs } from './safefetch.mjs';
10
+ import { fileVault } from './vault.mjs';
11
+ import { safeFetch, retryAfterMs } from '../shared/safefetch.mjs';
14
12
 
15
13
  const FILE = 'atproto.json';
16
14
  const POST_NSID = 'app.bsky.feed.post';
@@ -64,11 +62,15 @@ export function bskyText(text, noteUrl) {
64
62
  return { text: out, facets, truncated: true };
65
63
  }
66
64
 
67
- const filePath = (dir) => path.join(dir, FILE);
68
-
69
65
  export class Atproto {
70
- constructor({ localDir, actorId = null, log = console.log, fetcher = null }) {
71
- this.localDir = localDir;
66
+ /**
67
+ * `vault` is where the app password is kept: this machine when the identity
68
+ * runs on a laptop, its own pod when it runs inside the pod server. Absent,
69
+ * it is the file beside the credential, which is what a laptop wants.
70
+ */
71
+ constructor({ localDir, vault = null, actorId = null, log = console.log, fetcher = null }) {
72
+ this.vault = vault || fileVault(localDir);
73
+ this.name = vault ? 'bluesky' : FILE.replace(/\.json$/u, '');
72
74
  this.actorId = actorId;
73
75
  this.log = log;
74
76
  // Injectable for tests; the default is the politeness stack.
@@ -80,19 +82,16 @@ export class Atproto {
80
82
  // The stamped-record guard: a credential minted for another actor is treated
81
83
  // as absent, never silently adopted across identities.
82
84
  read() {
83
- let rec;
84
- try { rec = JSON.parse(fs.readFileSync(filePath(this.localDir), 'utf8')); } catch { return null; }
85
+ const rec = this.vault.read(this.name);
86
+ if (!rec) return null;
85
87
  if (rec?.mintedFor && this.actorId && rec.mintedFor !== this.actorId) {
86
- this.log(`atproto.json belongs to ${rec.mintedFor} — not reusing it for ${this.actorId}`);
88
+ this.log(`the Bluesky connection belongs to ${rec.mintedFor} — not reusing it for ${this.actorId}`);
87
89
  return null;
88
90
  }
89
91
  return rec;
90
92
  }
91
93
 
92
- write(rec) {
93
- fs.mkdirSync(this.localDir, { recursive: true, mode: 0o700 });
94
- writeJsonAtomic(filePath(this.localDir), rec);
95
- }
94
+ write(rec) { this.vault.write(this.name, rec); }
96
95
 
97
96
  connected() { return !!this.read()?.did; }
98
97
 
@@ -162,7 +161,7 @@ export class Atproto {
162
161
  method: 'POST', headers: { authorization: `Bearer ${rec.refreshJwt}` },
163
162
  }).catch(() => {});
164
163
  }
165
- try { fs.rmSync(filePath(this.localDir)); } catch {}
164
+ await this.vault.remove(this.name);
166
165
  this.log('bluesky disconnected');
167
166
  }
168
167
 
@@ -20,7 +20,7 @@ export class BskyGroup {
20
20
  // whatever record the caller keeps, so one join costs one probe.
21
21
  async isBridged(did) {
22
22
  try {
23
- const { safeFetch } = await import('./safefetch.mjs');
23
+ const { safeFetch } = await import('../shared/safefetch.mjs');
24
24
  const doFetch = this.fetcher || ((u, i) => safeFetch(u, i));
25
25
  const res = await doFetch(BRIDGE_ACTOR(did), { headers: { accept: AP_ACCEPT } });
26
26
  return res.status < 400;
@@ -4,20 +4,20 @@
4
4
  // one atproto.mjs has to a Bluesky account. Nothing here is a second identity:
5
5
  // no key, no pod, nothing published.
6
6
  //
7
- // One credential per account at AP_HOME/fediaccts/<id>.json (0600, atomic,
8
- // stamped with the actor it was connected for the keys.json rules). A file
9
- // stamped for someone else is skipped rather than fatal, so one stray record
10
- // cannot cost the owner the whole roster.
7
+ // One credential per account, stamped with the actor it was connected for
8
+ // (the keys.json rules). A record stamped for someone else is skipped rather
9
+ // than fatal, so one stray one cannot cost the owner the whole roster.
11
10
  //
12
- // An access token here is full access to that account on that server, so it
13
- // never reaches pod state: config.json gets the handle and the host, nothing
14
- // more.
11
+ // Where they are kept is vault.mjs's decision: this machine when the identity
12
+ // runs on a laptop, the identity's own pod when it runs inside the pod server.
13
+ // Either way config.json gets the handle and the host and nothing more — the
14
+ // roster is public to anyone who can read the identity's config, the token
15
+ // never is.
15
16
 
16
- import fs from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import crypto from 'node:crypto';
19
- import { writeJsonAtomic } from './home.mjs';
20
- import { safeFetch, retryAfterMs } from './safefetch.mjs';
19
+ import { fileVault } from './vault.mjs';
20
+ import { safeFetch, retryAfterMs } from '../shared/safefetch.mjs';
21
21
 
22
22
  const DIR = 'fediaccts';
23
23
  const APPS = '_apps';
@@ -42,8 +42,14 @@ export function cleanHost(host) {
42
42
  }
43
43
 
44
44
  export class FediAccounts {
45
- constructor({ localDir, actorId = null, log = console.log, fetcher = null }) {
46
- this.dir = path.join(localDir, DIR);
45
+ /**
46
+ * `vault` and `apps` are where the credentials and the per-host app
47
+ * registrations live. Absent, they are the directories beside the
48
+ * credential file, which is what a laptop wants.
49
+ */
50
+ constructor({ localDir, vault = null, apps = null, actorId = null, log = console.log, fetcher = null }) {
51
+ this.vault = vault || fileVault(path.join(localDir, DIR));
52
+ this.apps = apps || fileVault(path.join(localDir, DIR, APPS));
47
53
  this.actorId = actorId;
48
54
  this.log = log;
49
55
  // Injectable for tests; the default is the politeness stack, which also
@@ -55,28 +61,19 @@ export class FediAccounts {
55
61
 
56
62
  // ---- records ----
57
63
 
58
- _path(id) { return path.join(this.dir, `${id}.json`); }
59
-
60
- ids() {
61
- let names = [];
62
- try { names = fs.readdirSync(this.dir); } catch { return []; }
63
- return names.filter(n => n.endsWith('.json') && !n.startsWith('_')).map(n => n.slice(0, -5));
64
- }
64
+ ids() { return this.vault.names().filter(n => !n.startsWith('_')); }
65
65
 
66
66
  read(id) {
67
- let rec;
68
- try { rec = JSON.parse(fs.readFileSync(this._path(id), 'utf8')); } catch { return null; }
67
+ const rec = this.vault.read(id);
68
+ if (!rec) return null;
69
69
  if (rec?.mintedFor && this.actorId && rec.mintedFor !== this.actorId) {
70
- this.log(`fediaccts/${id}.json belongs to ${rec.mintedFor} — not reusing it for ${this.actorId}`);
70
+ this.log(`the connection record for ${id} belongs to ${rec.mintedFor} — not reusing it for ${this.actorId}`);
71
71
  return null;
72
72
  }
73
73
  return rec;
74
74
  }
75
75
 
76
- write(rec) {
77
- fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
78
- writeJsonAtomic(this._path(rec.id), rec);
79
- }
76
+ write(rec) { this.vault.write(rec.id, rec); }
80
77
 
81
78
  list() { return this.ids().map(id => this.read(id)).filter(Boolean); }
82
79
 
@@ -106,9 +103,9 @@ export class FediAccounts {
106
103
  return this.roster().find(r => r.id === id) || null;
107
104
  }
108
105
 
109
- remove(id) {
106
+ async remove(id) {
110
107
  if (!this.read(id)) return false;
111
- try { fs.rmSync(this._path(id)); } catch { return false; }
108
+ if (!await this.vault.remove(id)) return false;
112
109
  this.log(`Fediverse account disconnected: ${id}`);
113
110
  return true;
114
111
  }
@@ -143,11 +140,8 @@ export class FediAccounts {
143
140
  // was registered, so an agent that moved to another port re-registers rather
144
141
  // than sending the server a redirect it will refuse.
145
142
  async appFor(host, redirectUri) {
146
- const file = path.join(this.dir, APPS, `${host}.json`);
147
- try {
148
- const app = JSON.parse(fs.readFileSync(file, 'utf8'));
149
- if (app.redirectUri === redirectUri && app.clientId && app.clientSecret) return app;
150
- } catch { /* not registered here yet */ }
143
+ const known = this.apps.read(host);
144
+ if (known?.redirectUri === redirectUri && known.clientId && known.clientSecret) return known;
151
145
  const res = await this._fetch(host, `https://${host}/api/v1/apps`, {
152
146
  method: 'POST',
153
147
  headers: { 'content-type': 'application/json' },
@@ -159,8 +153,7 @@ export class FediAccounts {
159
153
  const body = await this._json(res, host);
160
154
  if (!body.client_id || !body.client_secret) throw new Error(`${host} registered no client`);
161
155
  const app = { host, redirectUri, clientId: body.client_id, clientSecret: body.client_secret };
162
- fs.mkdirSync(path.join(this.dir, APPS), { recursive: true, mode: 0o700 });
163
- writeJsonAtomic(file, app);
156
+ this.apps.write(host, app);
164
157
  return app;
165
158
  }
166
159
 
@@ -213,6 +206,9 @@ export class FediAccounts {
213
206
  addedAt: new Date().toISOString(), enabled: true,
214
207
  ...(this.actorId ? { mintedFor: this.actorId } : {}),
215
208
  });
209
+ // The owner has just been through a browser to get this; it is written
210
+ // down before they are told it worked.
211
+ if (!await this.vault.commit()) throw new Error('the connection could not be written down');
216
212
  this.log(`Fediverse account connected: @${me.username}@${host}`);
217
213
  return this.roster().find(r => r.id === id);
218
214
  }
@@ -13,7 +13,7 @@
13
13
  // still there and still pending.
14
14
 
15
15
  import crypto from 'node:crypto';
16
- import { lookupWebFinger, selfLink, confirmDelegation, followActor, unfollowActor } from './social.mjs';
16
+ import { lookupWebFinger, selfLink, confirmDelegation, followActor, unfollowActor } from '../core/social.mjs';
17
17
 
18
18
  export const IMPORT_STATE_DOC = 'import-state.json';
19
19
  export const IMPORT_KINDS = ['follow', 'block', 'mute', 'list', 'domain'];
@@ -6,7 +6,7 @@
6
6
  // Config in tagfeed.json: { instance, tags: [...], intervalMin }. Every note
7
7
  // is still verified by dereference at its origin before it is mirrored.
8
8
 
9
- import { isContentType, authorOf } from './intake.mjs';
9
+ import { isContentType, authorOf } from '../core/intake/index.mjs';
10
10
 
11
11
  // A public timeline page from somebody else's instance. Generous for 20 posts,
12
12
  // and a bound where there was none.
@@ -102,7 +102,7 @@ export class TagFeed {
102
102
  for (const tag of tags) {
103
103
  let list;
104
104
  try {
105
- const { safeFetch, retryAfterMs, readCapped } = await import('./safefetch.mjs');
105
+ const { safeFetch, retryAfterMs, readCapped } = await import('../shared/safefetch.mjs');
106
106
  const url = `${instance}/api/v1/timelines/tag/${encodeURIComponent(tag)}?limit=${PER_TAG}`;
107
107
  const res = this.fetcher === globalThis.fetch
108
108
  ? await safeFetch(url, { headers: { accept: 'application/json' } })
@@ -149,7 +149,7 @@ export class TagFeed {
149
149
  if (!this.store.getActors()[author]) {
150
150
  await this.intake.fetchAP(author).catch(() => {}); // warm name+avatar
151
151
  }
152
- const { attachmentsOf, titledContent } = await import('./wire.mjs');
152
+ const { attachmentsOf, titledContent } = await import('../core/wire.mjs');
153
153
  const attachments = attachmentsOf(note);
154
154
  this.store.addStatus({
155
155
  noteId, actor: author, content: titledContent(note),
@@ -0,0 +1,114 @@
1
+ // vault.mjs — where an identity keeps the credentials for accounts it holds on
2
+ // OTHER servers: a full-access token on a Mastodon-API server, a Bluesky app
3
+ // password. Not its own key, and nothing published.
4
+ //
5
+ // Two places, and which one is right depends on where the identity runs. On a
6
+ // laptop the pod is somebody else's server, so a token goes on this machine
7
+ // and nowhere near the pod. Inside a pod server the two are the same computer,
8
+ // and the pod is the thing that travels — a token left on the host is a
9
+ // connection the owner loses the day they take their pod elsewhere.
10
+ //
11
+ // The question is where the agent runs, not where its signing key is: sharing
12
+ // a key through the pod is a laptop's own choice and says nothing about a
13
+ // token for somebody else's server.
14
+ //
15
+ // `read`, `write` and `names` are synchronous because every caller here is.
16
+ // The pod's writes land through the store's own retrying queue; `commit`
17
+ // waits for them, and callers that have just taken a credential from a remote
18
+ // server use it before they answer.
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { writeJsonAtomic } from '../device/home.mjs';
22
+
23
+ /** What every connection document in pod state is named from. */
24
+ export const CONNECTION_PREFIX = 'conn-';
25
+
26
+ /** One directory, one document per name, 0600 and atomic — the laptop. */
27
+ export function fileVault(dir) {
28
+ const file = (name) => path.join(dir, `${name}.json`);
29
+ return {
30
+ kind: 'file',
31
+ where: dir,
32
+ names() {
33
+ try { return fs.readdirSync(dir).filter(n => n.endsWith('.json')).map(n => n.slice(0, -5)); }
34
+ catch { return []; }
35
+ },
36
+ read(name) {
37
+ try { return JSON.parse(fs.readFileSync(file(name), 'utf8')); } catch { return null; }
38
+ },
39
+ write(name, rec) {
40
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
41
+ writeJsonAtomic(file(name), rec);
42
+ },
43
+ async remove(name) {
44
+ try { fs.rmSync(file(name)); return true; } catch { return false; }
45
+ },
46
+ async commit() { return true; },
47
+ };
48
+ }
49
+
50
+ /** Documents in the identity's own pod state, one per name under `prefix`. */
51
+ export function podVault(store, prefix) {
52
+ const doc = (name) => `${prefix}${name}.json`;
53
+ return {
54
+ kind: 'pod',
55
+ where: `pod state (${prefix}*)`,
56
+ names() {
57
+ return store.names()
58
+ .filter(n => n.startsWith(prefix) && n.endsWith('.json'))
59
+ .map(n => n.slice(prefix.length, -5));
60
+ },
61
+ read(name) { return store.read(doc(name), null); },
62
+ write(name, rec) { store.write(doc(name), rec); },
63
+ async remove(name) { return store.remove(doc(name)); },
64
+ async commit() { return store.commit(); },
65
+ };
66
+ }
67
+
68
+ /**
69
+ * The vaults one agent's connections use.
70
+ *
71
+ * `embedded` is the whole question: an agent running inside the server that
72
+ * holds its pod is on the pod's own host, so a credential left on the machine
73
+ * is one its owner loses the day they take the pod elsewhere. An agent on a
74
+ * laptop is not, and its tokens stay where they are — including when the
75
+ * owner has chosen to share their SIGNING key through the pod, which says
76
+ * nothing about a token for somebody else's server.
77
+ */
78
+ export function vaultsFor({ embedded, home, store }) {
79
+ if (!embedded) {
80
+ return { bluesky: null, fedi: null, apps: null }; // the classes' own defaults, beside the credential
81
+ }
82
+ return {
83
+ bluesky: podVault(store, CONNECTION_PREFIX),
84
+ fedi: podVault(store, `${CONNECTION_PREFIX}fedi-`),
85
+ apps: podVault(store, `${CONNECTION_PREFIX}fediapp-`),
86
+ home,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Move whatever a file vault holds into a pod vault, and only then take the
92
+ * host's copies away. A token here is full access to an account on a server
93
+ * this project does not run: losing one in the move is a connection its owner
94
+ * has to make again, and cannot be told about.
95
+ */
96
+ export async function moveVault(from, to, log = () => {}) {
97
+ const names = from.names();
98
+ if (!names.length) return 0;
99
+ let moved = 0;
100
+ for (const name of names) {
101
+ const rec = from.read(name);
102
+ if (!rec) continue;
103
+ if (to.read(name)) { await from.remove(name); continue; }
104
+ to.write(name, rec);
105
+ moved += 1;
106
+ }
107
+ if (!await to.commit()) {
108
+ log('could not write the connected-account credentials to the pod — they stay on this host for now');
109
+ return 0;
110
+ }
111
+ for (const name of names) await from.remove(name);
112
+ if (moved) log(`${moved} connected-account credential(s) moved into the pod`);
113
+ return moved;
114
+ }
@@ -0,0 +1,124 @@
1
+ // as2.mjs — the one place ActivityStreams bytes become something to handle.
2
+ //
3
+ // An AS2 document IS a JSON-LD document; ActivityStreams 2.0 Core says so. We
4
+ // read it as one. Expanding it to a graph is what makes the document mean the
5
+ // same thing however its sender chose to write it — `"type": "Create"` and
6
+ // `"@type": "as:Create"` are the same statement, and only a JSON-LD reader
7
+ // knows that. Compacting it back against the standard context is what lets the
8
+ // handlers stay as they are: whatever arrived, they see an ordinary document.
9
+ //
10
+ // What we STORE and SEND is untouched by any of this. Bytes go into the pod as
11
+ // they arrived, and lib/core/wire.mjs builds what we send as compacted AS2
12
+ // with a plain string @context, as it always did. This is a reading layer.
13
+ //
14
+ // Every document goes through the processor. There is no shortcut for the
15
+ // common shape: one that skipped it would not be read as JSON-LD, and would
16
+ // produce no graph, so nothing could be validated against it.
17
+ //
18
+ // CONTEXTS ARE NEVER FETCHED. `contextsFor` serves the fourteen the fediverse
19
+ // actually uses and refuses every other URL. That refusal is the point: this
20
+ // code runs on documents a stranger wrote, in the same path the SSRF guards
21
+ // protect, and expanding one would otherwise mean dereferencing a URL of their
22
+ // choosing. A document naming a context we do not hold is refused with a
23
+ // reason, which is a visible drop rather than a silent one.
24
+
25
+ import jsonld from 'jsonld';
26
+ import { AS_CTX } from './wire.mjs';
27
+ import { CONTEXTS } from './contexts/index.mjs';
28
+
29
+ export { CONTEXTS };
30
+
31
+ /** Why a document could not be read. Carried to the caller so a drop is explained. */
32
+ export class AS2Error extends Error {
33
+ constructor(reason, { context = null } = {}) {
34
+ super(reason);
35
+ this.name = 'AS2Error';
36
+ this.context = context;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * A loader over the contexts we hold, and nothing else.
42
+ *
43
+ * jsonld calls this for every `@context` URL in a document. Anything not in
44
+ * the map throws rather than being fetched, so a document cannot make this
45
+ * process reach out to an address its sender picked.
46
+ */
47
+ export function contextLoader(url) {
48
+ const doc = CONTEXTS[url];
49
+ if (!doc) {
50
+ return Promise.reject(new AS2Error(`context not held here: ${url}`, { context: url }));
51
+ }
52
+ return Promise.resolve({ contextUrl: null, documentUrl: url, document: doc });
53
+ }
54
+
55
+ /**
56
+ * Read AS2 bytes.
57
+ *
58
+ * Returns `{ doc, graph }` — the document compacted against the standard
59
+ * context, which is what handlers read, and its quads, which is what a shape
60
+ * is validated against. Throws AS2Error when the document cannot be read as
61
+ * JSON-LD at all.
62
+ *
63
+ * `raw` is a string or a parsed object; both arrive here in practice.
64
+ */
65
+ export async function parseAS2(raw) {
66
+ let input;
67
+ if (typeof raw === 'string') {
68
+ try {
69
+ input = JSON.parse(raw);
70
+ } catch (e) {
71
+ throw new AS2Error(`not JSON: ${e.message}`);
72
+ }
73
+ } else {
74
+ input = raw;
75
+ }
76
+ if (!input || typeof input !== 'object') throw new AS2Error('not a JSON object');
77
+
78
+ // A document with no @context expands to nothing at all — every term is
79
+ // dropped, and the result is an empty graph rather than an error. Silently
80
+ // reading a delivery as "no statements" is the worst of the outcomes, so it
81
+ // is named here instead.
82
+ if (input['@context'] === undefined) throw new AS2Error('no @context');
83
+
84
+ const options = { documentLoader: contextLoader };
85
+ let graph;
86
+ let doc;
87
+ try {
88
+ graph = await jsonld.toRDF(input, options); // RDF/JS quads, which is what a shape is checked against
89
+ doc = await jsonld.compact(input, AS_CTX, options);
90
+ } catch (e) {
91
+ if (e instanceof AS2Error) throw e;
92
+ // jsonld wraps a loader rejection; recover ours so the caller can say which
93
+ // context was missing rather than reporting a library's own phrasing.
94
+ const cause = e?.details?.cause ?? e?.cause;
95
+ if (cause instanceof AS2Error) throw cause;
96
+ throw new AS2Error(`not readable as JSON-LD: ${e.message}`);
97
+ }
98
+ return { doc, graph };
99
+ }
100
+
101
+ /**
102
+ * Read AS2 bytes, and when they cannot be read as JSON-LD, read them the way
103
+ * this project always did.
104
+ *
105
+ * A delivery naming a context we do not hold, or carrying none at all, is
106
+ * still a delivery somebody sent. Refusing it here would lose mail that
107
+ * arrives perfectly well today, so the JSON-LD read is an improvement on the
108
+ * plain one and never a gate in front of it. `degraded` carries the reason
109
+ * when the improvement did not happen, so a caller can record it instead of
110
+ * letting it pass unnoticed.
111
+ *
112
+ * `doc` is null only when the bytes are not JSON at all, which is what the
113
+ * plain read would have concluded too.
114
+ */
115
+ export async function readLenient(raw) {
116
+ try {
117
+ const { doc, graph } = await parseAS2(raw);
118
+ return { doc, graph, degraded: null };
119
+ } catch (e) {
120
+ let doc = null;
121
+ try { doc = typeof raw === 'string' ? JSON.parse(raw) : (raw ?? null); } catch { /* not JSON either */ }
122
+ return { doc: (doc && typeof doc === 'object') ? doc : null, graph: null, degraded: e.message };
123
+ }
124
+ }