fedipod-server 0.7.0 → 0.9.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 +38 -8
- package/config/server.json +11 -0
- package/dist/claims.js +16 -5
- package/dist/components/context.jsonld +6 -0
- package/dist/directory.d.ts +11 -0
- package/dist/directory.js +19 -0
- package/dist/handler.d.ts +18 -2
- package/dist/handler.js +63 -27
- package/dist/handler.jsonld +27 -0
- package/lib/acctfeed.mjs +257 -0
- package/lib/admin.mjs +187 -7
- package/lib/bskygroup.mjs +1 -1
- package/lib/c2s.mjs +104 -4
- package/lib/embed.mjs +6 -1
- package/lib/fediacct.mjs +257 -0
- package/lib/front-core.mjs +74 -4
- package/lib/intake.mjs +21 -2
- package/lib/links.mjs +35 -0
- package/lib/mastoapi.mjs +298 -13
- package/lib/publisher.mjs +12 -1
- package/lib/remote.mjs +119 -3
- package/lib/setup.mjs +56 -0
- package/lib/storage.mjs +8 -2
- package/lib/store.mjs +26 -1
- package/lib/wire.mjs +20 -5
- package/package.json +1 -1
- package/phanpy/dist/assets/main-BdqNbG-a.js +1 -1
- package/phanpy/dist/compose/index.html +1 -1
- package/phanpy/dist/index.html +1 -1
- package/phanpy/dist/sw.js +2 -25
- package/phanpy/dist/sw.js.map +1 -0
- package/run-agent.mjs +35 -0
- package/web/admin/admin.js +81 -22
- package/web/admin/index.html +37 -8
- package/web/admin/setup/index.html +20 -4
- package/web/admin/setup/setup.js +50 -10
- package/web/front/admin.html +133 -0
- package/web/front/new-account.html +2 -2
- package/web/front/run.html +20 -16
- package/web/front/solid-oidc-client.js +6 -0
- package/web/front/solid-client-authn.bundle.js +0 -2
package/lib/c2s.mjs
CHANGED
|
@@ -5,14 +5,26 @@
|
|
|
5
5
|
// SAME helper the facade and admin surfaces use — this module re-implements
|
|
6
6
|
// no persistence and no delivery, so one write path stays one.
|
|
7
7
|
//
|
|
8
|
-
// GETs are redirects: the pod's documents are the
|
|
9
|
-
// and a second renderer here would only drift from them.
|
|
8
|
+
// GETs on the actor and outbox are redirects: the pod's documents are the
|
|
9
|
+
// canonical ones, and a second renderer here would only drift from them.
|
|
10
|
+
//
|
|
11
|
+
// The inbox is the exception, and has to be. Deliveries land in a container on
|
|
12
|
+
// the pod which the drain empties as it handles each item, so reading that
|
|
13
|
+
// container tells the owner only what has not been dealt with yet. What was
|
|
14
|
+
// actually received is whole only in the archive, so §5.2's "the owner can
|
|
15
|
+
// read their own inbox" is served from there, by this agent, to the owner
|
|
16
|
+
// alone.
|
|
10
17
|
|
|
11
18
|
import * as social from './social.mjs';
|
|
12
19
|
import * as wire from './wire.mjs';
|
|
13
20
|
|
|
14
21
|
const MAX_BODY = 512 * 1024; // same ceiling the inbox drain enforces
|
|
15
22
|
|
|
23
|
+
// How many archived items one page of the inbox will read. A page is one
|
|
24
|
+
// month, and a month with more than this is served short rather than costing
|
|
25
|
+
// the pod an unbounded read; the log says when that happened.
|
|
26
|
+
const MAX_INBOX_PAGE = 500;
|
|
27
|
+
|
|
16
28
|
// §6 names activities; anything else with a type is an object to wrap.
|
|
17
29
|
const ACTIVITY_TYPES = new Set([
|
|
18
30
|
'Create', 'Update', 'Delete', 'Follow', 'Like', 'Announce', 'Undo',
|
|
@@ -60,14 +72,102 @@ export class C2S {
|
|
|
60
72
|
return iri ? this.store.getStatuses().find((s) => s.noteId === iri) : null;
|
|
61
73
|
}
|
|
62
74
|
|
|
75
|
+
/** The months the archive holds, newest first. One container listing. */
|
|
76
|
+
async archiveMonths() {
|
|
77
|
+
const archive = this.agent.intake?.archive;
|
|
78
|
+
if (!archive) return [];
|
|
79
|
+
const { names } = await archive.list('');
|
|
80
|
+
return (names || [])
|
|
81
|
+
.map((n) => n.replace(/\/$/u, ''))
|
|
82
|
+
.filter((n) => /^\d{4}-\d{2}$/u.test(n))
|
|
83
|
+
.sort()
|
|
84
|
+
.reverse();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The owner's own inbox, §5.2. Paged by month because that is how the
|
|
89
|
+
* archive is stored, so a page costs one listing and a read per item and no
|
|
90
|
+
* page is dearer for another month being large.
|
|
91
|
+
*/
|
|
92
|
+
async sendInbox(res, url) {
|
|
93
|
+
const id = `${this.urls.base}ap/inbox`;
|
|
94
|
+
const page = url?.searchParams?.get('page') || null;
|
|
95
|
+
let months;
|
|
96
|
+
try {
|
|
97
|
+
months = await this.archiveMonths();
|
|
98
|
+
} catch (e) {
|
|
99
|
+
return this.send(res, 502, { error: `the archive could not be read: ${e.message}` });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!page) {
|
|
103
|
+
if (!months.length && this.store.getConfig()?.archiveInbox === false) {
|
|
104
|
+
this.log('inbox read: nothing to show — this identity does not keep what it receives');
|
|
105
|
+
}
|
|
106
|
+
return this.send(res, 200, {
|
|
107
|
+
'@context': wire.AS_CTX, id, type: 'OrderedCollection',
|
|
108
|
+
...(months.length ? { first: `${id}?page=${months[0]}` } : { orderedItems: [] }),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (!/^\d{4}-\d{2}$/u.test(page)) {
|
|
112
|
+
return this.send(res, 400, { error: 'page names a month, written 2026-09' });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const archive = this.agent.intake?.archive;
|
|
116
|
+
let names = [];
|
|
117
|
+
try {
|
|
118
|
+
// The trailing slash matters: without it this names a document, not the
|
|
119
|
+
// container, and a pod answers about the wrong thing.
|
|
120
|
+
({ names } = await archive.list(`${page}/`));
|
|
121
|
+
} catch (e) {
|
|
122
|
+
return this.send(res, 502, { error: `the archive could not be read: ${e.message}` });
|
|
123
|
+
}
|
|
124
|
+
const files = (names || []).filter((n) => n.endsWith('.json')).sort();
|
|
125
|
+
if (files.length > MAX_INBOX_PAGE) {
|
|
126
|
+
this.log(`inbox read: ${page} holds ${files.length} items; serving the first ${MAX_INBOX_PAGE}`);
|
|
127
|
+
}
|
|
128
|
+
const kept = [];
|
|
129
|
+
for (const file of files.slice(0, MAX_INBOX_PAGE)) {
|
|
130
|
+
// Read as written: these records are JSON-LD, and the default read asks
|
|
131
|
+
// turtle-first, which a server is free to answer with turtle.
|
|
132
|
+
const read = await archive.read(`${page}/${file}`, { accept: '*/*' });
|
|
133
|
+
if (!read?.ok || !read.body) continue;
|
|
134
|
+
try {
|
|
135
|
+
const record = JSON.parse(read.body);
|
|
136
|
+
// The record wraps the bytes as they arrived; the activity is those
|
|
137
|
+
// bytes, not a retelling of them.
|
|
138
|
+
kept.push({ at: record.receivedAt || '', activity: JSON.parse(record.raw) });
|
|
139
|
+
} catch { /* a record that will not parse is not one that can be served */ }
|
|
140
|
+
}
|
|
141
|
+
kept.sort((a, b) => String(b.at).localeCompare(String(a.at)));
|
|
142
|
+
const older = months.filter((m) => m < page)[0] || null;
|
|
143
|
+
return this.send(res, 200, {
|
|
144
|
+
'@context': wire.AS_CTX,
|
|
145
|
+
id: `${id}?page=${page}`,
|
|
146
|
+
type: 'OrderedCollectionPage',
|
|
147
|
+
partOf: id,
|
|
148
|
+
...(older ? { next: `${id}?page=${older}` } : {}),
|
|
149
|
+
orderedItems: kept.map((k) => k.activity),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
63
153
|
async handle(req, res, pathname, url) { // eslint-disable-line no-unused-vars
|
|
64
|
-
if (pathname !== '/ap/outbox' && pathname !== '/ap/actor') return false;
|
|
154
|
+
if (pathname !== '/ap/outbox' && pathname !== '/ap/actor' && pathname !== '/ap/inbox') return false;
|
|
65
155
|
if (req.method === 'OPTIONS') {
|
|
66
|
-
res.writeHead(204, { allow: 'GET, POST, OPTIONS' });
|
|
156
|
+
res.writeHead(204, { allow: pathname === '/ap/inbox' ? 'GET, OPTIONS' : 'GET, POST, OPTIONS' });
|
|
157
|
+
res.end(); return true;
|
|
67
158
|
}
|
|
68
159
|
if (!this.agent.configured() || !this.urls) {
|
|
69
160
|
return this.send(res, 409, { error: 'agent not configured' });
|
|
70
161
|
}
|
|
162
|
+
if (pathname === '/ap/inbox') {
|
|
163
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
164
|
+
return this.send(res, 405, { error: "deliveries go to this actor's inbox on the pod, which the "
|
|
165
|
+
+ 'actor document names; this address is the owner reading their own' });
|
|
166
|
+
}
|
|
167
|
+
const reader = await this.auth(req, pathname);
|
|
168
|
+
if (!reader.ok) return this.send(res, reader.status, { error: reader.error });
|
|
169
|
+
return this.sendInbox(res, url);
|
|
170
|
+
}
|
|
71
171
|
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
72
172
|
// The pod's copy is the document; send the reader there.
|
|
73
173
|
const target = pathname === '/ap/actor' ? this.urls.actor : this.urls.outbox;
|
package/lib/embed.mjs
CHANGED
|
@@ -210,5 +210,10 @@ export async function startEmbeddedAgent({
|
|
|
210
210
|
]);
|
|
211
211
|
};
|
|
212
212
|
|
|
213
|
-
|
|
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
|
}
|
package/lib/fediacct.mjs
ADDED
|
@@ -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
|
+
}
|
package/lib/front-core.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
261
|
-
// here because this function owns every path on the domain.
|
|
262
|
-
if (pathname === '/solid-client
|
|
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
|
|
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
|
+
};
|