fedipod-server 0.11.0 → 0.13.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 (152) hide show
  1. package/README.md +22 -6
  2. package/dist/claims.d.ts +8 -0
  3. package/dist/claims.js +10 -0
  4. package/dist/handler.d.ts +13 -0
  5. package/dist/handler.js +56 -11
  6. package/dist/handler.jsonld +8 -0
  7. package/dist/store-pod.js +18 -4
  8. package/lib/{c2s.mjs → client/c2s.mjs} +10 -3
  9. package/lib/{localapi.mjs → client/localapi.mjs} +2 -2
  10. package/lib/client/masto/accounts.mjs +264 -0
  11. package/lib/client/masto/body.mjs +69 -0
  12. package/lib/client/masto/index.mjs +183 -0
  13. package/lib/client/masto/instance.mjs +104 -0
  14. package/lib/client/masto/media.mjs +133 -0
  15. package/lib/client/masto/oauth.mjs +599 -0
  16. package/lib/client/masto/render.mjs +459 -0
  17. package/lib/client/masto/statuses.mjs +331 -0
  18. package/lib/client/masto/timelines.mjs +316 -0
  19. package/lib/{streaming.mjs → client/streaming.mjs} +1 -1
  20. package/lib/{acctfeed.mjs → connections/acctfeed.mjs} +1 -1
  21. package/lib/{atproto.mjs → connections/atproto.mjs} +15 -16
  22. package/lib/{bskygroup.mjs → connections/bskygroup.mjs} +1 -1
  23. package/lib/{fediacct.mjs → connections/fediacct.mjs} +31 -35
  24. package/lib/{import.mjs → connections/import.mjs} +1 -1
  25. package/lib/{tagfeed.mjs → connections/tagfeed.mjs} +3 -3
  26. package/lib/connections/vault.mjs +114 -0
  27. package/lib/core/as2.mjs +170 -0
  28. package/lib/core/contexts/activitystreams.json +379 -0
  29. package/lib/core/contexts/did-v1.json +57 -0
  30. package/lib/core/contexts/fep-5711.json +36 -0
  31. package/lib/core/contexts/gotosocial.json +86 -0
  32. package/lib/core/contexts/identity-v1.json +152 -0
  33. package/lib/core/contexts/index.mjs +45 -0
  34. package/lib/core/contexts/join-lemmy.json +33 -0
  35. package/lib/core/contexts/joinmastodon.json +28 -0
  36. package/lib/core/contexts/map.json +16 -0
  37. package/lib/core/contexts/miscellany.json +19 -0
  38. package/lib/core/contexts/schemaorg.json +8845 -0
  39. package/lib/core/contexts/security-data-integrity-v1.json +78 -0
  40. package/lib/core/contexts/security-data-integrity-v2.json +81 -0
  41. package/lib/core/contexts/security-multikey-v1.json +35 -0
  42. package/lib/core/contexts/security-v1.json +74 -0
  43. package/lib/core/contexts/webfinger.json +10 -0
  44. package/lib/{deliver.mjs → core/deliver.mjs} +2 -2
  45. package/lib/core/graphview.mjs +269 -0
  46. package/lib/core/intake/activities.mjs +437 -0
  47. package/lib/core/intake/activity.mjs +240 -0
  48. package/lib/core/intake/channel.mjs +144 -0
  49. package/lib/core/intake/group.mjs +222 -0
  50. package/lib/core/intake/index.mjs +629 -0
  51. package/lib/core/intake/notes.mjs +288 -0
  52. package/lib/core/intake/verify.mjs +142 -0
  53. package/lib/{keys.mjs → core/keys.mjs} +1 -1
  54. package/lib/core/publisher/collections.mjs +229 -0
  55. package/lib/core/publisher/index.mjs +421 -0
  56. package/lib/core/publisher/notes.mjs +188 -0
  57. package/lib/core/publisher/questions.mjs +233 -0
  58. package/lib/core/publisher/restore.mjs +199 -0
  59. package/lib/core/shapes/activitystreams.ttl +129 -0
  60. package/lib/core/shapes/index.mjs +107 -0
  61. package/lib/core/shapes/shapes-text.mjs +13 -0
  62. package/lib/{social.mjs → core/social.mjs} +2 -2
  63. package/lib/{store.mjs → core/store.mjs} +4 -0
  64. package/lib/{wire.mjs → core/wire.mjs} +2 -2
  65. package/lib/device/admin/index.mjs +13 -0
  66. package/lib/device/admin/origins.mjs +35 -0
  67. package/lib/device/admin/routes/connections.mjs +144 -0
  68. package/lib/device/admin/routes/gateway.mjs +199 -0
  69. package/lib/device/admin/routes/lifecycle.mjs +191 -0
  70. package/lib/device/admin/routes/owner.mjs +322 -0
  71. package/lib/device/admin/routes/setup.mjs +393 -0
  72. package/lib/device/admin/routes/social.mjs +188 -0
  73. package/lib/device/admin/server.mjs +95 -0
  74. package/lib/device/admin/static.mjs +244 -0
  75. package/lib/device/admin/surface.mjs +274 -0
  76. package/lib/device/cli/commands/account.mjs +586 -0
  77. package/lib/device/cli/commands/run.mjs +278 -0
  78. package/lib/device/cli/commands/service.mjs +221 -0
  79. package/lib/device/cli/commands/setup.mjs +410 -0
  80. package/lib/device/cli/commands/state.mjs +559 -0
  81. package/lib/device/cli/context.mjs +288 -0
  82. package/lib/{migrate.mjs → device/migrate.mjs} +1 -1
  83. package/lib/{remote.mjs → device/remote.mjs} +3 -3
  84. package/lib/{setup.mjs → device/setup.mjs} +3 -3
  85. package/lib/{update.mjs → device/update.mjs} +1 -1
  86. package/lib/{directory.mjs → gateway/directory.mjs} +1 -1
  87. package/lib/{front-core.mjs → gateway/front-core.mjs} +3 -3
  88. package/lib/{gateway-core.mjs → gateway/gateway-core.mjs} +1 -1
  89. package/lib/{httpsig.mjs → gateway/httpsig.mjs} +1 -1
  90. package/lib/server/embed.mjs +405 -0
  91. package/lib/{links.mjs → shared/links.mjs} +1 -1
  92. package/lib/{ua.mjs → shared/ua.mjs} +1 -1
  93. package/package.json +1 -1
  94. package/run-agent.mjs +33 -25
  95. package/web/admin/actors.js +145 -0
  96. package/web/admin/common.js +23 -0
  97. package/web/admin/connections.js +112 -0
  98. package/web/admin/gateway.js +111 -0
  99. package/web/admin/group.js +258 -0
  100. package/web/admin/index.html +7 -1
  101. package/web/admin/record.js +378 -0
  102. package/web/admin/setup/index.html +1 -0
  103. package/web/admin/setup/setup.js +2 -13
  104. package/web/admin/upkeep.js +170 -0
  105. package/web/app/README.md +6 -6
  106. package/web/app/admin-facade.mjs +3 -3
  107. package/web/app/agent.mjs +14 -16
  108. package/web/app/atproto-browser.mjs +1 -1
  109. package/web/app/boot.mjs +2 -3
  110. package/web/app/deliver-relay.mjs +1 -1
  111. package/web/app/dist/boot.js +22 -3
  112. package/web/app/dist/boot.js.map +2 -2
  113. package/web/app/dist/sw.js +21913 -5446
  114. package/web/app/dist/sw.js.map +4 -4
  115. package/web/app/fediacct-browser.mjs +1 -1
  116. package/web/app/keys-browser.mjs +27 -4
  117. package/web/app/shims/shapes-text.mjs +8 -0
  118. package/web/app/signup.mjs +2 -3
  119. package/web/app/site/admin/actors.js +145 -0
  120. package/web/app/site/admin/common.js +23 -0
  121. package/web/app/site/admin/connections.js +112 -0
  122. package/web/app/site/admin/gateway.js +111 -0
  123. package/web/app/site/admin/group.js +258 -0
  124. package/web/app/site/admin/index.html +7 -1
  125. package/web/app/site/admin/record.js +378 -0
  126. package/web/app/site/admin/setup/index.html +1 -0
  127. package/web/app/site/admin/setup/setup.js +2 -13
  128. package/web/app/site/admin/upkeep.js +170 -0
  129. package/web/app/site/boot.js +22 -3
  130. package/web/app/site/sw.js +21913 -5446
  131. package/web/app/sw-src.mjs +17 -2
  132. package/lib/admin.mjs +0 -1913
  133. package/lib/embed.mjs +0 -220
  134. package/lib/intake.mjs +0 -1981
  135. package/lib/mastoapi.mjs +0 -2284
  136. package/lib/publisher.mjs +0 -1192
  137. package/web/admin/admin.js +0 -1181
  138. package/web/app/site/admin/admin.js +0 -1181
  139. /package/lib/{oidc-auth.mjs → client/oidc-auth.mjs} +0 -0
  140. /package/lib/{webpush.mjs → client/webpush.mjs} +0 -0
  141. /package/lib/{bskyfeed.mjs → connections/bskyfeed.mjs} +0 -0
  142. /package/lib/{lease.mjs → core/lease.mjs} +0 -0
  143. /package/lib/{polls.mjs → core/polls.mjs} +0 -0
  144. /package/lib/{proof.mjs → core/proof.mjs} +0 -0
  145. /package/lib/{storage.mjs → core/storage.mjs} +0 -0
  146. /package/lib/{account.mjs → device/account.mjs} +0 -0
  147. /package/lib/{certs.mjs → device/certs.mjs} +0 -0
  148. /package/lib/{export-collections.mjs → device/export-collections.mjs} +0 -0
  149. /package/lib/{home.mjs → device/home.mjs} +0 -0
  150. /package/lib/{ports.mjs → device/ports.mjs} +0 -0
  151. /package/lib/{guard.mjs → shared/guard.mjs} +0 -0
  152. /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,170 @@
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.
8
+ //
9
+ // The graph is what the handlers read, through lib/core/graphview.mjs. `doc`,
10
+ // the copy compacted against the standard context, is kept for the callers
11
+ // that republish what they read rather than act on it — compaction alone drops
12
+ // any term the sender defined in its own context, so it is the weaker read.
13
+ //
14
+ // What we STORE and SEND is untouched by any of this. Bytes go into the pod as
15
+ // they arrived, and lib/core/wire.mjs builds what we send as compacted AS2
16
+ // with a plain string @context, as it always did. This is a reading layer.
17
+ //
18
+ // Every document goes through the processor. There is no shortcut for the
19
+ // common shape: one that skipped it would not be read as JSON-LD, and would
20
+ // produce no graph, so nothing could be validated against it.
21
+ //
22
+ // CONTEXTS ARE NEVER FETCHED. The loader serves the fourteen the fediverse
23
+ // actually uses and refuses every other URL. That refusal is the point: this
24
+ // code runs on documents a stranger wrote, in the same path the SSRF guards
25
+ // protect, and expanding one would otherwise mean dereferencing a URL of their
26
+ // choosing. A document naming a context we do not hold is not lost for it —
27
+ // `groundContext` reads it against the contexts we hold and says so in
28
+ // `degraded` — but nothing is ever fetched to make that happen.
29
+
30
+ import jsonld from 'jsonld';
31
+ import { AS_CTX } from './wire.mjs';
32
+ import { CONTEXTS } from './contexts/index.mjs';
33
+ import { graphView } from './graphview.mjs';
34
+
35
+ export { CONTEXTS };
36
+
37
+ /** Why a document could not be read. Carried to the caller so a drop is explained. */
38
+ export class AS2Error extends Error {
39
+ constructor(reason, { context = null } = {}) {
40
+ super(reason);
41
+ this.name = 'AS2Error';
42
+ this.context = context;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * A loader over the contexts we hold, and nothing else.
48
+ *
49
+ * jsonld calls this for every `@context` URL in a document. Anything not in
50
+ * the map throws rather than being fetched, so a document cannot make this
51
+ * process reach out to an address its sender picked.
52
+ */
53
+ export function contextLoader(url) {
54
+ const doc = CONTEXTS[url];
55
+ if (!doc) {
56
+ return Promise.reject(new AS2Error(`context not held here: ${url}`, { context: url }));
57
+ }
58
+ return Promise.resolve({ contextUrl: null, documentUrl: url, document: doc });
59
+ }
60
+
61
+ /**
62
+ * Read AS2 bytes.
63
+ *
64
+ * Returns `{ doc, graph }` — the document compacted against the standard
65
+ * context, which is what handlers read, and its quads, which is what a shape
66
+ * is validated against. Throws AS2Error when the document cannot be read as
67
+ * JSON-LD at all.
68
+ *
69
+ * `raw` is a string or a parsed object; both arrive here in practice.
70
+ */
71
+ export async function parseAS2(raw) {
72
+ let input;
73
+ if (typeof raw === 'string') {
74
+ try {
75
+ input = JSON.parse(raw);
76
+ } catch (e) {
77
+ throw new AS2Error(`not JSON: ${e.message}`);
78
+ }
79
+ } else {
80
+ input = raw;
81
+ }
82
+ if (!input || typeof input !== 'object') throw new AS2Error('not a JSON object');
83
+
84
+ // A document with no @context expands to nothing at all — every term is
85
+ // dropped, and the result is an empty graph rather than an error. Silently
86
+ // reading a delivery as "no statements" is the worst of the outcomes, so it
87
+ // is named here instead.
88
+ if (input['@context'] === undefined) throw new AS2Error('no @context');
89
+
90
+ const options = { documentLoader: contextLoader };
91
+ let graph;
92
+ let doc;
93
+ try {
94
+ graph = await jsonld.toRDF(input, options); // RDF/JS quads, which is what a shape is checked against
95
+ doc = await jsonld.compact(input, AS_CTX, options);
96
+ } catch (e) {
97
+ if (e instanceof AS2Error) throw e;
98
+ // jsonld wraps a loader rejection; recover ours so the caller can say which
99
+ // context was missing rather than reporting a library's own phrasing.
100
+ const cause = e?.details?.cause ?? e?.cause;
101
+ if (cause instanceof AS2Error) throw cause;
102
+ throw new AS2Error(`not readable as JSON-LD: ${e.message}`);
103
+ }
104
+ return { doc, graph };
105
+ }
106
+
107
+ /**
108
+ * Rewrite a document's `@context` to one we can read without fetching anything.
109
+ *
110
+ * A document carrying no `@context` is not a puzzle: AS2 Core §2.1 says a
111
+ * consumer meeting `application/activity+json` without one MUST assume the
112
+ * normative context still applies. That is what this does. A document naming a
113
+ * context we do not hold keeps every entry we DO hold — inline objects
114
+ * included, since those need no fetch — and loses only the unfetchable ones,
115
+ * so a Mastodon post with an unknown extension still reads as a post.
116
+ *
117
+ * Nothing here dereferences a URL. The result names only contexts already on
118
+ * disk, so the guarantee that a stranger's document cannot make us reach out
119
+ * survives the fallback.
120
+ */
121
+ export function groundContext(input) {
122
+ const ctx = input['@context'];
123
+ const held = (c) => typeof c === 'string' ? Object.hasOwn(CONTEXTS, c) : (c && typeof c === 'object');
124
+ let kept;
125
+ if (ctx === undefined || ctx === null) kept = [];
126
+ else if (Array.isArray(ctx)) kept = ctx.filter(held);
127
+ else kept = held(ctx) ? [ctx] : [];
128
+ if (!kept.some(c => c === AS_CTX)) kept.unshift(AS_CTX);
129
+ return { ...input, '@context': kept.length === 1 ? kept[0] : kept };
130
+ }
131
+
132
+ /**
133
+ * Read AS2 bytes, and when they cannot be read as JSON-LD as written, read
134
+ * them against the contexts we do hold.
135
+ *
136
+ * A delivery naming a context we do not hold, or carrying none at all, is
137
+ * still a delivery somebody sent. It is now grounded rather than refused, so
138
+ * `graph` is present for anything that is JSON at all — which is what lets the
139
+ * handlers read the graph instead of the document. `degraded` still carries
140
+ * the reason the document could not be read exactly as written, so a caller
141
+ * records what happened instead of letting it pass unnoticed.
142
+ *
143
+ * `doc` and `graph` are null only when the bytes are not JSON at all, which is
144
+ * what the plain read would have concluded too.
145
+ */
146
+ export async function readLenient(raw) {
147
+ try {
148
+ const { doc, graph } = await parseAS2(raw);
149
+ return { doc, graph, view: graphView(graph), degraded: null };
150
+ } catch (e) {
151
+ let input = null;
152
+ try { input = typeof raw === 'string' ? JSON.parse(raw) : (raw ?? null); } catch { /* not JSON either */ }
153
+ if (!input || typeof input !== 'object') return { doc: null, graph: null, view: null, degraded: e.message };
154
+ // Grounded, so a graph exists for every document that is JSON at all.
155
+ //
156
+ // `doc` stays the bytes as they were parsed, NOT the grounded compaction.
157
+ // Compacting shortens an IRI to whatever term names it — `#Public` comes
158
+ // back as `as:Public` — and the callers that still read `doc` republish
159
+ // what they read, so rewriting it here silently changed what they posted.
160
+ try {
161
+ const { graph } = await parseAS2(groundContext(input));
162
+ return { doc: input, graph, view: graphView(graph), degraded: e.message };
163
+ } catch (inner) {
164
+ // Grounding names only held contexts, so reaching here means the document
165
+ // is malformed JSON-LD rather than unfetchable. Hand back what a plain
166
+ // read would have seen, with no graph, as this always did.
167
+ return { doc: input, graph: null, view: null, degraded: `${e.message}; grounded read also failed: ${inner.message}` };
168
+ }
169
+ }
170
+ }