fedipod-server 0.19.0 → 0.21.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/dist/claims.js CHANGED
@@ -19,7 +19,8 @@ function claims(input, frontHost) {
19
19
  const bare = String(input.host).split(':')[0].toLowerCase();
20
20
  if (bare !== String(frontHost).toLowerCase())
21
21
  return false; // a pod subdomain → not ours
22
- return FRONT_PATHS.has(input.pathname) || input.pathname.startsWith('/u/');
22
+ return FRONT_PATHS.has(input.pathname) || input.pathname.startsWith('/u/')
23
+ || input.pathname.startsWith('/@'); // the short profile address
23
24
  }
24
25
  // What an identity answers on its own pod's origin: the protocol routes other
25
26
  // software addresses it by, and the one path its owner's pages live under.
@@ -35,7 +36,7 @@ const AGENT_PATHS = new Set([
35
36
  // at this name before, so no pod resource is displaced.
36
37
  '/.well-known/oauth-authorization-server',
37
38
  ]);
38
- const AGENT_PREFIXES = ['/api/', '/oauth/'];
39
+ const AGENT_PREFIXES = ['/api/', '/oauth/', '/@']; // /@handle: the short profile address
39
40
  // An identity's inbox is the container `/<root>/ap/inbox/`, matched by shape
40
41
  // rather than a fixed root. A POST here is a delivery, verified at the door
41
42
  // before it is written; the handler checks the exact path per identity. The
@@ -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,13 +183,16 @@ 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 };
143
192
  }
144
193
 
145
194
  await discovery.writeWebfinger(this.remote, urls,
146
- wire.jrd({ handle: this.config.handle, host, actor: urls.actor }));
195
+ wire.jrd({ handle: this.config.handle, host, actor: urls.actor, page: urls.profileHtml }));
147
196
  await discovery.writeHostMeta(this.remote, urls, wire.hostMeta(urls.base));
148
197
 
149
198
  const nodeinfoDocUrl = urls.home + 'ap/nodeinfo-2.0';
@@ -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
@@ -52,11 +52,14 @@ export function hostMeta(base) {
52
52
  return `<?xml version="1.0" encoding="UTF-8"?>\n<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">\n <Link rel="lrdd" template="${base}.well-known/webfinger?resource={uri}"/>\n</XRD>\n`;
53
53
  }
54
54
 
