fedipod-server 0.6.0 → 0.8.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/bskygroup.mjs CHANGED
@@ -30,7 +30,7 @@ export class BskyGroup {
30
30
  // ONE reply ever: tell an unbridged joiner how to reach the fediverse side.
31
31
  async nudge({ did, handle }) {
32
32
  const text = `@${handle} welcome! Follow @ap.brid.gy and your posts will reach `
33
- + 'the fediverse side of this group too.';
33
+ + 'the Fediverse side of this group too.';
34
34
  const start = 0;
35
35
  const rec = this.atproto.read();
36
36
  await this.atproto.xrpc('com.atproto.repo.createRecord', {
package/lib/embed.mjs CHANGED
@@ -210,5 +210,10 @@ export async function startEmbeddedAgent({
210
210
  ]);
211
211
  };
212
212
 
213
- return { agent, handle, home, surface, host: authorities.host, stop };
213
+ // podHome and actorUrl are the identity's own locations on the pod. They are
214
+ // returned rather than rebuilt by the caller so the root name lives here.
215
+ return {
216
+ agent, handle, home, surface, host: authorities.host,
217
+ podHome: urls.home, actorUrl: urls.actor, stop,
218
+ };
214
219
  }
@@ -0,0 +1,257 @@
1
+ // fediacct.mjs — the agent's connections to fediverse accounts the owner holds
2
+ // on OTHER servers. The agent is an API client of an existing Mastodon-API
3
+ // account, the same relationship a Mastodon app has to this agent, and the same
4
+ // one atproto.mjs has to a Bluesky account. Nothing here is a second identity:
5
+ // no key, no pod, nothing published.
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.
11
+ //
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.
15
+
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import crypto from 'node:crypto';
19
+ import { writeJsonAtomic } from './home.mjs';
20
+ import { safeFetch, retryAfterMs } from './safefetch.mjs';
21
+
22
+ const DIR = 'fediaccts';
23
+ const APPS = '_apps';
24
+ const SCOPES = 'read write';
25
+ const STATE_TTL_MS = 10 * 60_000;
26
+ const CLIENT_NAME = 'FediPod';
27
+
28
+ // The id becomes a file name, and both halves come from a remote server's own
29
+ // answer. `--handle ../escape` is the shape of bug this project has already
30
+ // paid for once, so the result is checked rather than trusted.
31
+ export function safeId(user, host) {
32
+ const id = `${String(user || '').trim()}@${String(host || '').trim()}`
33
+ .toLowerCase().replace(/[^a-z0-9.@_-]/g, '-');
34
+ if (!/^[a-z0-9][a-z0-9.@_-]{0,80}$/.test(id) || id.includes('..')) return null;
35
+ return id;
36
+ }
37
+
38
+ // A host as we will address it: bare authority, no scheme, no path, no case.
39
+ export function cleanHost(host) {
40
+ const raw = String(host || '').trim().replace(/^https?:\/\//i, '').replace(/\/.*$/, '');
41
+ return /^[a-z0-9.-]+(:\d+)?$/i.test(raw) ? raw.toLowerCase() : null;
42
+ }
43
+
44
+ export class FediAccounts {
45
+ constructor({ localDir, actorId = null, log = console.log, fetcher = null }) {
46
+ this.dir = path.join(localDir, DIR);
47
+ this.actorId = actorId;
48
+ this.log = log;
49
+ // Injectable for tests; the default is the politeness stack, which also
50
+ // refuses private addresses — a connected account is on the public web.
51
+ this.fetcher = fetcher || ((url, init) => safeFetch(url, init));
52
+ this.pausedUntil = new Map(); // host → epoch ms
53
+ this.pending = new Map(); // state nonce → { host, redirectUri, at }
54
+ }
55
+
56
+ // ---- records ----
57
+
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
+ }
65
+
66
+ read(id) {
67
+ let rec;
68
+ try { rec = JSON.parse(fs.readFileSync(this._path(id), 'utf8')); } catch { return null; }
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}`);
71
+ return null;
72
+ }
73
+ return rec;
74
+ }
75
+
76
+ write(rec) {
77
+ fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
78
+ writeJsonAtomic(this._path(rec.id), rec);
79
+ }
80
+
81
+ list() { return this.ids().map(id => this.read(id)).filter(Boolean); }
82
+
83
+ connected() { return this.list().some(r => r.token && r.enabled !== false); }
84
+
85
+ // What may be written to pod state: who, where, and whether it is polled.
86
+ roster() {
87
+ return this.list().map(r => ({
88
+ id: r.id, handle: r.handle, host: r.host,
89
+ addedAt: r.addedAt || null, enabled: r.enabled !== false,
90
+ }));
91
+ }
92
+
93
+ status() {
94
+ return this.list().map(r => ({
95
+ id: r.id, handle: r.handle, host: r.host,
96
+ enabled: r.enabled !== false,
97
+ needsReconnect: !!r.needsReconnect,
98
+ cooldownFor: Math.max(0, Math.round(((this.pausedUntil.get(r.host) || 0) - Date.now()) / 1000)),
99
+ }));
100
+ }
101
+
102
+ setEnabled(id, on) {
103
+ const rec = this.read(id);
104
+ if (!rec) return null;
105
+ this.write({ ...rec, enabled: !!on });
106
+ return this.roster().find(r => r.id === id) || null;
107
+ }
108
+
109
+ remove(id) {
110
+ if (!this.read(id)) return false;
111
+ try { fs.rmSync(this._path(id)); } catch { return false; }
112
+ this.log(`Fediverse account disconnected: ${id}`);
113
+ return true;
114
+ }
115
+
116
+ // ---- HTTP ----
117
+
118
+ async _fetch(host, url, init) {
119
+ const left = (this.pausedUntil.get(host) || 0) - Date.now();
120
+ if (left > 0) throw new Error(`${host} asked us to back off — ${Math.ceil(left / 1000)}s left`);
121
+ const res = await this.fetcher(url, init);
122
+ if (res.status === 429 || res.status === 503) {
123
+ const ms = retryAfterMs(res) ?? 60_000;
124
+ this.pausedUntil.set(host, Date.now() + ms);
125
+ this.log(`${host} answered ${res.status} — pausing for ${Math.round(ms / 1000)}s`);
126
+ }
127
+ return res;
128
+ }
129
+
130
+ async _json(res, host) {
131
+ const body = await res.json().catch(() => ({}));
132
+ if (res.status >= 400) {
133
+ const err = new Error(body.error_description || body.error || `${host} answered ${res.status}`);
134
+ err.status = res.status;
135
+ throw err;
136
+ }
137
+ return body;
138
+ }
139
+
140
+ // ---- the OAuth dance, as the client ----
141
+
142
+ // One app registration per host, remembered. The redirect_uri is part of what
143
+ // was registered, so an agent that moved to another port re-registers rather
144
+ // than sending the server a redirect it will refuse.
145
+ 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 */ }
151
+ const res = await this._fetch(host, `https://${host}/api/v1/apps`, {
152
+ method: 'POST',
153
+ headers: { 'content-type': 'application/json' },
154
+ body: JSON.stringify({
155
+ client_name: CLIENT_NAME, redirect_uris: redirectUri,
156
+ scopes: SCOPES, website: 'https://github.com/jeff-zucker/FediPod',
157
+ }),
158
+ });
159
+ const body = await this._json(res, host);
160
+ if (!body.client_id || !body.client_secret) throw new Error(`${host} registered no client`);
161
+ 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);
164
+ return app;
165
+ }
166
+
167
+ // Step one: where to send the browser. The state nonce is single-use and
168
+ // short-lived — without one, any page the owner visits could hand our
169
+ // callback somebody else's authorization code and bind THEIR account here.
170
+ async begin({ host, redirectUri }) {
171
+ const h = cleanHost(host);
172
+ if (!h) throw new Error('that is not a server address');
173
+ const app = await this.appFor(h, redirectUri);
174
+ const state = crypto.randomBytes(24).toString('hex');
175
+ const now = Date.now();
176
+ for (const [k, v] of this.pending) if (now - v.at > STATE_TTL_MS) this.pending.delete(k);
177
+ this.pending.set(state, { host: h, redirectUri, at: now });
178
+ const u = new URL(`https://${h}/oauth/authorize`);
179
+ u.searchParams.set('client_id', app.clientId);
180
+ u.searchParams.set('redirect_uri', redirectUri);
181
+ u.searchParams.set('response_type', 'code');
182
+ u.searchParams.set('scope', SCOPES);
183
+ u.searchParams.set('state', state);
184
+ return { url: u.href, state, host: h };
185
+ }
186
+
187
+ // Step two: the code comes back. Exchange it, ask the server who we now are,
188
+ // and only then write anything.
189
+ async complete({ state, code }) {
190
+ const pend = this.pending.get(state);
191
+ if (!pend || Date.now() - pend.at > STATE_TTL_MS) throw new Error('that sign-in has expired — start again');
192
+ this.pending.delete(state);
193
+ const { host, redirectUri } = pend;
194
+ const app = await this.appFor(host, redirectUri);
195
+ const res = await this._fetch(host, `https://${host}/oauth/token`, {
196
+ method: 'POST',
197
+ headers: { 'content-type': 'application/json' },
198
+ body: JSON.stringify({
199
+ grant_type: 'authorization_code', code, redirect_uri: redirectUri,
200
+ client_id: app.clientId, client_secret: app.clientSecret, scope: SCOPES,
201
+ }),
202
+ });
203
+ const tok = await this._json(res, host);
204
+ if (!tok.access_token) throw new Error(`${host} returned no token`);
205
+ const me = await this.whoami(host, tok.access_token);
206
+ const id = safeId(me.username, host);
207
+ if (!id) throw new Error(`${host} answered with a name we cannot file safely`);
208
+ this.write({
209
+ id, host, token: tok.access_token, scope: tok.scope || SCOPES,
210
+ handle: `@${me.username}@${host}`, acct: me.acct || me.username,
211
+ accountId: String(me.id), actorUrl: me.url || null,
212
+ name: me.display_name || me.username, icon: me.avatar || null,
213
+ addedAt: new Date().toISOString(), enabled: true,
214
+ ...(this.actorId ? { mintedFor: this.actorId } : {}),
215
+ });
216
+ this.log(`Fediverse account connected: @${me.username}@${host}`);
217
+ return this.roster().find(r => r.id === id);
218
+ }
219
+
220
+ async whoami(host, token) {
221
+ const res = await this._fetch(host, `https://${host}/api/v1/accounts/verify_credentials`, {
222
+ headers: { authorization: `Bearer ${token}` },
223
+ });
224
+ const me = await this._json(res, host);
225
+ if (!me.username) throw new Error(`${host} did not say who we are`);
226
+ return me;
227
+ }
228
+
229
+ // ---- authenticated calls on behalf of one connected account ----
230
+
231
+ // Returns the Response, so a caller can read rate-limit headers. A 401 means
232
+ // the token was revoked on the far side: the account is marked rather than
233
+ // deleted, because the owner should be told rather than quietly dropped.
234
+ async api(id, pathname, init = {}) {
235
+ const rec = this.read(id);
236
+ if (!rec?.token) throw new Error(`no connected account ${id}`);
237
+ const url = pathname.startsWith('https://') ? pathname : `https://${rec.host}${pathname}`;
238
+ const res = await this._fetch(rec.host, url, {
239
+ ...init,
240
+ headers: { ...(init.headers || {}), authorization: `Bearer ${rec.token}` },
241
+ });
242
+ if (res.status === 401) {
243
+ if (!rec.needsReconnect) this.write({ ...rec, needsReconnect: true });
244
+ const err = new Error(`${rec.handle} no longer accepts our token — reconnect it`);
245
+ err.status = 401;
246
+ throw err;
247
+ }
248
+ if (rec.needsReconnect && res.status < 400) this.write({ ...rec, needsReconnect: false });
249
+ return res;
250
+ }
251
+
252
+ async apiJson(id, pathname, init = {}) {
253
+ const rec = this.read(id);
254
+ const res = await this.api(id, pathname, init);
255
+ return this._json(res, rec?.host || id);
256
+ }
257
+ }
@@ -18,6 +18,7 @@
18
18
  import crypto from 'node:crypto';
