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.
- package/README.md +22 -6
- package/dist/claims.d.ts +8 -0
- package/dist/claims.js +10 -0
- package/dist/handler.d.ts +13 -0
- package/dist/handler.js +56 -11
- package/dist/handler.jsonld +8 -0
- package/dist/store-pod.js +18 -4
- package/lib/{c2s.mjs → client/c2s.mjs} +10 -3
- package/lib/{localapi.mjs → client/localapi.mjs} +2 -2
- package/lib/client/masto/accounts.mjs +264 -0
- package/lib/client/masto/body.mjs +69 -0
- package/lib/client/masto/index.mjs +183 -0
- package/lib/client/masto/instance.mjs +104 -0
- package/lib/client/masto/media.mjs +133 -0
- package/lib/client/masto/oauth.mjs +599 -0
- package/lib/client/masto/render.mjs +459 -0
- package/lib/client/masto/statuses.mjs +331 -0
- package/lib/client/masto/timelines.mjs +316 -0
- package/lib/{streaming.mjs → client/streaming.mjs} +1 -1
- package/lib/{acctfeed.mjs → connections/acctfeed.mjs} +1 -1
- package/lib/{atproto.mjs → connections/atproto.mjs} +15 -16
- package/lib/{bskygroup.mjs → connections/bskygroup.mjs} +1 -1
- package/lib/{fediacct.mjs → connections/fediacct.mjs} +31 -35
- package/lib/{import.mjs → connections/import.mjs} +1 -1
- package/lib/{tagfeed.mjs → connections/tagfeed.mjs} +3 -3
- package/lib/connections/vault.mjs +114 -0
- package/lib/core/as2.mjs +170 -0
- package/lib/core/contexts/activitystreams.json +379 -0
- package/lib/core/contexts/did-v1.json +57 -0
- package/lib/core/contexts/fep-5711.json +36 -0
- package/lib/core/contexts/gotosocial.json +86 -0
- package/lib/core/contexts/identity-v1.json +152 -0
- package/lib/core/contexts/index.mjs +45 -0
- package/lib/core/contexts/join-lemmy.json +33 -0
- package/lib/core/contexts/joinmastodon.json +28 -0
- package/lib/core/contexts/map.json +16 -0
- package/lib/core/contexts/miscellany.json +19 -0
- package/lib/core/contexts/schemaorg.json +8845 -0
- package/lib/core/contexts/security-data-integrity-v1.json +78 -0
- package/lib/core/contexts/security-data-integrity-v2.json +81 -0
- package/lib/core/contexts/security-multikey-v1.json +35 -0
- package/lib/core/contexts/security-v1.json +74 -0
- package/lib/core/contexts/webfinger.json +10 -0
- package/lib/{deliver.mjs → core/deliver.mjs} +2 -2
- package/lib/core/graphview.mjs +269 -0
- package/lib/core/intake/activities.mjs +437 -0
- package/lib/core/intake/activity.mjs +240 -0
- package/lib/core/intake/channel.mjs +144 -0
- package/lib/core/intake/group.mjs +222 -0
- package/lib/core/intake/index.mjs +629 -0
- package/lib/core/intake/notes.mjs +288 -0
- package/lib/core/intake/verify.mjs +142 -0
- package/lib/{keys.mjs → core/keys.mjs} +1 -1
- package/lib/core/publisher/collections.mjs +229 -0
- package/lib/core/publisher/index.mjs +421 -0
- package/lib/core/publisher/notes.mjs +188 -0
- package/lib/core/publisher/questions.mjs +233 -0
- package/lib/core/publisher/restore.mjs +199 -0
- package/lib/core/shapes/activitystreams.ttl +129 -0
- package/lib/core/shapes/index.mjs +107 -0
- package/lib/core/shapes/shapes-text.mjs +13 -0
- package/lib/{social.mjs → core/social.mjs} +2 -2
- package/lib/{store.mjs → core/store.mjs} +4 -0
- package/lib/{wire.mjs → core/wire.mjs} +2 -2
- package/lib/device/admin/index.mjs +13 -0
- package/lib/device/admin/origins.mjs +35 -0
- package/lib/device/admin/routes/connections.mjs +144 -0
- package/lib/device/admin/routes/gateway.mjs +199 -0
- package/lib/device/admin/routes/lifecycle.mjs +191 -0
- package/lib/device/admin/routes/owner.mjs +322 -0
- package/lib/device/admin/routes/setup.mjs +393 -0
- package/lib/device/admin/routes/social.mjs +188 -0
- package/lib/device/admin/server.mjs +95 -0
- package/lib/device/admin/static.mjs +244 -0
- package/lib/device/admin/surface.mjs +274 -0
- package/lib/device/cli/commands/account.mjs +586 -0
- package/lib/device/cli/commands/run.mjs +278 -0
- package/lib/device/cli/commands/service.mjs +221 -0
- package/lib/device/cli/commands/setup.mjs +410 -0
- package/lib/device/cli/commands/state.mjs +559 -0
- package/lib/device/cli/context.mjs +288 -0
- package/lib/{migrate.mjs → device/migrate.mjs} +1 -1
- package/lib/{remote.mjs → device/remote.mjs} +3 -3
- package/lib/{setup.mjs → device/setup.mjs} +3 -3
- package/lib/{update.mjs → device/update.mjs} +1 -1
- package/lib/{directory.mjs → gateway/directory.mjs} +1 -1
- package/lib/{front-core.mjs → gateway/front-core.mjs} +3 -3
- package/lib/{gateway-core.mjs → gateway/gateway-core.mjs} +1 -1
- package/lib/{httpsig.mjs → gateway/httpsig.mjs} +1 -1
- package/lib/server/embed.mjs +405 -0
- package/lib/{links.mjs → shared/links.mjs} +1 -1
- package/lib/{ua.mjs → shared/ua.mjs} +1 -1
- package/package.json +1 -1
- package/run-agent.mjs +33 -25
- package/web/admin/actors.js +145 -0
- package/web/admin/common.js +23 -0
- package/web/admin/connections.js +112 -0
- package/web/admin/gateway.js +111 -0
- package/web/admin/group.js +258 -0
- package/web/admin/index.html +7 -1
- package/web/admin/record.js +378 -0
- package/web/admin/setup/index.html +1 -0
- package/web/admin/setup/setup.js +2 -13
- package/web/admin/upkeep.js +170 -0
- package/web/app/README.md +6 -6
- package/web/app/admin-facade.mjs +3 -3
- package/web/app/agent.mjs +14 -16
- package/web/app/atproto-browser.mjs +1 -1
- package/web/app/boot.mjs +2 -3
- package/web/app/deliver-relay.mjs +1 -1
- package/web/app/dist/boot.js +22 -3
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +21913 -5446
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/fediacct-browser.mjs +1 -1
- package/web/app/keys-browser.mjs +27 -4
- package/web/app/shims/shapes-text.mjs +8 -0
- package/web/app/signup.mjs +2 -3
- package/web/app/site/admin/actors.js +145 -0
- package/web/app/site/admin/common.js +23 -0
- package/web/app/site/admin/connections.js +112 -0
- package/web/app/site/admin/gateway.js +111 -0
- package/web/app/site/admin/group.js +258 -0
- package/web/app/site/admin/index.html +7 -1
- package/web/app/site/admin/record.js +378 -0
- package/web/app/site/admin/setup/index.html +1 -0
- package/web/app/site/admin/setup/setup.js +2 -13
- package/web/app/site/admin/upkeep.js +170 -0
- package/web/app/site/boot.js +22 -3
- package/web/app/site/sw.js +21913 -5446
- package/web/app/sw-src.mjs +17 -2
- package/lib/admin.mjs +0 -1913
- package/lib/embed.mjs +0 -220
- package/lib/intake.mjs +0 -1981
- package/lib/mastoapi.mjs +0 -2284
- package/lib/publisher.mjs +0 -1192
- package/web/admin/admin.js +0 -1181
- package/web/app/site/admin/admin.js +0 -1181
- /package/lib/{oidc-auth.mjs → client/oidc-auth.mjs} +0 -0
- /package/lib/{webpush.mjs → client/webpush.mjs} +0 -0
- /package/lib/{bskyfeed.mjs → connections/bskyfeed.mjs} +0 -0
- /package/lib/{lease.mjs → core/lease.mjs} +0 -0
- /package/lib/{polls.mjs → core/polls.mjs} +0 -0
- /package/lib/{proof.mjs → core/proof.mjs} +0 -0
- /package/lib/{storage.mjs → core/storage.mjs} +0 -0
- /package/lib/{account.mjs → device/account.mjs} +0 -0
- /package/lib/{certs.mjs → device/certs.mjs} +0 -0
- /package/lib/{export-collections.mjs → device/export-collections.mjs} +0 -0
- /package/lib/{home.mjs → device/home.mjs} +0 -0
- /package/lib/{ports.mjs → device/ports.mjs} +0 -0
- /package/lib/{guard.mjs → shared/guard.mjs} +0 -0
- /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
|
|
11
|
-
import
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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(`
|
|
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
|
-
|
|
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('
|
|
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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
//
|
|
13
|
-
//
|
|
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 {
|
|
20
|
-
import { safeFetch, retryAfterMs } from '
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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(`
|
|
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
|
-
|
|
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
|
|
147
|
-
|
|
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
|
-
|
|
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 '
|
|
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 '
|
|
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('
|
|
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('
|
|
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
|
+
}
|
package/lib/core/as2.mjs
ADDED
|
@@ -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
|
+
}
|