fedipod-server 0.4.0 → 0.6.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/lib/intake.mjs CHANGED
@@ -113,7 +113,19 @@ function pollOf(note) {
113
113
  })),
114
114
  };
115
115
  }
116
- export const isContentType = (t) => CONTENT_TYPES.has(t);
116
+ // AS2 lets `type` be one string or a list, and implementations use both —
117
+ // `["Person","Service"]` is an ordinary actor. Read either form.
118
+ const typesOf = (t) => (Array.isArray(t) ? t : [t]).filter(x => typeof x === 'string');
119
+ export const isContentType = (t) => typesOf(t).some(x => CONTENT_TYPES.has(x));
120
+ const isActorType = (t) => typesOf(t).some(x => ACTOR_TYPES.has(x));
121
+
122
+ // What we will carry to our followers on someone else's behalf (§7.1.2): the
123
+ // activities a conversation is made of, and nothing else. A type this file
124
+ // does not handle falls out of handle() with no rejection, and "no rejection"
125
+ // is what qualifies an activity for forwarding — so without this gate a
126
+ // stranger could have anything at all, of a type nothing here reads,
127
+ // re-delivered to every follower over our signature.
128
+ const FORWARDABLE = new Set(['Create', 'Update', 'Delete', 'Like', 'Announce', 'Undo']);
117
129
  const ACCEPT_AP = 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
118
130
 
