fedipod 1.10.0 → 1.11.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 +0 -2
- package/lib/client/masto/render.mjs +1 -1
- package/lib/core/publisher/index.mjs +49 -10
- package/lib/core/social.mjs +3 -0
- package/lib/core/wire.mjs +39 -3
- package/lib/device/cli/commands/setup.mjs +1 -0
- package/lib/device/setup.mjs +1 -0
- package/lib/server/embed.mjs +1 -0
- package/package.json +1 -1
- package/run-agent.mjs +4 -0
- package/web/app/agent.mjs +1 -0
- package/web/app/dist/boot.js +1 -0
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +85 -11
- package/web/app/dist/sw.js.map +2 -2
- package/web/app/signup.mjs +1 -0
- package/web/app/site/boot.js +1 -0
- package/web/app/site/sw.js +85 -11
- package/web/front/admin.js +5 -1
package/README.md
CHANGED
|
@@ -37,10 +37,8 @@ Which specs FediPod follows, and where it stops short: [specs-in-use.md](specs-i
|
|
|
37
37
|
4. Enter your pod password once. It creates the account and locks your
|
|
38
38
|
signing key. The password is not stored.
|
|
39
39
|
|
|
40
|
-
<!-- CLAUDE 2026-09-15 — the short profile address; delete these markers when done -->
|
|
41
40
|
Anyone can open your profile at `https://fedipod.net/@handle`, signed in or
|
|
42
41
|
not.
|
|
43
|
-
<!-- /CLAUDE -->
|
|
44
42
|
|
|
45
43
|
Your account then opens in the client. To use it from another browser, go to
|
|
46
44
|
https://fedipod.net, enter your address, sign in at your pod, and unlock your
|
|
@@ -46,7 +46,7 @@ export function account(api, actorUrl, { selfAcct } = {}) {
|
|
|
46
46
|
// asking about itself would be told it was a person.
|
|
47
47
|
bot: false, discoverable: true,
|
|
48
48
|
group: self ? api.store.getConfig()?.kind === 'group' : cached.type === 'Group',
|
|
49
|
-
created_at: '2026-01-01T00:00:00.000Z',
|
|
49
|
+
created_at: (self && api.store.getConfig()?.createdAt) || '2026-01-01T00:00:00.000Z',
|
|
50
50
|
note: (self ? api.store.getConfig()?.summary : cached.summary) || '',
|
|
51
51
|
url: actorUrl, uri: actorUrl,
|
|
52
52
|
avatar: selfIcon(api, self, cached) || TRANSPARENT_PNG,
|
|
@@ -118,6 +118,52 @@ export class Publisher {
|
|
|
118
118
|
});
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
// The human half: a page a browser can open and follow from. The actor
|
|
122
|
+
// document is for servers; this is the address you hand to a person. It
|
|
123
|
+
// shows who the account is, its fields, when it joined and what it pinned;
|
|
124
|
+
// the counts and the posts live in the collections. Written only when its
|
|
125
|
+
// content changed.
|
|
126
|
+
async publishProfilePage({ force = false } = {}) {
|
|
127
|
+
const { urls } = this;
|
|
128
|
+
const host = new URL(urls.base).host;
|
|
129
|
+
// When the account was made. Accounts from before this was recorded take
|
|
130
|
+
// the date of their oldest post, or today.
|
|
131
|
+
if (!this.config.createdAt) {
|
|
132
|
+
const oldest = this.store.getStatuses().filter(s => s.kind === 'post' && s.published)
|
|
133
|
+
.map(s => s.published).sort()[0];
|
|
134
|
+
this.config.createdAt = oldest || new Date().toISOString();
|
|
135
|
+
this.store.setConfig?.({ ...this.store.getConfig?.(), createdAt: this.config.createdAt });
|
|
136
|
+
}
|
|
137
|
+
const pinned = this.store.getStatuses()
|
|
138
|
+
.filter(s => s.kind === 'post' && s.pinned && s.visibility !== 'private' && s.visibility !== 'direct')
|
|
139
|
+
.sort((a, b) => String(b.published).localeCompare(String(a.published)))
|
|
140
|
+
.slice(0, 5)
|
|
141
|
+
.map(s => ({ content: s.content, published: s.published, url: s.noteId }));
|
|
142
|
+
// The address as the world knows it: at the Gateway for a fronted
|
|
143
|
+
// identity, on the pod's host otherwise, the actor id where no host can
|
|
144
|
+
// answer for a handle.
|
|
145
|
+
const frontActor = this.config.gateway?.frontActor;
|
|
146
|
+
const address = frontActor ? `@${wire.publicHandle(this.config)}@${new URL(frontActor).host}`
|
|
147
|
+
: (wire.webfingerHost(urls.base) ? `@${this.config.handle}@${host}` : urls.actor);
|
|
148
|
+
const html = wire.profilePageHtml({
|
|
149
|
+
name: this.config.name || this.config.handle,
|
|
150
|
+
address,
|
|
151
|
+
summary: this.config.summary ? wire.contentHtml(this.config.summary) : null,
|
|
152
|
+
icon: this.config.icon || null,
|
|
153
|
+
image: this.config.image || null,
|
|
154
|
+
fields: this.config.fields || [],
|
|
155
|
+
joined: this.config.createdAt,
|
|
156
|
+
pinned,
|
|
157
|
+
kind: this.config.kind,
|
|
158
|
+
});
|
|
159
|
+
const digest = crypto.createHash('sha256').update(html).digest('hex').slice(0, 32);
|
|
160
|
+
const seen = this.store.read('published.json', {});
|
|
161
|
+
if (!force && seen.pageDigest === digest) return false;
|
|
162
|
+
await podActor.writeProfilePage(this.remote, urls, html);
|
|
163
|
+
this.store.write('published.json', { ...this.store.read('published.json', {}), pageDigest: digest });
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
121
167
|
async publishProfile({ force = false } = {}) {
|
|
122
168
|
const { urls } = this;
|
|
123
169
|
const host = new URL(urls.base).host;
|
|
@@ -137,6 +183,9 @@ export class Publisher {
|
|
|
137
183
|
quiesced: !!this.config.quiescedAt, version: AGENT_VERSION,
|
|
138
184
|
moderators: this.config.moderators || [],
|
|
139
185
|
})).digest('hex').slice(0, 32);
|
|
186
|
+
// The human page has its own gate: it changes with pins and the joined
|
|
187
|
+
// date as well as with the profile, and costs one write when it does.
|
|
188
|
+
await this.publishProfilePage({ force });
|
|
140
189
|
if (!force && this.store.read('published.json', {}).surfaceDigest === surface) {
|
|
141
190
|
this.log('profile unchanged — nothing republished');
|
|
142
191
|
return { unreachable: [], updated: 0, skipped: true };
|
|
@@ -166,16 +215,6 @@ export class Publisher {
|
|
|
166
215
|
// what concerns this identity. Written only while a gateway is advertised.
|
|
167
216
|
if (gwActive) await this.publishGatewayPolicy();
|
|
168
217
|
|
|
169
|
-
// The human half: a page a browser can open and follow from. The actor
|
|
170
|
-
// document is for servers; this is the address you hand to a person.
|
|
171
|
-
await podActor.writeProfilePage(this.remote, urls, wire.profilePageHtml({
|
|
172
|
-
name: this.config.name || this.config.handle,
|
|
173
|
-
address: wire.webfingerHost(urls.base) ? `@${this.config.handle}@${host}` : urls.actor,
|
|
174
|
-
summary: this.config.summary ? wire.contentHtml(this.config.summary) : null,
|
|
175
|
-
icon: this.config.icon || null,
|
|
176
|
-
kind: this.config.kind,
|
|
177
|
-
}));
|
|
178
|
-
|
|
179
218
|
// WebID → actor: the profile card lists the actor as a foaf:account.
|
|
180
219
|
// Best-effort — a profile that cannot be read or edited does not stop the
|
|
181
220
|
// publish, it is logged and the rest of the surface still goes up.
|
package/lib/core/social.mjs
CHANGED
|
@@ -523,6 +523,9 @@ export async function pinStatus(agent, s, pinned) {
|
|
|
523
523
|
if (s.actor !== agent.publisher.urls.actor) throw new Error('not your status');
|
|
524
524
|
const updated = agent.store.updateStatus(s.noteId, { pinned: !!pinned });
|
|
525
525
|
await agent.publisher.publishFeatured().catch(e => agent.log?.(`featured: ${e.message}`));
|
|
526
|
+
if (agent.publisher.publishProfilePage) {
|
|
527
|
+
await agent.publisher.publishProfilePage().catch(e => agent.log?.(`profile page: ${e.message}`));
|
|
528
|
+
}
|
|
526
529
|
agent.publisher.publishProfile?.().catch(() => {});
|
|
527
530
|
return updated || s;
|
|
528
531
|
}
|
package/lib/core/wire.mjs
CHANGED
|
@@ -196,9 +196,24 @@ export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null
|
|
|
196
196
|
// the actor document. It says who this is and how to follow — the handle to
|
|
197
197
|
// paste, and the remote-follow control that hands the visitor to their own
|
|
198
198
|
// server's follow screen.
|
|
199
|
-
|
|
199
|
+
// `fields` are the profile's name/value rows, `joined` an ISO date, `pinned`
|
|
200
|
+
// the pinned posts as { content (sanitized HTML), published, url }.
|
|
201
|
+
export function profilePageHtml({ name, address, summary = null, icon = null, image = null,
|
|
202
|
+
fields = [], joined = null, pinned = [], kind = 'person' }) {
|
|
200
203
|
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, c => ({ ...HTML_ESCAPES, '"': '"' }[c]));
|
|
201
204
|
const what = kind === 'group' ? 'a group' : 'an account';
|
|
205
|
+
const month = (iso) => {
|
|
206
|
+
const d = new Date(iso);
|
|
207
|
+
return Number.isNaN(d.getTime()) ? null
|
|
208
|
+
: d.toLocaleDateString('en', { month: 'long', year: 'numeric', timeZone: 'UTC' });
|
|
209
|
+
};
|
|
210
|
+
const day = (iso) => {
|
|
211
|
+
const d = new Date(iso);
|
|
212
|
+
return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10);
|
|
213
|
+
};
|
|
214
|
+
const fieldRows = fields.filter(f => f?.name && f?.value !== undefined && f?.value !== null && String(f.value) !== '');
|
|
215
|
+
const joinedText = joined ? month(joined) : null;
|
|
216
|
+
const pins = pinned.filter(x => x && typeof x.content === 'string');
|
|
202
217
|
return `<!doctype html>
|
|
203
218
|
<html lang="en">
|
|
204
219
|
<head>
|
|
@@ -208,14 +223,23 @@ export function profilePageHtml({ name, address, summary = null, icon = null, ki
|
|
|
208
223
|
<style>
|
|
209
224
|
:root { color-scheme: light dark; }
|
|
210
225
|
body { font: 112.5%/1.5 system-ui, sans-serif; max-width: 34rem; margin: 3rem auto; padding: 0 1rem; }
|
|
211
|
-
img.
|
|
226
|
+
img.header { width: 100%; max-height: 12rem; object-fit: cover; border-radius: 1rem; }
|
|
227
|
+
img.avatar { width: 6rem; height: 6rem; border-radius: 1rem; object-fit: cover; margin-top: 1rem; }
|
|
212
228
|
h1 { margin: .5rem 0 0; }
|
|
213
229
|
.address { font-size: 1.1rem; user-select: all; }
|
|
230
|
+
dl.fields { display: grid; grid-template-columns: max-content 1fr; gap: .4rem 1rem; margin: 1.5rem 0; }
|
|
231
|
+
dl.fields dt { font-weight: 600; }
|
|
232
|
+
dl.fields dd { margin: 0; overflow-wrap: anywhere; }
|
|
233
|
+
.joined { margin: 1rem 0; }
|
|
234
|
+
section.pinned h2 { font-size: 1.1rem; margin: 2rem 0 .5rem; }
|
|
235
|
+
article.pin { border: 1px solid #8884; border-radius: 1rem; padding: 1rem 1.2rem; margin: 1rem 0; }
|
|
236
|
+
article.pin p:last-child { margin-bottom: 0; }
|
|
237
|
+
article.pin .when { font-size: 1rem; }
|
|
214
238
|
form { margin-top: 2rem; }
|
|
215
239
|
label { display: block; margin-bottom: .3rem; }
|
|
216
240
|
input { font: inherit; padding: .5rem; width: 14rem; max-width: 100%; }
|
|
217
241
|
button { font: inherit; padding: .5rem 1rem; }
|
|
218
|
-
.hint { color: #
|
|
242
|
+
.hint { color: #666; font-size: 1rem; }
|
|
219
243
|
.err { color: #b00020; }
|
|
220
244
|
.err:empty { display: none; }
|
|
221
245
|
@media (prefers-color-scheme: dark) {
|
|
@@ -226,10 +250,22 @@ button { font: inherit; padding: .5rem 1rem; }
|
|
|
226
250
|
</head>
|
|
227
251
|
<body>
|
|
228
252
|
<main>
|
|
253
|
+
${image ? `<img class="header" src="${esc(image)}" alt="">` : ''}
|
|
229
254
|
${icon ? `<img class="avatar" src="${esc(icon)}" alt="">` : ''}
|
|
230
255
|
<h1>${esc(name)}</h1>
|
|
231
256
|
<p class="address">${esc(address)}</p>
|
|
232
257
|
${summary ? `<div>${summary}</div>` : ''}
|
|
258
|
+
${fieldRows.length ? `<dl class="fields">
|
|
259
|
+
${fieldRows.map(f => `<dt>${esc(f.name)}</dt><dd>${esc(f.value)}</dd>`).join('\n')}
|
|
260
|
+
</dl>` : ''}
|
|
261
|
+
${joinedText ? `<p class="joined">Joined ${esc(joinedText)}</p>` : ''}
|
|
262
|
+
${pins.length ? `<section class="pinned">
|
|
263
|
+
<h2>Pinned</h2>
|
|
264
|
+
${pins.map(x => `<article class="pin">
|
|
265
|
+
${x.content}
|
|
266
|
+
<p class="when">${x.url ? `<a href="${esc(x.url)}">${esc(day(x.published))}</a>` : esc(day(x.published))}</p>
|
|
267
|
+
</article>`).join('\n')}
|
|
268
|
+
</section>` : ''}
|
|
233
269
|
<p>This is ${what} on the Fediverse. To follow it, paste the address above
|
|
234
270
|
into the search box of Mastodon or any Fediverse app — or use the form.</p>
|
|
235
271
|
<form id="follow">
|
|
@@ -163,6 +163,7 @@ const credential = await mintCredential({ origin: issuer, email, password, name:
|
|
|
163
163
|
const rec = {
|
|
164
164
|
...credential,
|
|
165
165
|
remotePod: pod.endsWith('/') ? pod : pod + '/',
|
|
166
|
+
createdAt: new Date().toISOString(),
|
|
166
167
|
// The private half goes on THIS machine, exactly as the browser setup does
|
|
167
168
|
// (lib/setup.mjs). Omitting it here meant the two paths produced different
|
|
168
169
|
// installs from the same answers: the CLI put the timeline, contacts,
|
package/lib/device/setup.mjs
CHANGED
|
@@ -293,6 +293,7 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
293
293
|
const rec = {
|
|
294
294
|
...credential,
|
|
295
295
|
remotePod: pod.endsWith('/') ? pod : pod + '/',
|
|
296
|
+
createdAt: new Date().toISOString(),
|
|
296
297
|
...(root ? { root } : {}),
|
|
297
298
|
...(keys === 'pod' ? { keysMode: 'pod' } : {}),
|
|
298
299
|
// Where the private half lives. Per-machine, like keysMode.
|
package/lib/server/embed.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.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",
|
package/run-agent.mjs
CHANGED
|
@@ -588,6 +588,10 @@ export class Agent {
|
|
|
588
588
|
if (repair) {
|
|
589
589
|
this.ensureActorPublished()
|
|
590
590
|
.catch(e => this.log(`actor check failed: ${e.message}`));
|
|
591
|
+
// The human page, rewritten only when what it shows has changed: one
|
|
592
|
+
// local digest compare, and a write the first time after an upgrade.
|
|
593
|
+
this.publisher.publishProfilePage()
|
|
594
|
+
.catch(e => this.log(`profile page: ${e.message}`));
|
|
591
595
|
}
|
|
592
596
|
this.seedFollowNotifications().catch(e => this.log(`notification seeding failed: ${e.message}`));
|
|
593
597
|
}
|
package/web/app/agent.mjs
CHANGED
|
@@ -88,6 +88,7 @@ export class BrowserAgent {
|
|
|
88
88
|
// Own posts the outbox names and the timeline index lacks come back
|
|
89
89
|
// here, before anything acts on the index.
|
|
90
90
|
await this.publisher.healStatuses().catch((e) => this.log(`healing the timeline index: ${e.message}`));
|
|
91
|
+
await this.publisher.publishProfilePage().catch((e) => this.log(`profile page: ${e.message}`));
|
|
91
92
|
// And start delivering again, since demote() stopped it. startQueue() is
|
|
92
93
|
// idempotent, so a goActive() that was already active costs nothing.
|
|
93
94
|
this.deliverer?.startQueue?.();
|
package/web/app/dist/boot.js
CHANGED
|
@@ -33444,6 +33444,7 @@ async function signUp(answers, { onStep = () => {
|
|
|
33444
33444
|
handle,
|
|
33445
33445
|
name: handle,
|
|
33446
33446
|
issuer: credential.issuerOrigin,
|
|
33447
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
33447
33448
|
...gateway ? { gateway } : {}
|
|
33448
33449
|
};
|
|
33449
33450
|
const cfgRemote = new BrowserRemotePod(session, { webId: credential.webId, role: "signup", log: () => {
|