19
19
  import { handleDelivery } from './gateway-core.mjs';
20
20
  import { readCapped } from './safefetch.mjs';
21
+ import { linkTargets, REL } from './links.mjs';
21
22
 
22
23
  // The one WebFinger document, spelled out here rather than imported from
23
24
  // wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
@@ -48,6 +49,24 @@ async function verifyPodToken(request, pathname, verifier) {
48
49
  // Is this WebID served by the claimed pod? A pod owner's WebID lives on the pod
49
50
  // origin — that is the whole proof: a token for a WebID under podHome could
50
51
  // only be minted by someone who controls that pod's identity provider.
52
+ // A pod that will not answer must not hold up the person opting in; without
53
+ // an answer the older check stands on its own.
54
+ const OWNER_LOOKUP_MS = 5_000;
55
+
56
+ /**
57
+ * Who the pod server says owns the pod. The server that hosts it is the
58
+ * authority on that, so when it answers, its answer decides. A server that
59
+ * says nothing leaves where the WebID lives as the only evidence there is.
60
+ */
61
+ async function podOwners(podBase, fetchImpl = fetch) {
62
+ try {
63
+ const res = await fetchImpl(podBase, {
64
+ method: 'HEAD', signal: AbortSignal.timeout(OWNER_LOOKUP_MS),
65
+ });
66
+ return linkTargets(res?.headers?.get?.('link'), REL.owner, podBase);
67
+ } catch { return []; }
68
+ }
69
+
51
70
  function webidUnderPod(webid, podHome) {
52
71
  try { return new URL(webid).origin === new URL(podHome).origin; } catch { return false; }
53
72
  }
@@ -130,6 +149,8 @@ function identFor(rec, policy = null) {
130
149
  // host the front's own host, e.g. "fedipod.net" (for WebFinger subjects)
131
150
  // frontOrigin "https://fedipod.net"
132
151
  // lookup(handle) -> record | null the directory
152
+ // listDirectory() -> { handle: record } every row, for the admin roster
153
+ // removeDirectory(handle) -> boolean drop a row; false when a seeded row remains
133
154
  // podPut(url, body, ct) -> boolean append to a user's pod (per-user cred inside)
134
155
  // podGet(url) -> Response read a user's pod (public reads; plain fetch is fine)
135
156
  export async function routeFront(request, ctx) {
@@ -155,6 +176,51 @@ export async function routeFront(request, ctx) {
155
176
  return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: ctx.runPage };
156
177
  }
157
178
 
179
+ // The admin page: the host reading who has accounts here. The page signs in
180
+ // and calls the roster API below; a deploy with no admin supplies no page.
181
+ if (pathname === '/admin') {
182
+ if (request.method !== 'GET' && request.method !== 'HEAD') return { status: 405, headers: {}, body: '' };
183
+ if (!ctx.adminPage) return notFound();
184
+ return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: ctx.adminPage };
185
+ }
186
+
187
+ // The roster: every directory row, secrets stripped, for the host's own
188
+ // eyes. The reader proves themself the way attach proves a pod — a
189
+ // Solid-OIDC token — and must be the WebID the deploy names as admin.
190
+ if (pathname === '/api/roster') {
191
+ if (!ctx.listDirectory || !ctx.adminWebId) return j(501, { error: 'this front has no roster to offer' });
192
+ const webid = await verifyPodToken(request, pathname, ctx.verifier);
193
+ if (!webid) return j(401, { error: 'a Solid-OIDC token is required' });
194
+ if (webid !== ctx.adminWebId) return j(403, { error: 'that WebID is not the admin of this front' });
195
+ const rows = await ctx.listDirectory();
196
+ const accounts = Object.values(rows)
197
+ .map((r) => ({
198
+ handle: r.handle, kind: r.kind || 'person', fronted: !r.inboxOnly,
199
+ podHome: r.podHome, webId: r.webId || null, actorUrl: r.actorUrl,
200
+ address: `@${r.handle}@${ctx.host}`,
201
+ }))
202
+ .sort((a, b) => a.handle.localeCompare(b.handle));
203
+ return j(200, { host: ctx.host, accounts });
204
+ }
205
+
206
+ // Revoke: the admin removes an account's row, so the server stops answering
207
+ // for the name. Nothing on the user's pod is touched. Attach-created rows go
208
+ // for good; a row seeded in the deploy's environment can only be removed there.
209
+ if (pathname === '/api/revoke' && request.method === 'POST') {
210
+ if (!ctx.removeDirectory || !ctx.adminWebId) return j(501, { error: 'this front cannot revoke accounts' });
211
+ const webid = await verifyPodToken(request, pathname, ctx.verifier);
212
+ if (!webid) return j(401, { error: 'a Solid-OIDC token is required' });
213
+ if (webid !== ctx.adminWebId) return j(403, { error: 'that WebID is not the admin of this front' });
214
+ let body;
215
+ try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
216
+ const handle = String(body.handle || '').toLowerCase();
217
+ if (!(await ctx.lookup(handle))) return j(404, { error: 'no such account' });
218
+ const removed = (await ctx.removeDirectory(handle)) === true;
219
+ return j(200, removed ? { handle, removed }
220
+ : { handle, removed: false,
221
+ reason: 'this row is seeded in the deploy environment (FEDIPOD_DIRECTORY_JSON) — remove it there and redeploy' });
222
+ }
223
+
158
224
  // Live handle check for the page: valid shape AND not already in the
159
225
  // directory. Also tells the page whether this host offers pods.
160
226
  if (pathname === '/api/handle') {
@@ -238,7 +304,11 @@ export async function routeFront(request, ctx) {
238
304
  } catch { return j(400, { error: 'podBase is not a URL' }); }
239
305
  const webid = await verifyPodToken(request, pathname, ctx.verifier);
240
306
  if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
241
- if (!webid.startsWith(podBase)) {
307
+ // The pod's own server names its owner when it can. Where it does, that is
308
+ // the proof; where it does not, the WebID must at least live under the pod.
309
+ const owners = await podOwners(podBase, ctx.fetchImpl || fetch);
310
+ const proven = owners.length ? owners.includes(webid) : webid.startsWith(podBase);
311
+ if (!proven) {
242
312
  return j(403, { error: 'the token proves a different pod than the one you listed' });
243
313
  }
244
314
  if (action === 'opt-in') {
@@ -257,9 +327,9 @@ export async function routeFront(request, ctx) {
257
327
  return j(400, { error: 'action must be opt-in or opt-out' });
258
328
  }
259
329
 
260
- // The vendored Solid-OIDC browser library the signup page loads served
261
- // here because this function owns every path on the domain.
262
- if (pathname === '/solid-client-authn.bundle.js') {
330
+ // The vendored Solid-OIDC browser library the /run and /admin pages load
331
+ // served here because this function owns every path on the domain.
332
+ if (pathname === '/solid-oidc-client.js') {
263
333
  if (!ctx.authBundle) return notFound();
264
334
  return { status: 200, headers: { 'content-type': 'text/javascript' }, body: ctx.authBundle };
265
335
  }
package/lib/intake.mjs CHANGED
@@ -18,6 +18,7 @@ import * as $rdf from 'rdflib';
18
18
  import { USER_AGENT } from './ua.mjs';
19
19
  import { PUBLIC } from './wire.mjs';
20
20
  import { HTTP_TIMEOUT_MS, readCapped } from './safefetch.mjs';
21
+ import { linkTargets, REL } from './links.mjs';
21
22
  import { dropFollower } from './store.mjs';
22
23
 
23
24
  const RDF = $rdf.Namespace('http://www.w3.org/1999/02/22-rdf-syntax-ns#');
@@ -307,6 +308,24 @@ export class Intake {
307
308
  }
308
309
  }
309
310
 
311
+ /**
312
+ * Where this pod describes the services it offers. The pod says so on any
313
+ * response about one of its resources; the well-known path is only what a
314
+ * pod that says nothing has always used.
315
+ */
316
+ async _storageDescriptionUrl() {
317
+ try {
318
+ const head = await fetch(this.urls.base, {
319
+ method: 'HEAD',
320
+ headers: { 'user-agent': USER_AGENT },
321
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
322
+ });
323
+ const [found] = linkTargets(head.headers.get('link'), REL.storageDescription, this.urls.base);
324
+ if (found) return found;
325
+ } catch { /* the well-known path below */ }
326
+ return this.urls.base + '.well-known/solid';
327
+ }
328
+
310
329
  async _subscribeOnce() {
311
330
  // Reuse a channel we already have rather than asking for another one.
312
331
  const saved = this.store.read(CHANNEL_DOC, null);
@@ -314,13 +333,13 @@ export class Intake {
314
333
  this._openSocket(saved.receiveFrom, true);
315
334
  return;
316
335
  }
317
- const descRes = await fetch(this.urls.base + '.well-known/solid', {
336
+ const descUrl = await this._storageDescriptionUrl();
337
+ const descRes = await fetch(descUrl, {
318
338
  headers: { accept: 'text/turtle', 'user-agent': USER_AGENT },
319
339
  signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
320
340
  });
321
341
  // The service description is RDF; ask rdflib which subject is the
322
342
  // WebSocketChannel2023 service rather than pattern-matching the document.
323
- const descUrl = this.urls.base + '.well-known/solid';
324
343
  const g = $rdf.graph();
325
344
  try { $rdf.parse(await readCapped(descRes), g, descUrl, 'text/turtle'); }
326
345
  catch (e) { this.wsState = 'unavailable'; this.log(`service description unparsable (${e.message}) — polling only`); return; }
package/lib/links.mjs ADDED
@@ -0,0 +1,35 @@
1
+ // links.mjs — reading RFC 8288 Link headers.
2
+ //
3
+ // Solid says where a resource's access control lives, where a storage
4
+ // describes itself, and who owns a storage, by putting a link on the response.
5
+ // Working any of those out from the resource's own URL instead is exactly what
6
+ // the specs tell clients not to do, so this is the one place that reads them.
7
+
8
+ /**
9
+ * Every target a Link header gives for one relation, resolved against the URL
10
+ * the header came from. A header may carry several links, and one link may
11
+ * carry several relation names.
12
+ */
13
+ export function linkTargets(headerValue, rel, baseUrl) {
14
+ if (!headerValue) return [];
15
+ const wanted = String(rel).toLowerCase();
16
+ const out = [];
17
+ // Split on the commas BETWEEN links: one inside a URI has its closing angle
18
+ // bracket still ahead of it, and is left alone.
19
+ for (const part of String(headerValue).split(/,(?![^<]*>)/u)) {
20
+ const link = /^\s*<([^>]*)>\s*(.*)$/u.exec(part);
21
+ if (!link) continue;
22
+ const relParam = /(?:^|;)\s*rel\s*=\s*(?:"([^"]*)"|([^;"\s]+))/iu.exec(link[2]);
23
+ const names = (relParam?.[1] ?? relParam?.[2] ?? '').toLowerCase().split(/\s+/u);
24
+ if (!names.includes(wanted)) continue;
25
+ try { out.push(new URL(link[1], baseUrl).href); } catch { /* not a URL we can follow */ }
26
+ }
27
+ return out;
28
+ }
29
+
30
+ /** The relations this project follows. */
31
+ export const REL = {
32
+ acl: 'acl',
33
+ storageDescription: 'http://www.w3.org/ns/solid/terms#storageDescription',
34
+ owner: 'http://www.w3.org/ns/solid/terms#owner',
35
+ };
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, followsNeedApproval } from './wire.mjs';
15
+ import { sanitizeHtml, followsNeedApproval, publicHandle } 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';
@@ -201,7 +201,7 @@ export class MastoApi {
201
201
  // No fallback handle. `account()` already reads config for self, so a
202
202
  // literal here only ever supplies a name that is not this actor's — and it
203
203
  // was a real person's, so every install with no handle called itself jeff.
204
- return this.account(this.urls.actor, { selfAcct: this.store.getConfig()?.handle || null });
204
+ return this.account(this.urls.actor, { selfAcct: publicHandle(this.store.getConfig()) });
205
205
  }
206
206
 
207
207
  account(actorUrl, { selfAcct } = {}) {
@@ -209,7 +209,7 @@ export class MastoApi {
209
209
  let host = '', user = cached.preferredUsername || '';
210
210
  try { host = new URL(actorUrl).host; if (!user) user = new URL(actorUrl).pathname.split('/').pop(); } catch {}
211
211
  const self = actorUrl === this.urls?.actor;
212
- if (self) user = selfAcct || this.store.getConfig()?.handle || user;
212
+ if (self) user = selfAcct || publicHandle(this.store.getConfig()) || user;
213
213
  return {
214
214
  id: this.store.idFor(actorUrl),
215
215
  username: user,
@@ -327,6 +327,76 @@ export class MastoApi {
327
327
  } catch (e) { return send(422, { error: e.message }); }
328
328
  }
329
329
 
330
+ // Which account acts: the one that saw the post, and the first of them when
331
+ // several did. Undo inverts that — a like fans IN to one account, an unlike
332
+ // fans OUT to every account holding one, because a stray like left behind
333
+ // after the owner asked for it to go is the worse failure.
334
+ async acctAction(send, s, verb) {
335
+ const accounts = this.agent.fediaccts;
336
+ const held = s.sourceAccts || [];
337
+ if (!accounts || !held.length) {
338
+ return send(422, { error: 'this post came from a connected account, and none is connected now' });
339
+ }
340
+ const flag = verb.endsWith('favourite') ? 'favourited' : 'reblogged';
341
+ const undo = verb.startsWith('un');
342
+ const targets = (undo ? held.filter(v => v[flag]) : [held[0]]).filter(v => v?.remoteId);
343
+ if (!targets.length) return send(200, this.status(s));
344
+ try {
345
+ const acted = new Set();
346
+ for (const v of targets) {
347
+ await accounts.api(v.acct,
348
+ `/api/v1/statuses/${encodeURIComponent(v.remoteId)}/${verb}`, { method: 'POST' });
349
+ acted.add(v.acct);
350
+ }
351
+ const next = held.map(v => (acted.has(v.acct) ? { ...v, [flag]: !undo } : v));
352
+ return send(200, this.status(this.store.updateStatus(s.noteId, { sourceAccts: next }) || s));
353
+ } catch (e) { return send(e.status === 401 ? 401 : 422, { error: e.message }); }
354
+ }
355
+
356
+ // The reply exists only on that account's server, so the row added here is
357
+ // its one local copy — the same shape bskyReply uses for the same reason.
358
+ async acctReply(send, body, parent, visibility) {
359
+ const accounts = this.agent.fediaccts;
360
+ const held = (parent.sourceAccts || [])[0];
361
+ if (!accounts || !held?.remoteId) {
362
+ return send(422, { error: 'this post came from a connected account, and none is connected now' });
363
+ }
364
+ if (visibility !== 'public' && visibility !== 'unlisted') {
365
+ return send(422, { error: 'a reply from a connected account is public — pick public or unlisted' });
366
+ }
367
+ if (body.scheduled_at) return send(422, { error: 'a reply from a connected account cannot be scheduled' });
368
+ if ([].concat(body.media_ids || body['media_ids[]'] || []).filter(Boolean).length) {
369
+ return send(422, { error: 'images on a reply from a connected account are not supported' });
370
+ }
371
+ try {
372
+ const out = await accounts.apiJson(held.acct, '/api/v1/statuses', {
373
+ method: 'POST',
374
+ headers: { 'content-type': 'application/json' },
375
+ body: JSON.stringify({
376
+ status: body.status, in_reply_to_id: held.remoteId, visibility,
377
+ ...(body.spoiler_text ? { spoiler_text: String(body.spoiler_text) } : {}),
378
+ }),
379
+ });
380
+ if (!out?.uri) return send(502, { error: 'that server accepted the reply but did not say where it is' });
381
+ const rec = accounts.read(held.acct);
382
+ const actor = out.account?.uri || out.account?.url || rec?.actorUrl;
383
+ if (actor && !this.store.getActors()[actor]) {
384
+ this.store.cacheActor(actor, {
385
+ name: out.account?.display_name || rec?.name,
386
+ preferredUsername: out.account?.username || rec?.acct, type: 'Person',
387
+ });
388
+ }
389
+ this.store.addStatus({
390
+ noteId: out.uri, actor, inReplyTo: parent.noteId,
391
+ content: sanitizeHtml(out.content || ''),
392
+ published: out.created_at || new Date().toISOString(), kind: 'acct',
393
+ sourceAccts: [{ acct: held.acct, remoteId: String(out.id) }],
394
+ ...(out.url && out.url !== out.uri ? { link: out.url } : {}),
395
+ });
396
+ return send(200, this.status(this.store.getStatuses().find(x => x.noteId === out.uri)));
397
+ } catch (e) { return send(422, { error: e.message }); }
398
+ }
399
+
330
400
  status(s, { all } = {}) {
331
401
  const replies = (all || this.store.getStatuses()).filter(x => x.inReplyTo === s.noteId).length;
332
402
  return {
@@ -339,7 +409,11 @@ export class MastoApi {
339
409
  edited_at: s.editedAt || null,
340
410
  uri: s.noteId, url: s.link || s.noteId,
341
411
  replies_count: replies, reblogs_count: 0, favourites_count: 0,
342
- favourited: !!s.favourited, reblogged: !!s.reblogged,
412
+ // True when ANY of the owner's accounts holds it. The flag is really
413
+ // what the next tap will do: an empty star on a post one account has
414
+ // already liked invites a second outward like from a second identity.
415
+ favourited: !!s.favourited || (s.sourceAccts || []).some(v => v.favourited),
416
+ reblogged: !!s.reblogged || (s.sourceAccts || []).some(v => v.reblogged),
343
417
  muted: false, bookmarked: !!s.bookmarked, pinned: !!s.pinned,
344
418
  content: s.content || '',
345
419
  reblog: null, application: null,
@@ -807,6 +881,10 @@ export class MastoApi {
807
881
  // — the parent is not an AP object, so there is no AP note to make.
808
882
  const bskyParent = inReplyTo && this.store.getStatuses().find(x => x.noteId === inReplyTo && x.kind === 'bsky');
809
883
  if (bskyParent) return this.bskyReply(send, body, bskyParent, visibility);
884
+ // A reply to a post a connected account brought us goes out from that
885
+ // account, where the conversation already is.
886
+ const acctParent = inReplyTo && this.store.getStatuses().find(x => x.noteId === inReplyTo && x.kind === 'acct');
887
+ if (acctParent) return this.acctReply(send, body, acctParent, visibility);
810
888
  // Private and direct posts live in an owner-only pod container — and
811
889
  // only on a pod that provably enforces it.
812
890
  if (visibility === 'private' || visibility === 'direct') {
@@ -888,8 +966,24 @@ export class MastoApi {
888
966
  const noteUrl = this.store.urlFor(mStatus[1]);
889
967
  const s = noteUrl && this.store.getStatuses().find(x => x.noteId === noteUrl);
890
968
  if (!s) return send(404, { error: 'Record not found' });
891
- if (s.actor !== this.urls.actor) return send(403, { error: 'not your status' });
969
+ // A post the owner wrote on a connected account is theirs to delete too.
970
+ // It is deleted where it lives — an AP Delete of ours would address an
971
+ // object that was never ours to speak for.
972
+ const onAcct = s.actor !== this.urls.actor
973
+ ? (this.agent.fediaccts?.list() || []).find(r => r.actorUrl && r.actorUrl === s.actor)
974
+ : null;
975
+ if (s.actor !== this.urls.actor && !onAcct) return send(403, { error: 'not your status' });
892
976
  const rendered = this.status(s);
977
+ if (onAcct) {
978
+ const held = (s.sourceAccts || []).find(v => v.acct === onAcct.id);
979
+ if (!held?.remoteId) return send(422, { error: `we do not know where ${onAcct.handle} keeps that post` });
980
+ try {
981
+ await this.agent.fediaccts.api(onAcct.id,
982
+ `/api/v1/statuses/${encodeURIComponent(held.remoteId)}`, { method: 'DELETE' });
983
+ } catch (e) { return send(502, { error: e.message }); }
984
+ this.store.removeStatus(s.noteId);
985
+ return send(200, { ...rendered, text: s.content || '' });
986
+ }
893
987
  // 502, because the refusal is the pod's: the client asked correctly and
894
988
  // the post is still up. Reporting 200 here is what let a deleted post
895
989
  // stay publicly readable with nothing to show for it.
@@ -1172,6 +1266,11 @@ export class MastoApi {
1172
1266
  return send(200, this.status(patch ? (this.store.updateStatus(s.noteId, patch) || s) : s));
1173
1267
  } catch (e) { return send(422, { error: e.message }); }
1174
1268
  }
1269
+ // A post that reached us through a connected account is not ours to
1270
+ // address as the pod actor. Our own posts and our own inbox's timeline
1271
+ // keep the AP path even when a connected account also saw them: where
1272
+ // the pod identity has standing, it is the one that acts.
1273
+ if (s.kind === 'acct') return this.acctAction(send, s, mAction[2]);
1175
1274
  const updated = await social[mAction[2]](this.agent, s);
1176
1275
  return send(200, this.status(updated || s));
1177
1276
  }