119
131
  export function sameOrigin(a, b) {
@@ -741,7 +753,7 @@ export class Intake {
741
753
  // Same origin rather than exact equality: signedFetch follows redirects
742
754
  // without reporting where it landed, so a server that redirects its own
743
755
  // canonical actor URL would otherwise stop being cached at all.
744
- if (ACTOR_TYPES.has(doc?.type) && doc.id && sameOrigin(doc.id, url)) {
756
+ if (isActorType(doc?.type) && doc.id && sameOrigin(doc.id, url)) {
745
757
  this.store.cacheActor(doc.id, doc);
746
758
  }
747
759
  return doc;
@@ -1123,6 +1135,7 @@ export class Intake {
1123
1135
  // never forwarded.
1124
1136
  async _maybeForward(activity) {
1125
1137
  if (!activity || typeof activity !== 'object') return;
1138
+ if (!FORWARDABLE.has(activity.type)) return; // see FORWARDABLE
1126
1139
  try {
1127
1140
  const audience = []
1128
1141
  .concat(activity.to || [], activity.cc || [], activity.audience || [])
@@ -1380,9 +1393,9 @@ export class Intake {
1380
1393
  const note = await this.fetchAP(objectId);
1381
1394
  if (!note) return `object fetch failed (${objectId})`;
1382
1395
  if (note.id !== objectId || !isContentType(note.type)) return `object not verifiable content (${objectId}, ${note.type})`;
1383
- const { attachmentsOf, sanitizeHtml } = await import('./wire.mjs');
1396
+ const { attachmentsOf, titledContent } = await import('./wire.mjs');
1384
1397
  const attachments = attachmentsOf(note);
1385
- const content = sanitizeHtml(note.content); // hostile markup never reaches pod or client
1398
+ const content = titledContent(note); // hostile markup never reaches pod or client
1386
1399
  // The delivering actor was checked on arrival; the author is only known once
1387
1400
  // the note is dereferenced. authorOf refuses an author the note's own origin
1388
1401
  // does not vouch for — see its comment; this is where a forged attribution
@@ -1594,8 +1607,8 @@ export class Intake {
1594
1607
  const note = await this.fetchAP(objectId);
1595
1608
  if (!note) throw new Error(`cannot refetch ${objectId} — will retry`);
1596
1609
  if (note.id !== objectId || !isContentType(note.type)) return `object not verifiable content (${objectId}, ${note.type})`;
1597
- const { attachmentsOf, sanitizeHtml } = await import('./wire.mjs');
1598
- const content = sanitizeHtml(note.content);
1610
+ const { attachmentsOf, titledContent } = await import('./wire.mjs');
1611
+ const content = titledContent(note);
1599
1612
  const attachments = attachmentsOf(note);
1600
1613
  const freshPoll = pollOf(note);
1601
1614
  const freshEmojis = emojisOf(note);
package/lib/keys.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // keys.mjs — actor signing keys. RSA (Mastodon's draft-cavage HTTP Signatures
2
- // require RSA-SHA256) + Ed25519 (stored for future FEP-8b32 use, not yet in
3
- // the actor doc). PEM at rest (0600), CryptoKey in memory for Fedify's
4
- // signRequest.
2
+ // require RSA-SHA256) signs deliveries; Ed25519 signs the FEP-8b32 proof that
3
+ // travels with an activity. PEM at rest (0600), CryptoKey in memory for
4
+ // Fedify's signRequest and for the proof.
5
5
  //
6
6
  // The key lives on THIS MACHINE by default (AP_HOME/keys.json): the pod host
7
7
  // then never holds it. `setup --keys pod` puts it in pod state instead,
@@ -112,9 +112,23 @@ export async function resolveKeys(store, { localDir = null, rotate = false, acto
112
112
  log(rotate ? 'minted a REPLACEMENT signing key — republish the profile' : 'minted a signing key');
113
113
  }
114
114
 
115
+ // A record written before proofs existed has no Ed25519 half. Minting one is
116
+ // safe in a way minting an RSA key is not: nothing has ever published or
117
+ // cached it, so no remote signature is invalidated.
118
+ if (!rec.ed25519) {
119
+ rec = { ...rec, ed25519: generate().ed25519 };
120
+ if (localDir) writeLocal(localDir, rec); else store.write('keys.json', rec);
121
+ log('minted the Ed25519 half of the signing key');
122
+ }
123
+
115
124
  const der = crypto.createPrivateKey(rec.rsa.privatePem).export({ type: 'pkcs8', format: 'der' });
116
125
  const rsaPrivate = await crypto.subtle.importKey('pkcs8', der, RSA_ALG, true, ['sign']);
117
- return { rsaPrivate, rsaPublicPem: rec.rsa.publicPem };
126
+ const { edPrivateKey, multibaseEd25519 } = await import('./proof.mjs');
127
+ return {
128
+ rsaPrivate, rsaPublicPem: rec.rsa.publicPem,
129
+ edPrivate: await edPrivateKey(rec.ed25519.privatePem),
130
+ edPublicMultibase: multibaseEd25519(rec.ed25519.publicPem),
131
+ };
118
132
  }
119
133
 
120
134
  // Move a local key into pod state — the arrangement that lets several
package/lib/lease.mjs CHANGED
@@ -29,6 +29,7 @@ export class Lease {
29
29
  this.stopped = false;
30
30
  this.timer = null;
31
31
  this.heldUntil = 0; // wall-clock end of the lease we last wrote
32
+ this.denied = null; // why the last acquire() said no: 'unreadable' | 'held'
32
33
  }
33
34
 
34
35
  // null means the lease document is NOT THERE — nobody holds it. UNREADABLE
@@ -74,16 +75,21 @@ export class Lease {
74
75
  const cur = await this.readFresh();
75
76
  // Cannot read it: stay a viewer. A viewer that should have been active is
76
77
  // an inconvenience; two agents both draining is the destructive one.
77
- if (cur === UNREADABLE) { this.log('lease unreadable — staying a viewer'); return false; }
78
- if (cur && cur.holder !== this.id && Date.now() < cur.expiresAt) return false;
78
+ if (cur === UNREADABLE) {
79
+ this.denied = 'unreadable';
80
+ this.log('lease unreadable — staying a viewer'); return false;
81
+ }
82
+ if (cur && cur.holder !== this.id && Date.now() < cur.expiresAt) { this.denied = 'held'; return false; }
79
83
  const doc = { holder: this.id, expiresAt: Date.now() + TTL_MS };
80
- if (!await this.write(doc)) return false;
84
+ if (!await this.write(doc)) { this.denied = 'held'; return false; }
81
85
  const confirm = await this.readFresh();
82
- if (confirm === UNREADABLE || confirm?.holder !== this.id) return false;
86
+ if (confirm === UNREADABLE) { this.denied = 'unreadable'; return false; }
87
+ if (confirm?.holder !== this.id) { this.denied = 'held'; return false; }
83
88
  // From the document we WROTE, and only once the confirm agrees. Recomputing
84
89
  // it here would claim a lease that outlives what the pod's copy grants, by
85
90
  // however long the PUT and the confirming GET took.
86
91
  this.heldUntil = doc.expiresAt;
92
+ this.denied = null;
87
93
  return true;
88
94
  }
89
95
 
package/lib/mastoapi.mjs CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  import crypto from 'node:crypto';
14
14
  import * as social from './social.mjs';
15
- import { sanitizeHtml } from './wire.mjs';
15
+ import { sanitizeHtml, followsNeedApproval } from './wire.mjs';
16
16
  import { authorOf } from './intake.mjs';
17
17
  import { profileUrl, postUrl } from './bskyfeed.mjs';
18
18
  import { Push } from './webpush.mjs';
@@ -222,7 +222,7 @@ export class MastoApi {
222
222
  // people — so read it from config, or the editor opens empty and saving
223
223
  // wipes what was there.
224
224
  display_name: (self ? this.store.getConfig()?.name : cached.name) || cached.name || user,
225
- locked: self ? !!this.store.getConfig()?.approveJoins : false,
225
+ locked: self ? followsNeedApproval(this.store.getConfig() || {}) : false,
226
226
  // Read from config for self, like the fields above it: our own actor is
227
227
  // not in the actor cache — the cache is for other people — so a group
228
228
  // asking about itself would be told it was a person.
package/lib/proof.mjs ADDED
@@ -0,0 +1,79 @@
1
+ // proof.mjs — FEP-8b32 object integrity proofs (Data Integrity, eddsa-jcs-2022).
2
+ //
3
+ // An HTTP signature covers one delivery and says nothing about who wrote what
4
+ // was inside it. A proof travels with the activity, so a server that receives
5
+ // one of ours second-hand — carried by a group, or forwarded by a follower's
6
+ // server — can tell it is ours without asking us.
7
+ //
8
+ // The RSA `#main-key` and its HTTP signatures are untouched; this is a second
9
+ // key alongside, which is what the Data Integrity suites require.
10
+
11
+ import crypto from 'node:crypto';
12
+ import serialize from 'json-canon';
13
+
14
+ // multicodec: an Ed25519 public key is its 32 raw bytes behind 0xed 0x01.
15
+ const ED25519_PREFIX = Buffer.from([0xed, 0x01]);
16
+ const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
17
+
18
+ export function base58btc(bytes) {
19
+ const b = Buffer.from(bytes);
20
+ let n = 0n;
21
+ for (const byte of b) n = (n << 8n) + BigInt(byte);
22
+ let out = '';
23
+ while (n > 0n) { out = B58[Number(n % 58n)] + out; n /= 58n; }
24
+ for (const byte of b) { if (byte !== 0) break; out = B58[0] + out; }
25
+ return out;
26
+ }
27
+
28
+ /** The `publicKeyMultibase` form of an Ed25519 public key in PEM. */
29
+ export function multibaseEd25519(publicPem) {
30
+ const raw = Buffer.from(crypto.createPublicKey(publicPem).export({ format: 'jwk' }).x, 'base64url');
31
+ return 'z' + base58btc(Buffer.concat([ED25519_PREFIX, raw]));
32
+ }
33
+
34
+ /** The signing key, as WebCrypto wants it. */
35
+ export function edPrivateKey(privatePem) {
36
+ const jwk = crypto.createPrivateKey(privatePem).export({ format: 'jwk' });
37
+ return crypto.subtle.importKey('jwk', { ...jwk, key_ops: ['sign'] }, { name: 'Ed25519' }, true, ['sign']);
38
+ }
39
+
40
+ const sha256 = (s) => crypto.createHash('sha256').update(s).digest();
41
+
42
+ // The proof's own terms have to be declared in the document that carries it,
43
+ // or a receiver that processes JSON-LD expands the proof away before it ever
44
+ // looks at it. Declared BEFORE signing, because the proof covers the document
45
+ // exactly as sent, `@context` included.
46
+ export const DI_CTX = 'https://w3id.org/security/data-integrity/v1';
47
+
48
+ function withProofContext(doc) {
49
+ const ctx = doc['@context'];
50
+ const list = Array.isArray(ctx) ? ctx : ctx ? [ctx] : [];
51
+ if (list.includes(DI_CTX)) return doc;
52
+ return { ...doc, '@context': [...list, DI_CTX] };
53
+ }
54
+
55
+ /**
56
+ * Return the activity with a `proof` attached. The proof covers the activity
57
+ * exactly as it goes on the wire, minus the proof itself — so it must be the
58
+ * last thing added, and nothing may edit the activity afterwards.
59
+ *
60
+ * Whole seconds on `created`: a verifier re-serializes the timestamp it was
61
+ * given, and a fractional one has more than one spelling.
62
+ */
63
+ export async function attachProof(activity, { privateKey, verificationMethod, created = new Date() } = {}) {
64
+ if (!privateKey || !verificationMethod || !activity || typeof activity !== 'object') return activity;
65
+ const { proof: _existing, ...bare } = activity;
66
+ const doc = withProofContext(bare);
67
+ const config = {
68
+ '@context': doc['@context'],
69
+ type: 'DataIntegrityProof',
70
+ cryptosuite: 'eddsa-jcs-2022',
71
+ verificationMethod,
72
+ proofPurpose: 'assertionMethod',
73
+ created: new Date(created).toISOString().replace(/\.\d+Z$/, 'Z'),
74
+ };
75
+ const digest = Buffer.concat([sha256(serialize(config)), sha256(serialize(doc))]);
76
+ const sig = await crypto.subtle.sign('Ed25519', privateKey, digest);
77
+ const { '@context': _ctx, ...emitted } = config;
78
+ return { ...doc, proof: { ...emitted, proofValue: 'z' + base58btc(Buffer.from(sig)) } };
79
+ }
package/lib/publisher.mjs CHANGED
@@ -22,7 +22,7 @@ const AGENT_VERSION = JSON.parse(fs.readFileSync(
22
22
  path.join(path.dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8')).version;
23
23
 
24
24
  export class Publisher {
25
- constructor({ config, remote, local, store, deliverer, publicKeyPem, log = console.log,
25
+ constructor({ config, remote, local, store, deliverer, publicKeyPem, assertionKey = null, log = console.log,
26
26
  probeFetch = null, resolveMention = null, privateOnPod = true,
27
27
  }) {
28
28
  this.config = config;
@@ -31,6 +31,7 @@ export class Publisher {
31
31
  this.store = store;
32
32
  this.deliverer = deliverer;
33
33
  this.publicKeyPem = publicKeyPem;
34
+ this.assertionKey = assertionKey; // Ed25519 public half, multibase; null = no proofs
34
35
  // A fronted identity (config.gateway.frontActor) advertises its ids on a
35
36
  // shared domain; the map tells RemotePod where each writes on the pod.
36
37
  const publicBase = config.gateway?.frontActor
@@ -76,7 +77,8 @@ export class Publisher {
76
77
  const actorDoc = wire.actorDoc({
77
78
  urls, handle: this.config.handle, name: this.config.name, publicKeyPem: this.publicKeyPem,
78
79
  movedTo: this.config.movedTo || null, kind: this.config.kind,
79
- approveJoins: !!this.config.approveJoins,
80
+ approveJoins: wire.followsNeedApproval(this.config),
81
+ assertionKey: this.assertionKey,
80
82
  summary: this.config.summary || null, icon: this.config.icon || null,
81
83
  image: this.config.image || null, fields: this.config.fields || [],
82
84
  webId: this.remote.webId || null,
@@ -350,7 +352,8 @@ export class Publisher {
350
352
  await this.remote.putJson(urls.actor, wire.actorDoc({
351
353
  urls, handle: this.config.handle, name: this.config.name,
352
354
  publicKeyPem: this.publicKeyPem, movedTo: target, kind: this.config.kind,
353
- approveJoins: !!this.config.approveJoins,
355
+ approveJoins: wire.followsNeedApproval(this.config),
356
+ assertionKey: this.assertionKey,
354
357
  summary: this.config.summary || null, icon: this.config.icon || null,
355
358
  image: this.config.image || null, fields: this.config.fields || [],
356
359
  webId: this.remote.webId || null,
package/lib/tagfeed.mjs CHANGED
@@ -135,10 +135,10 @@ export class TagFeed {
135
135
  if (!this.store.getActors()[author]) {
136
136
  await this.intake.fetchAP(author).catch(() => {}); // warm name+avatar
137
137
  }
138
- const { attachmentsOf, sanitizeHtml } = await import('./wire.mjs');
138
+ const { attachmentsOf, titledContent } = await import('./wire.mjs');
139
139
  const attachments = attachmentsOf(note);
140
140
  this.store.addStatus({
141
- noteId, actor: author, content: sanitizeHtml(note.content),
141
+ noteId, actor: author, content: titledContent(note),
142
142
  published: note.published, inReplyTo: note.inReplyTo, kind: 'tag', tag,
143
143
  ...(attachments.length ? { attachments } : {}),
144
144
  });
package/lib/update.mjs CHANGED
@@ -42,16 +42,21 @@ export async function checkLatest({ current = localVersion(), fetchImpl = fetch
42
42
  } catch { return null; }
43
43
  }
44
44
 
45
+ // The npm half, injectable — a test asking what runUpdate DOES must never be
46
+ // able to install software on the machine running it.
47
+ const npmInstallLatest = () =>
48
+ execFileSync('npm', ['install', '-g', 'fedipod@latest'], { stdio: 'pipe' });
49
+
45
50
  // Fast-forward only: a checkout with local changes is somebody's work, and an
46
51
  // update must refuse rather than eat it.
47
- export function runUpdate({ root = repoRoot(), log = () => {} } = {}) {
52
+ export function runUpdate({ root = repoRoot(), log = () => {}, install = npmInstallLatest } = {}) {
48
53
  // The ordinary install comes from npm, where updating is npm's job and the
49
54
  // files under us are not ours to move. A checkout is somebody working on it,
50
55
  // and gets the fast-forward below.
51
56
  if (!fs.existsSync(path.join(root, '.git'))) {
52
57
  try {
53
58
  log('updating from npm…');
54
- execFileSync('npm', ['install', '-g', 'fedipod@latest'], { stdio: 'pipe' });
59
+ install();
55
60
  return { ok: true, note: 'updated from npm — restart the agent to serve it' };
56
61
  } catch (e) {
57
62
  return { ok: false,
package/lib/wire.mjs CHANGED
@@ -87,7 +87,19 @@ export function jrd({ handle, host, actor }) {
87
87
 
88
88
  // `kind: 'group'` publishes a Group rather than a Person, which is what makes
89
89
  // Mastodon and Lemmy treat the actor as a community you join.
90
- export function actorDoc({ urls, handle, name, publicKeyPem, movedTo = null, kind = 'person',
90
+ // Whether a follow will be held for the operator instead of accepted on
91
+ // arrival. A person's follows wait unless `autoAcceptFollows` says otherwise,
92
+ // because a delivery on its own proves nothing about who sent it; a group is
93
+ // open unless its operator asked to see joins. The actor document has to say
94
+ // the same thing, or a remote server shows "Following" for a follow that is
95
+ // in fact still waiting, and nothing ever tells it otherwise.
96
+ export function followsNeedApproval(config = {}) {
97
+ return !!config.approveJoins || (config.kind !== 'group' && !config.autoAcceptFollows);
98
+ }
99
+
100
+ export const assertionKeyId = (urls) => urls.actor + '#ed25519-key';
101
+
102
+ export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null, movedTo = null, kind = 'person',
91
103
  approveJoins = false, summary = null, icon = null, image = null, fields = [],
92
104
  webId = null, aliases = [], moderators = null, pendingFollowers = null, pendingFollowing = null,
93
105
  blocked = null, inbox = null }) {
@@ -103,6 +115,12 @@ export function actorDoc({ urls, handle, name, publicKeyPem, movedTo = null, kin
103
115
  // inline exactly as Mastodon declares it.
104
116
  context.push({ toot: 'http://joinmastodon.org/ns#', featured: { '@id': 'toot:featured', '@type': '@id' } });
105
117
  if (approveJoins) context.push({ manuallyApprovesFollowers: 'as:manuallyApprovesFollowers' });
118
+ // FEP-8b32. The Multikey is a SECOND key beside publicKey/#main-key, not a
119
+ // replacement: HTTP signatures stay RSA, and only the proof is Ed25519.
120
+ // `assertionMethod` comes from the DID context and the key's own terms from
121
+ // the Multikey one; the proof's terms travel with the proof, not here.
122
+ if (assertionKey) context.push('https://www.w3.org/ns/did/v1',
123
+ 'https://w3id.org/security/multikey/v1');
106
124
  // FEP-4ccd and FEP-c648 terms, declared exactly as those FEPs declare them,
107
125
  // and only when the collections are actually published.
108
126
  if (pendingFollowers || pendingFollowing) {
@@ -130,6 +148,10 @@ export function actorDoc({ urls, handle, name, publicKeyPem, movedTo = null, kin
130
148
  id: urls.actor,
131
149
  type: kind === 'group' ? 'Group' : 'Person',
132
150
  ...(approveJoins ? { manuallyApprovesFollowers: true } : {}),
151
+ ...(assertionKey ? { assertionMethod: [{
152
+ id: assertionKeyId(urls), type: 'Multikey', controller: urls.actor,
153
+ publicKeyMultibase: assertionKey,
154
+ }] } : {}),
133
155
  ...(movedTo ? { movedTo } : {}),
134
156
  ...(webId || aliases.length ? { alsoKnownAs: [...(webId ? [webId] : []), ...aliases] } : {}),
135
157
  preferredUsername: handle,
@@ -526,11 +548,28 @@ export function sanitizeHtml(html) {
526
548
  });
527
549
  }
528
550
 
551
+ // A Lemmy post, a blog article, a PeerTube video and a Bookwyrm review each
552
+ // carry their headline in `name` and their body in `content`. Reading content
553
+ // alone loses the headline, and a link post is often nothing else — so keep it
554
+ // as the first line, unless the body already opens with it.
555
+ export function titledContent(note) {
556
+ const html = sanitizeHtml(note?.content);
557
+ const types = Array.isArray(note?.type) ? note.type : [note?.type];
558
+ const title = typeof note?.name === 'string' ? note.name.trim() : '';
559
+ if (!title || types.includes('Note')) return html;
560
+ const opening = html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase();
561
+ if (opening.startsWith(title.toLowerCase())) return html;
562
+ const esc = String(title).replace(/[&<>]/g, c => HTML_ESCAPES[c]);
563
+ return `<p><strong>${esc}</strong></p>${html}`;
564
+ }
565
+
529
566
  // Normalize a wire Note's attachment list to { url, mediaType, description }.
530
567
  export function attachmentsOf(note) {
531
568
  const list = Array.isArray(note?.attachment) ? note.attachment : note?.attachment ? [note.attachment] : [];
569
+ // A bare Link carries `href` and no `url` — Lemmy's link posts arrive that
570
+ // way, and reading only `url` dropped the link the post was about.
532
571
  return list.map(a => ({
533
- url: typeof a?.url === 'string' ? a.url : a?.url?.href,
572
+ url: typeof a?.url === 'string' ? a.url : a?.url?.href || (typeof a?.href === 'string' ? a.href : undefined),
534
573
  mediaType: a?.mediaType || '',
535
574
  ...(a?.name ? { description: a.name } : {}),
536
575
  })).filter(a => a.url);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod-server",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "The FediPod Server: a full ActivityPub server as a Community Solid Server component.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -35,6 +35,11 @@
35
35
  "@types/node": "^20.0.0",
36
36
  "@solid/community-server": "^7.2.0"
37
37
  },
38
+ "overrides": {
39
+ "@opentelemetry/core": "^2.8.0",
40
+ "package.json": "npm:empty-npm-package@1.0.0",
41
+ "package-lock.json": "npm:empty-npm-package@1.0.0"
42
+ },
38
43
  "lsd:module": "https://linkedsoftwaredependencies.org/bundles/npm/fedipod-server",
39
44
  "lsd:components": "dist/components/components.jsonld",
40
45
  "lsd:contexts": {
@@ -50,6 +55,7 @@
50
55
  "build:components": "componentsjs-generator -s src -c dist/components -r fps",
51
56
  "test": "npm run build && node --test test/*.mjs",
52
57
  "test:e2e": "node test/e2e/live-agent.mjs",
58
+ "prepublishOnly": "npm test",
53
59
  "prepack": "node scripts/pack-tree.mjs copy",
54
60
  "postpack": "node scripts/pack-tree.mjs clean"
55
61
  }
package/run-agent.mjs CHANGED
@@ -47,7 +47,7 @@ import { Lease } from './lib/lease.mjs';
47
47
  import { startAdmin } from './lib/admin.mjs';
48
48
  import { exposureProblem, hostLabel } from './lib/guard.mjs';
49
49
  import { pendingSteps } from './lib/migrate.mjs';
50
- import { apUrls } from './lib/wire.mjs';
50
+ import { apUrls, assertionKeyId } from './lib/wire.mjs';
51
51
  import { followActor, unfollowActor, resolveHandle } from './lib/social.mjs';
52
52
 
53
53
  export class Agent {
@@ -324,11 +324,14 @@ export class Agent {
324
324
  clearInterval(this.schedTimer);
325
325
  this.deliverer = new Deliverer({
326
326
  store: this.store, rsaPrivate: keys.rsaPrivate, keyId: this.urls.actor + '#main-key',
327
+ actorId: this.urls.actor, edPrivate: keys.edPrivate,
328
+ proofKeyId: assertionKeyId(this.urls),
327
329
  log: this.log, passive: this.viewer,
328
330
  });
329
331
  this.publisher = new Publisher({
330
332
  config, remote: this.remote, local: this.local, store: this.store,
331
- deliverer: this.deliverer, publicKeyPem: keys.rsaPublicPem, log: this.log,
333
+ deliverer: this.deliverer, publicKeyPem: keys.rsaPublicPem,
334
+ assertionKey: keys.edPublicMultibase, log: this.log,
332
335
  resolveMention: (h) => resolveHandle(this, h),
333
336
  // Whether the fediverse tree is on the pod at all, so the ACL check does
334
337
  // not probe for something the default layout keeps on local disk.
@@ -355,7 +358,9 @@ export class Agent {
355
358
  this.intake.bskyGroup = null;
356
359
  if (this.viewer) {
357
360
  this.startViewer();
358
- this.log(`another agent is active for this pod — viewing as @${config.handle} (read-only)`);
361
+ this.log(this.lease.denied === 'unreadable'
362
+ ? `the pod cannot be read, so the lease is unknown — viewing as @${config.handle} (read-only)`
363
+ : `another agent is active for this pod — viewing as @${config.handle} (read-only)`);
359
364
  return true;
360
365
  }
361
366
  await this.startActive({ repair });
@@ -389,7 +394,9 @@ export class Agent {
389
394
  log: this.log,
390
395
  });
391
396
  this.publisher.publicKeyPem = keys.rsaPublicPem;
397
+ this.publisher.assertionKey = keys.edPublicMultibase;
392
398
  this.deliverer.rsaPrivate = keys.rsaPrivate;
399
+ this.deliverer.edPrivate = keys.edPrivate;
393
400
  await this.publisher.publishProfile();
394
401
  return { changed: before !== keys.rsaPublicPem, publicKeyPem: keys.rsaPublicPem };
395
402
  }
@@ -141,7 +141,7 @@ function render() {
141
141
  ['local host', (origins.named || origins.loopback || `http://localhost:${config.port}`)
142
142
  .replace(/\/$/, '')],
143
143
  ];
144
- if (config.update || config.pendingUpgrade?.length) rows.push(['software', 'ctl']);
144
+ if (config.version || config.update || config.pendingUpgrade?.length) rows.push(['software', 'ctl']);
145
145
  if (config.quiescedAt) rows.push(['parked since', config.quiescedAt]);
146
146
  if (config.movedTo) rows.push(['moved to', config.movedTo]);
147
147
  // A person gates followers here; a group's gate is the joins control on its
@@ -190,8 +190,13 @@ function render() {
190
190
  dd.append(UPDATE_CTL);
191
191
  UPDATE_CTL.hidden = false;
192
192
  const u = config.update;
193
+ // The version this agent is running, never the one sitting in the
194
+ // checkout — saying otherwise would name a version nobody is serving.
195
+ const running = config.version || u?.current || null;
193
196
  const words = [];
194
- if (u) words.push(u.available ? `FediPod ${u.current} — ${u.latest} available` : `FediPod ${u.current}`);
197
+ if (running) words.push(u?.available ? `FediPod ${running} — ${u.latest} available` : `FediPod ${running}`);
198
+ if (config.versionOnDisk && running && config.versionOnDisk !== running)
199
+ words.push(`${config.versionOnDisk} is on disk — restart to run it`);
195
200
  if (config.pendingUpgrade?.length) words.push('older data layout — run `fedipod upgrade` in a terminal');
196
201
  UPDATE_WORD.textContent = words.join('; ');
197
202
  UPDATE_GO.hidden = !u?.available;
@@ -232,6 +237,7 @@ function render() {
232
237
  renderAliases();
233
238
  renderOthers();
234
239
  renderInbox();
240
+ renderGateway();
235
241
  if (config.kind === 'group') {
236
242
  // Its lists have no bound, so this page scrolls — see body.group in the CSS.
237
243
  document.body.classList.add('group');
@@ -931,9 +937,62 @@ async function renderInbox() {
931
937
  panel.hidden = false;
932
938
  }
933
939
 
934
- // The inbox-gateway / multi-user-front operator panel is parked: not shown in
935
- // the UI yet. The backend routes (GET/POST /gateway) still exist and work, so
936
- // this can be re-added when the feature is ready to expose.
940
+ // The gateway panel: attach through a multi-user front with this agent's own
941
+ // credential, see what the door has verified, detach back to the pod inbox.
942
+ let gwTimer = null;
943
+ async function renderGateway() {
944
+ const { status, json: g } = await api('/gateway');
945
+ if (status !== 200 || !g) return; // no answer, no surface
946
+ $('pane-gateway').hidden = false;
947
+ if (g.configured) {
948
+ const host = (() => { try { return new URL(g.url).host; } catch { return g.url; } })();
949
+ const st = g.stats || {};
950
+ $('gateway-summary').textContent = `Attached to ${host} (mode ${g.mode})`
951
+ + (g.frontActor ? `, publishing as ${g.frontActor}` : '')
952
+ + ` — ${st.verified || 0} deliveries verified, ${st.unverified || 0} unverified.`;
953
+ $('gateway-attach-form').hidden = true;
954
+ $('gateway-attached').hidden = false;
955
+ } else {
956
+ $('gateway-summary').textContent = 'Mail arrives directly at your pod\'s own inbox.';
957
+ $('gateway-attach-form').hidden = false;
958
+ $('gateway-attached').hidden = true;
959
+ if (!$('gw-name').value && config?.handle) $('gw-name').value = config.handle;
960
+ }
961
+ }
962
+ // Live availability, asked through the agent (the front answers it without CORS).
963
+ function gwCheck() {
964
+ clearTimeout(gwTimer);
965
+ const front = $('gw-front').value.trim().replace(/\/+$/, '');
966
+ const name = $('gw-name').value.trim().toLowerCase();
967
+ $('gw-name-msg').textContent = ''; $('gw-name-msg').className = 'hint';
968
+ if (!front || !name) return;
969
+ gwTimer = setTimeout(async () => {
970
+ const { status, json } = await postJson('/gateway', { action: 'check', front, handle: name });
971
+ if (status !== 200 || !json) return;
972
+ $('gw-name-msg').textContent = json.available
973
+ ? `${name} is free at ${front.replace(/^https?:\/\//, '')}`
974
+ : (json.reason || 'that name is taken');
975
+ $('gw-name-msg').className = json.available ? 'hint' : 'warn';
976
+ }, 300);
977
+ }
978
+ $('gw-front').addEventListener('input', gwCheck);
979
+ $('gw-name').addEventListener('input', gwCheck);
980
+ $('gw-attach').onclick = async () => {
981
+ const front = $('gw-front').value.trim().replace(/\/+$/, '');
982
+ const fronted = $('gw-shape').value === 'front';
983
+ $('gw-attach').disabled = true;
984
+ const r = await write('/gateway',
985
+ { action: 'attach', front, handle: $('gw-name').value.trim().toLowerCase(), fronted },
986
+ fronted ? 'attached — restart the agent to publish under the gateway name'
987
+ : 'attached — your mail now arrives through the gateway, filtered');
988
+ $('gw-attach').disabled = false;
989
+ if (r) renderGateway();
990
+ };
991
+ $('gw-detach').onclick = async () => {
992
+ const r = await write('/gateway', { action: 'forget' },
993
+ 'detached — the actor was republished advertising your pod\'s own inbox');
994
+ if (r) renderGateway();
995
+ };
937
996
 
938
997
  $('inbox-keep').addEventListener('click', () => {
939
998
  dismissed = true;
@@ -314,9 +314,36 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
314
314
  </span>
315
315
  </section>
316
316
 
317
- <!-- The inbox-gateway / multi-user-front controls are deliberately not shown
318
- in the UI yet. The backend routes (GET/POST /gateway) exist and work; the
319
- operator surface is parked until the feature is ready to expose. -->
317
+ <!-- The inbox gateway: a mail filter for an account that stays yours. -->
318
+ <section id="pane-gateway" hidden>
319
+ <h2><span class="hlabel">Gateway</span></h2>
320
+ <p id="gateway-summary"></p>
321
+ <div id="gateway-attach-form" hidden>
322
+ <p class="hint">A gateway filters your fediverse mail before it reaches your pod: your
323
+ actor advertises the gateway's door as its inbox, each delivery is verified there,
324
+ spam is dropped, and the rest lands in your pod as before. Attaching proves the pod
325
+ with this agent's own credential — no password leaves this machine.</p>
326
+ <p>
327
+ <label for="gw-front">Gateway</label>
328
+ <input type="url" id="gw-front" placeholder="https://fedipod.net" autocomplete="off">
329
+ <label for="gw-name">Your name there</label>
330
+ <input type="text" id="gw-name" autocomplete="off" autocapitalize="off" spellcheck="false">
331
+ </p>
332
+ <p id="gw-name-msg" class="hint"></p>
333
+ <p>
334
+ <select id="gw-shape" aria-label="Name shape" title=" Which address the fediverse sees — the pod-based name keeps everything on your pod; a gateway-based name survives changing pods">
335
+ <option value="pod">keep my pod-based name</option>
336
+ <option value="front">use a gateway-based name</option>
337
+ </select>
338
+ <button id="gw-attach" class="primary" title=" Create the gateway account and point your mail through its door">Attach</button>
339
+ </p>
340
+ </div>
341
+ <div id="gateway-attached" hidden>
342
+ <p>
343
+ <button id="gw-detach" class="inline danger" title=" Republish the actor with your pod's own inbox and forget the gateway">Detach</button>
344
+ </p>
345
+ </div>
346
+ </section>
320
347
 
321
348
  <section id="pane-group" hidden>
322
349
  <!-- Both queues are consequences of a moderation setting: with the setting off