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 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.
@@ -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
- export function profilePageHtml({ name, address, summary = null, icon = null, kind = 'person' }) {
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, '"': '&quot;' }[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.avatar { width: 6rem; height: 6rem; border-radius: 1rem; object-fit: cover; }
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: #777; font-size: .9rem; }
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,
@@ -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.
@@ -137,6 +137,7 @@ function ensureCredential(home, { podBase, webId }) {
137
137
  const rec = {
138
138
  webId,
139
139
  remotePod: podBase.endsWith('/') ? podBase : podBase + '/',
140
+ createdAt: new Date().toISOString(),
140
141
  root: 'fedipod/',
141
142
  keysMode: 'pod',
142
143
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod",
3
- "version": "1.10.0",
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?.();
@@ -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: () => {