55
- export function jrd({ handle, host, actor, aliases = [] }) {
55
+ export function jrd({ handle, host, actor, aliases = [], page = null }) {
56
56
  return {
57
57
  subject: `acct:${handle}@${host}`,
58
58
  ...(aliases.length ? { aliases } : {}),
59
- links: [{ rel: 'self', type: 'application/activity+json', href: actor }],
59
+ links: [
60
+ { rel: 'self', type: 'application/activity+json', href: actor },
61
+ ...(page ? [{ rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: page }] : []),
62
+ ],
60
63
  };
61
64
  }
62
65
 
@@ -193,9 +196,24 @@ export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null
193
196
  // the actor document. It says who this is and how to follow — the handle to
194
197
  // paste, and the remote-follow control that hands the visitor to their own
195
198
  // server's follow screen.
196
- 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' }) {
197
203
  const esc = (s) => String(s ?? '').replace(/[&<>"]/g, c => ({ ...HTML_ESCAPES, '"': '&quot;' }[c]));
198
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');
199
217
  return `<!doctype html>
200
218
  <html lang="en">
201
219
  <head>
@@ -205,14 +223,23 @@ export function profilePageHtml({ name, address, summary = null, icon = null, ki
205
223
  <style>
206
224
  :root { color-scheme: light dark; }
207
225
  body { font: 112.5%/1.5 system-ui, sans-serif; max-width: 34rem; margin: 3rem auto; padding: 0 1rem; }
208
- 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; }
209
228
  h1 { margin: .5rem 0 0; }
210
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; }
211
238
  form { margin-top: 2rem; }
212
239
  label { display: block; margin-bottom: .3rem; }
213
240
  input { font: inherit; padding: .5rem; width: 14rem; max-width: 100%; }
214
241
  button { font: inherit; padding: .5rem 1rem; }
215
- .hint { color: #777; font-size: .9rem; }
242
+ .hint { color: #666; font-size: 1rem; }
216
243
  .err { color: #b00020; }
217
244
  .err:empty { display: none; }
218
245
  @media (prefers-color-scheme: dark) {
@@ -223,10 +250,22 @@ button { font: inherit; padding: .5rem 1rem; }
223
250
  </head>
224
251
  <body>
225
252
  <main>
253
+ ${image ? `<img class="header" src="${esc(image)}" alt="">` : ''}
226
254
  ${icon ? `<img class="avatar" src="${esc(icon)}" alt="">` : ''}
227
255
  <h1>${esc(name)}</h1>
228
256
  <p class="address">${esc(address)}</p>
229
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>` : ''}
230
269
  <p>This is ${what} on the Fediverse. To follow it, paste the address above
231
270
  into the search box of Mastodon or any Fediverse app — or use the form.</p>
232
271
  <form id="follow">
@@ -181,6 +181,14 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
181
181
  const scheme = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
182
182
  return json(res, 200, masto.authorizationServerMetadata(`${scheme}://${req.headers.host}${mount}`));
183
183
  }
184
+ // The short address a person types: /@handle opens the profile page.
185
+ const at = /^\/@([^/]+)$/u.exec(p);
186
+ const ownHandle = handle || agent.store?.getConfig?.()?.handle || '';
187
+ if (at && ownHandle && agent.publisher?.urls?.profileHtml
188
+ && decodeURIComponent(at[1]).toLowerCase() === String(ownHandle).toLowerCase()) {
189
+ res.writeHead(302, { location: agent.publisher.urls.profileHtml, 'cache-control': 'no-store' });
190
+ res.end(); return;
191
+ }
184
192
  if (atDoor && gate(req, res)) return;
185
193
  if (p === '/api/v1/streaming/health') {
186
194
  res.writeHead(200, { 'content-type': 'text/plain' }); res.end('OK'); return;
@@ -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.
@@ -26,11 +26,17 @@ import { podBaseOfWebId } from '../pod/urls.mjs';
26
26
  // wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
27
27
  // friends), which a serverless front must never carry — it crashed the
28
28
  // deployed function before it answered its first request.
29
- const jrd = ({ handle, host, actor, aliases = [] }) => ({
29
+ const jrd = ({ handle, host, actor, aliases = [], page = null }) => ({
30
30
  subject: `acct:${handle}@${host}`,
31
31
  ...(aliases.length ? { aliases } : {}),
32
- links: [{ rel: 'self', type: 'application/activity+json', href: actor }],
32
+ links: [
33
+ { rel: 'self', type: 'application/activity+json', href: actor },
34
+ ...(page ? [{ rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: page }] : []),
35
+ ],
33
36
  });
37
+ // The public profile page of any account the front answers for lives on
38
+ // its pod, whether the address is the front's or the pod's own.
39
+ const profilePageOf = (rec) => (rec?.podHome ? rec.podHome + 'ap/profile.html' : null);
34
40
 
35
41
  const AP_CT = 'application/activity+json';
36
42
 
@@ -600,13 +606,29 @@ async function route(request, ctx) {
600
606
  // is the alias, so a client signing in by the fronted address can find
601
607
  // the pod (and its login) without a lookup only the host could answer.
602
608
  const podActor = rec.inboxOnly ? [] : [rec.podHome + 'ap/actor'];
603
- const out = j(200, jrd({ handle: m[1], host: ctx.host, actor: rec.actorUrl, aliases: podActor }),
609
+ const out = j(200, jrd({ handle: m[1], host: ctx.host, actor: rec.actorUrl, aliases: podActor, page: profilePageOf(rec) }),
604
610
  'application/jrd+json');
605
611
  // RFC 7033: a JRD is read from any origin.
606
612
  out.headers['access-control-allow-origin'] = '*';
607
613
  return out;
608
614
  }
609
615
 
616
+ // The short address a person types: https://<host>/@handle opens the
617
+ // profile page, the way most Fediverse servers spell it. /@handle@pod-host
618
+ // is the same for an account whose address is its pod's, which the front
619
+ // holds under that full address — the form other servers use for a
620
+ // profile that lives elsewhere.
621
+ const at = /^\/@([^/@]+)(?:@([^/@]+))?$/u.exec(pathname);
622
+ if (at) {
623
+ const handle = decodeURIComponent(at[1]).toLowerCase();
624
+ const host = at[2] ? decodeURIComponent(at[2]).toLowerCase() : null;
625
+ const key = host && host !== String(ctx.host).toLowerCase() ? `${handle}@${host}` : handle;
626
+ const rec = await ctx.lookup(key);
627
+ const page = rec && profilePageOf(rec);
628
+ if (!page) return notFound();
629
+ return { status: 302, headers: { location: page, 'cache-control': 'no-store' }, body: '' };
630
+ }
631
+
610
632
  const up = parseUserPath(pathname);
611
633
  if (!up) return notFound();
612
634
  const rec = await ctx.lookup(up.handle);
@@ -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-server",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "The FediPod Server: a full ActivityPub server as a Community Solid Server component.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
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: () => {