fedipod 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/front-core.mjs +47 -0
- package/package.json +1 -1
- package/web/front/admin.html +129 -0
- package/web/front/run.html +2 -1
package/lib/front-core.mjs
CHANGED
|
@@ -130,6 +130,8 @@ function identFor(rec, policy = null) {
|
|
|
130
130
|
// host the front's own host, e.g. "fedipod.net" (for WebFinger subjects)
|
|
131
131
|
// frontOrigin "https://fedipod.net"
|
|
132
132
|
// lookup(handle) -> record | null the directory
|
|
133
|
+
// listDirectory() -> { handle: record } every row, for the admin roster
|
|
134
|
+
// removeDirectory(handle) -> boolean drop a row; false when a seeded row remains
|
|
133
135
|
// podPut(url, body, ct) -> boolean append to a user's pod (per-user cred inside)
|
|
134
136
|
// podGet(url) -> Response read a user's pod (public reads; plain fetch is fine)
|
|
135
137
|
export async function routeFront(request, ctx) {
|
|
@@ -155,6 +157,51 @@ export async function routeFront(request, ctx) {
|
|
|
155
157
|
return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: ctx.runPage };
|
|
156
158
|
}
|
|
157
159
|
|
|
160
|
+
// The admin page: the host reading who has accounts here. The page signs in
|
|
161
|
+
// and calls the roster API below; a deploy with no admin supplies no page.
|
|
162
|
+
if (pathname === '/admin') {
|
|
163
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') return { status: 405, headers: {}, body: '' };
|
|
164
|
+
if (!ctx.adminPage) return notFound();
|
|
165
|
+
return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: ctx.adminPage };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// The roster: every directory row, secrets stripped, for the host's own
|
|
169
|
+
// eyes. The reader proves themself the way attach proves a pod — a
|
|
170
|
+
// Solid-OIDC token — and must be the WebID the deploy names as admin.
|
|
171
|
+
if (pathname === '/api/roster') {
|
|
172
|
+
if (!ctx.listDirectory || !ctx.adminWebId) return j(501, { error: 'this front has no roster to offer' });
|
|
173
|
+
const webid = await verifyPodToken(request, pathname, ctx.verifier);
|
|
174
|
+
if (!webid) return j(401, { error: 'a Solid-OIDC token is required' });
|
|
175
|
+
if (webid !== ctx.adminWebId) return j(403, { error: 'that WebID is not the admin of this front' });
|
|
176
|
+
const rows = await ctx.listDirectory();
|
|
177
|
+
const accounts = Object.values(rows)
|
|
178
|
+
.map((r) => ({
|
|
179
|
+
handle: r.handle, kind: r.kind || 'person', fronted: !r.inboxOnly,
|
|
180
|
+
podHome: r.podHome, webId: r.webId || null, actorUrl: r.actorUrl,
|
|
181
|
+
address: `@${r.handle}@${ctx.host}`,
|
|
182
|
+
}))
|
|
183
|
+
.sort((a, b) => a.handle.localeCompare(b.handle));
|
|
184
|
+
return j(200, { host: ctx.host, accounts });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Revoke: the admin removes an account's row, so the server stops answering
|
|
188
|
+
// for the name. Nothing on the user's pod is touched. Attach-created rows go
|
|
189
|
+
// for good; a row seeded in the deploy's environment can only be removed there.
|
|
190
|
+
if (pathname === '/api/revoke' && request.method === 'POST') {
|
|
191
|
+
if (!ctx.removeDirectory || !ctx.adminWebId) return j(501, { error: 'this front cannot revoke accounts' });
|
|
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
|
+
let body;
|
|
196
|
+
try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
|
|
197
|
+
const handle = String(body.handle || '').toLowerCase();
|
|
198
|
+
if (!(await ctx.lookup(handle))) return j(404, { error: 'no such account' });
|
|
199
|
+
const removed = (await ctx.removeDirectory(handle)) === true;
|
|
200
|
+
return j(200, removed ? { handle, removed }
|
|
201
|
+
: { handle, removed: false,
|
|
202
|
+
reason: 'this row is seeded in the deploy environment (FEDIPOD_DIRECTORY_JSON) — remove it there and redeploy' });
|
|
203
|
+
}
|
|
204
|
+
|
|
158
205
|
// Live handle check for the page: valid shape AND not already in the
|
|
159
206
|
// directory. Also tells the page whether this host offers pods.
|
|
160
207
|
if (pathname === '/api/handle') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Standalone single-actor ActivityPub agent whose wire face, RDF truth and state all live on a Solid pod (CSS). Bundles a Phanpy UI and a Mastodon client-API facade.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>FediPod — accounts on this server</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: light dark; --line:#d9d4c7; --dim:#45443d; --accent:#6a8a5a; }
|
|
9
|
+
@media (prefers-color-scheme: dark) { :root { --dim:#c2c0b4; } }
|
|
10
|
+
body { font: 19px/1.6 system-ui, sans-serif; max-width: 44rem; margin: 2rem auto; padding: 0 1.1rem; }
|
|
11
|
+
h1 { font-size: 1.7rem; margin: 0 0 .2rem; }
|
|
12
|
+
p.lede { color: var(--dim); margin-top: 0; }
|
|
13
|
+
label { display: block; font-weight: 600; margin: .8rem 0 .2rem; }
|
|
14
|
+
input[type=url] { width: 100%; padding: .5rem .6rem; font: inherit;
|
|
15
|
+
border: 1px solid var(--line); border-radius: 8px; box-sizing: border-box; }
|
|
16
|
+
.hint { color: var(--dim); font-size: .95rem; }
|
|
17
|
+
.primary { background: var(--accent); color: #fff; border: none; border-radius: 9px;
|
|
18
|
+
padding: .6rem 1.1rem; font: inherit; cursor: pointer; }
|
|
19
|
+
.primary[disabled] { opacity: .5; cursor: not-allowed; }
|
|
20
|
+
button { font: inherit; cursor: pointer; }
|
|
21
|
+
[hidden] { display: none !important; }
|
|
22
|
+
table { border-collapse: collapse; width: 100%; margin: 1.2rem 0; }
|
|
23
|
+
th, td { text-align: left; padding: .5rem .7rem; border-bottom: 1px solid var(--line); }
|
|
24
|
+
th { font-weight: 600; }
|
|
25
|
+
</style>
|
|
26
|
+
</head>
|
|
27
|
+
<body>
|
|
28
|
+
<h1>Accounts on this server</h1>
|
|
29
|
+
<p class="lede">Every name this server answers for: accounts whose identity lives here, and
|
|
30
|
+
accounts that only use it as their gateway. Sign in as the server's admin to see them.</p>
|
|
31
|
+
|
|
32
|
+
<form id="roster-form">
|
|
33
|
+
<p class="field">
|
|
34
|
+
<label for="roster-issuer">Your identity provider</label>
|
|
35
|
+
<input type="url" id="roster-issuer" autocomplete="off" value="https://solidcommunity.net">
|
|
36
|
+
</p>
|
|
37
|
+
<p class="actions">
|
|
38
|
+
<button type="submit" id="roster-signin" class="primary">Sign in & list accounts</button>
|
|
39
|
+
</p>
|
|
40
|
+
<p class="hint" id="roster-note" hidden></p>
|
|
41
|
+
</form>
|
|
42
|
+
|
|
43
|
+
<table id="roster-table" hidden>
|
|
44
|
+
<thead>
|
|
45
|
+
<tr><th>Account</th><th>Kind</th><th>Identity</th><th>Pod</th><th>Remove</th></tr>
|
|
46
|
+
</thead>
|
|
47
|
+
<tbody id="roster-rows"></tbody>
|
|
48
|
+
</table>
|
|
49
|
+
|
|
50
|
+
<script src="/solid-client-authn.bundle.js"></script>
|
|
51
|
+
<script>
|
|
52
|
+
const $ = (id) => document.getElementById(id);
|
|
53
|
+
const note = (text) => { const n = $('roster-note'); n.hidden = !text; n.textContent = text || ''; };
|
|
54
|
+
|
|
55
|
+
$('roster-issuer').addEventListener('input', () => {
|
|
56
|
+
$('roster-signin').disabled = !/^https?:\/\/\S+/.test($('roster-issuer').value.trim());
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
$('roster-form').addEventListener('submit', (ev) => {
|
|
60
|
+
ev.preventDefault();
|
|
61
|
+
const auth = window.solidClientAuthentication;
|
|
62
|
+
if (!auth) { note('the sign-in library did not load — reload and try again'); return; }
|
|
63
|
+
auth.login({
|
|
64
|
+
oidcIssuer: $('roster-issuer').value.trim(),
|
|
65
|
+
redirectUrl: location.origin + '/admin',
|
|
66
|
+
clientName: 'FediPod admin',
|
|
67
|
+
}).catch((e) => note('sign-in failed to start: ' + e.message));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Back from the identity provider: read the roster with the proven login.
|
|
71
|
+
(async () => {
|
|
72
|
+
const auth = window.solidClientAuthentication;
|
|
73
|
+
if (!auth) return;
|
|
74
|
+
const info = await auth.handleIncomingRedirect().catch(() => null);
|
|
75
|
+
if (!info?.isLoggedIn) return;
|
|
76
|
+
|
|
77
|
+
// Signed fetches build a DPoP proof from the URL, so it must be absolute.
|
|
78
|
+
const load = async () => {
|
|
79
|
+
let res;
|
|
80
|
+
try { res = await auth.fetch(location.origin + '/api/roster'); }
|
|
81
|
+
catch (e) { note('the roster request failed: ' + e.message); return; }
|
|
82
|
+
const d = await res.json().catch(() => ({}));
|
|
83
|
+
if (res.status === 403) { note('signed in as ' + info.webId + ', which is not this server’s admin'); return; }
|
|
84
|
+
if (res.status === 501) { note('this server names no admin — set FEDIPOD_ADMIN_WEBID and redeploy'); return; }
|
|
85
|
+
if (res.status !== 200) { note('roster unavailable: ' + (d.error || 'HTTP ' + res.status)); return; }
|
|
86
|
+
const rows = $('roster-rows');
|
|
87
|
+
rows.textContent = '';
|
|
88
|
+
for (const a of d.accounts || []) {
|
|
89
|
+
const tr = document.createElement('tr');
|
|
90
|
+
const cell = (child) => { const td = document.createElement('td'); td.append(child); tr.append(td); };
|
|
91
|
+
cell(a.address);
|
|
92
|
+
cell(a.kind);
|
|
93
|
+
cell(a.fronted ? 'lives here' : 'gateway only');
|
|
94
|
+
const pod = document.createElement('a');
|
|
95
|
+
pod.href = a.podHome; pod.textContent = new URL(a.podHome).host;
|
|
96
|
+
cell(pod);
|
|
97
|
+
const rm = document.createElement('button');
|
|
98
|
+
rm.type = 'button'; rm.textContent = 'Remove';
|
|
99
|
+
rm.onclick = () => revoke(a);
|
|
100
|
+
cell(rm);
|
|
101
|
+
rows.append(tr);
|
|
102
|
+
}
|
|
103
|
+
$('roster-table').hidden = false;
|
|
104
|
+
note((d.accounts || []).length + ' account(s) on ' + d.host);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const revoke = async (a) => {
|
|
108
|
+
if (!confirm('Remove ' + a.address + ' from this server? The name stops resolving here; nothing on their pod is touched.')) return;
|
|
109
|
+
let res;
|
|
110
|
+
try {
|
|
111
|
+
res = await auth.fetch(location.origin + '/api/revoke', {
|
|
112
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
113
|
+
body: JSON.stringify({ handle: a.handle }),
|
|
114
|
+
});
|
|
115
|
+
} catch (e) { note('the remove request failed: ' + e.message); return; }
|
|
116
|
+
const d = await res.json().catch(() => ({}));
|
|
117
|
+
if (res.status !== 200) { note('remove refused: ' + (d.error || 'HTTP ' + res.status)); return; }
|
|
118
|
+
if (!d.removed) { note(a.address + ' stays: ' + d.reason); return; }
|
|
119
|
+
await load();
|
|
120
|
+
note('removed ' + a.address);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
note('signed in as ' + info.webId + ' — reading the roster…');
|
|
124
|
+
await load();
|
|
125
|
+
})();
|
|
126
|
+
</script>
|
|
127
|
+
<i>(cc) 4.0 By, Jeff Zucker, 2026</i>
|
|
128
|
+
</body>
|
|
129
|
+
</html>
|
package/web/front/run.html
CHANGED
|
@@ -114,7 +114,8 @@ you to keep running. Sign in with your pod to prove it is yours.</p>
|
|
|
114
114
|
const n = $('run-note');
|
|
115
115
|
n.hidden = false;
|
|
116
116
|
n.textContent = 'signed in as ' + info.webId + ' — asking the server…';
|
|
117
|
-
|
|
117
|
+
// The signed fetch builds a DPoP proof from the URL, so it must be absolute.
|
|
118
|
+
const res = await auth.fetch(location.origin + '/api/agent', {
|
|
118
119
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
119
120
|
body: JSON.stringify({ action: p.action, podBase: p.podBase }),
|
|
120
121
|
}).catch(() => null);
|