fedipod-server 0.17.0 → 0.18.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.
@@ -99,7 +99,28 @@ export function assertDirectAddressed(content, mentions) {
99
99
  throw e;
100
100
  }
101
101
 
102
- export async function publishNote(publisher, content, { inReplyTo, attachments, visibility = 'public', spoilerText = null } = {}) {
102
+ // A client may name the document it is creating (the Slug of a client-to-server
103
+ // POST). Only a plain name is taken, and only when nothing is there already;
104
+ // otherwise the agent mints one as it always has.
105
+ const SLUG_OK = /^[A-Za-z0-9._-]{1,64}$/u;
106
+ export const safeSlug = (s) => (typeof s === 'string' && SLUG_OK.test(s) && !/^\.+$/u.test(s) ? s : null);
107
+ async function slugFor(publisher, container, wanted, published) {
108
+ const name = safeSlug(wanted);
109
+ if (name && !(await podNotes.read(publisher.remote, container + name).catch(() => null))) return name;
110
+ return published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
111
+ }
112
+
113
+ // What the author's own timeline shows for an object: its content, else its
114
+ // text under one of the names other vocabularies use, else a link to it.
115
+ export function rowContent(obj) {
116
+ if (typeof obj?.content === 'string' && obj.content.trim()) return wire.sanitizeHtml(obj.content);
117
+ const plain = [obj?.bodyValue, obj?.name, obj?.summary].find((v) => typeof v === 'string' && v.trim());
118
+ if (plain) return wire.contentHtml(plain);
119
+ const esc = (v) => String(v).replace(/[&<>"]/gu, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
120
+ return `<p><a href="${esc(obj?.id || '')}">${esc(obj?.type || 'object')}</a></p>`;
121
+ }
122
+
123
+ export async function publishNote(publisher, content, { inReplyTo, attachments, visibility = 'public', spoilerText = null, slug: wanted = null } = {}) {
103
124
  const { urls } = publisher;
104
125
  const priv = visibility === 'private' || visibility === 'direct';
105
126
  if (priv) {
@@ -107,7 +128,7 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
107
128
  if (ready !== true) throw new Error(ready);
108
129
  }
109
130
  const published = new Date().toISOString();
110
- const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
131
+ const slug = await slugFor(publisher, priv ? urls.privateNotes : urls.notes, wanted, published);
111
132
  const mentions = await publisher._mentionsFor(content, inReplyTo);
112
133
  if (visibility === 'direct') assertDirectAddressed(content, mentions);
113
134
  const note = wire.noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions,
@@ -127,6 +148,11 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
127
148
  ...(attachments?.length ? { attachments } : {}),
128
149
  ...(note.tag?.length ? { mentions: note.tag.map(t => ({ href: t.href, name: t.name })) } : {}),
129
150
  });
151
+ // The row is what the author's own timeline reads. Land it before answering
152
+ // — the same PUT the debounce would send in 300 ms — so a worker killed or a
153
+ // write refused after the answer cannot leave a post that stands on the pod
154
+ // and reached followers but never shows to its author.
155
+ if (await publisher.store.commit?.() === false) publisher.log(`post published but its timeline row was refused: ${note.id}`);
130
156
 
131
157
  const create = wire.createActivity(note, urls);
132
158
  // Published as its own document: a group that carries this post wraps the
@@ -164,6 +190,62 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
164
190
  // edit's own stamp. The pod documents are overwritten in place — the Create
165
191
  // too, so a group's Announce resolves to the edited text — and an Update
166
192
  // goes everywhere the Create went.
193
+ // Any object as a post: what a client-to-server Create carries when it is not
194
+ // a Note or a Question — a Web Annotation, say. Stored as sent, with its own
195
+ // context, under this actor; the Create around it is what followers receive.
196
+ // Servers that know the type show it; the rest ignore it, which is the
197
+ // expected outcome.
198
+ //
199
+ // An object already living on this pod (an id under it) is not copied: the
200
+ // Create names it where it is. Anything else is written into the notes
201
+ // container under the client's slug when it is free, else a minted one.
202
+ export async function publishObject(publisher, object, { visibility = 'public', slug: wanted = null } = {}) {
203
+ const { urls } = publisher;
204
+ const priv = visibility === 'private' || visibility === 'direct';
205
+ if (priv) {
206
+ const ready = await publisher.privateReady();
207
+ if (ready !== true) throw new Error(ready);
208
+ }
209
+ const published = new Date().toISOString();
210
+ const container = priv ? urls.privateNotes : urls.notes;
211
+ const pod = publisher.config?.remotePod || urls.base;
212
+ const ownId = typeof object.id === 'string' && /^https?:\/\//u.test(object.id) && object.id.startsWith(pod)
213
+ ? object.id : null;
214
+ const name = await slugFor(publisher, container, wanted, published);
215
+ const addressed = wire.addressing(urls, visibility);
216
+ let doc = null;
217
+ const id = ownId || container + name;
218
+ if (!ownId) {
219
+ doc = { ...object, id, attributedTo: urls.actor, published: object.published || published, to: addressed.to, cc: addressed.cc };
220
+ if (!doc['@context']) doc['@context'] = wire.AS_CTX;
221
+ if (typeof doc.content === 'string') doc.content = wire.sanitizeHtml(doc.content);
222
+ await podNotes.write(publisher.remote, id, doc);
223
+ }
224
+ if (!priv) await publisher.recordOutbox(id);
225
+ const row = doc || object;
226
+ publisher.store.addStatus({
227
+ noteId: id, actor: urls.actor, content: rowContent(row), published: row.published || published,
228
+ kind: 'post', slug: name, visibility,
229
+ });
230
+ if (await publisher.store.commit?.() === false) publisher.log(`object published but its timeline row was refused: ${id}`);
231
+
232
+ // The Create sits beside the object; for an object living elsewhere on the
233
+ // pod it sits in the notes container, whose ACL is public Read.
234
+ const createId = doc ? wire.createActivityId(id) : wire.createActivityId(container + name);
235
+ const create = {
236
+ '@context': wire.AS_CTX, id: createId, type: 'Create', actor: urls.actor,
237
+ published: row.published || published, to: addressed.to, cc: addressed.cc,
238
+ object: doc || id,
239
+ };
240
+ await podNotes.writeCreate(publisher.remote, create.id, create);
241
+ const contacts = publisher.store.getContacts();
242
+ const inboxes = visibility === 'direct' ? []
243
+ : [...new Set(contacts.followers.map((f) => f.sharedInbox || f.inbox).filter(Boolean))];
244
+ await publisher.deliverer.deliverToAll(inboxes, create);
245
+ publisher.log(`${row.type || 'object'} published: ${id} → ${inboxes.length} inbox(es)`);
246
+ return { id, createId, copied: !ownId };
247
+ }
248
+
167
249
  export async function updateNote(publisher, s, { content, spoilerText = null, attachments = null } = {}) {
168
250
  const { urls } = publisher;
169
251
  const updated = new Date().toISOString();
@@ -82,6 +82,7 @@ export async function publishQuestion(publisher, content, { options = [], multip
82
82
  ...(spoilerText ? { spoiler: spoilerText } : {}),
83
83
  ...(question.tag?.length ? { mentions: question.tag.map(t => ({ href: t.href, name: t.name })) } : {}),
84
84
  });
85
+ if (await publisher.store.commit?.() === false) publisher.log(`poll published but its timeline row was refused: ${question.id}`);
85
86
 
86
87
  const create = publisher._pollActivity('Create', question, wire.createActivityId(question.id));
87
88
  await podNotes.writeCreate(publisher.remote, create.id, create);
@@ -5,6 +5,7 @@
5
5
  import * as wire from '../wire.mjs';
6
6
  import * as podNotes from '../../pod/notes.mjs';
7
7
  import { readLenient } from '../as2.mjs';
8
+ import { rowContent } from './notes.mjs';
8
9
 
9
10
  const ACCEPT_AP = 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
10
11
  const REBUILD_MAX_PER_RUN = 200;
@@ -151,13 +152,14 @@ export async function rebuildStatuses(publisher, { fromNotes = false } = {}) {
151
152
  if (have.has(id) || removed.has(id)) continue;
152
153
  budget--;
153
154
  const note = await podNotes.read(publisher.remote, id).catch(() => null);
154
- if (note?.type !== 'Note' || note.id !== id || note.attributedTo !== urls.actor) continue;
155
+ // Any object of ours: a Note, a poll, or whatever a client posted.
156
+ if (!note?.type || note.id !== id || note.attributedTo !== urls.actor) continue;
155
157
  const attachments = wire.attachmentsOf(note);
156
158
  const mentions = (Array.isArray(note.tag) ? note.tag : [])
157
159
  .filter(t => t?.type === 'Mention' && t.href)
158
160
  .map(t => ({ href: t.href, name: t.name }));
159
161
  recovered.push({
160
- noteId: note.id, actor: urls.actor, content: note.content || '',
162
+ noteId: note.id, actor: urls.actor, content: rowContent(note),
161
163
  published: note.published || null,
162
164
  ...(note.inReplyTo ? { inReplyTo: note.inReplyTo } : {}),
163
165
  kind: 'post', slug: note.id.slice(urls.notes.length),
@@ -197,3 +199,26 @@ export async function rebuildStatuses(publisher, { fromNotes = false } = {}) {
197
199
  dropped: Math.max(0, merged.length - kept.length),
198
200
  };
199
201
  }
202
+
203
+ // Own posts the outbox records and the statuses index does not.
204
+ //
205
+ // The index row is the last thing a post writes, and it goes by a debounced
206
+ // PUT: a worker killed or a write refused after the post was answered leaves a
207
+ // post that stands on the pod and reached followers but never shows on its
208
+ // author's own timeline. Run when an agent becomes active. Cheap when nothing
209
+ // is wrong — two cached documents compared, no request — and the capped,
210
+ // merge-only rebuild when something is.
211
+ export async function healStatuses(publisher) {
212
+ const { urls, store } = publisher;
213
+ const own = store.read('outbox.json', [])
214
+ .map(i => (typeof i === 'string' ? i : null))
215
+ .filter(id => id && id.startsWith(urls.notes));
216
+ if (!own.length) return { missing: 0, recovered: 0 };
217
+ const have = new Set(store.getStatuses().map(s => s.noteId));
218
+ const removed = new Set(store.read('outbox-removed.json', []).map(r => r.id));
219
+ const missing = own.filter(id => !have.has(id) && !removed.has(id));
220
+ if (!missing.length) return { missing: 0, recovered: 0 };
221
+ const r = await rebuildStatuses(publisher);
222
+ publisher.log(`timeline index healed: ${missing.length} own post(s) were missing, ${r.recovered} recovered`);
223
+ return { missing: missing.length, recovered: r.recovered };
224
+ }
@@ -204,6 +204,7 @@ export async function reblog(agent, s) {
204
204
  // missing entry, a failed status write would let a retry announce twice.
205
205
  const updated = agent.store.updateStatus(s.noteId, { reblogged: true, announceActivity: act });
206
206
  await agent.publisher.recordOutbox(act);
207
+ if (await agent.store.commit?.() === false) agent.log(`boost sent but its timeline row was refused: ${s.noteId}`);
207
208
  return updated;
208
209
  }
209
210
 
@@ -74,6 +74,7 @@ export function dropFollower(contacts, actor, why) {
74
74
 
75
75
  export class PodStore {
76
76
  constructor({ storage = null, log = console.log } = {}) {
77
+ this.lastSkipped = []; // what the last load could not read, as `name (HTTP n)`
77
78
  this.storage = storage;
78
79
  this.log = log;
79
80
  this.cache = new Map(); // name → parsed value
@@ -146,6 +147,9 @@ export class PodStore {
146
147
  try { this.cache.set(name, JSON.parse(r.body)); }
147
148
  catch (e) { this.log(`state load ${name}: unparsable (${e.message})`); }
148
149
  }
150
+ // Kept for /status: a document skipped here is a timeline or a contact
151
+ // list quietly missing, and the owner should be able to see that.
152
+ this.lastSkipped = skipped;
149
153
  if (skipped.length) this.log(`state load skipped ${skipped.length}: ${skipped.join(', ')}`);
150
154
  this.log(`state loaded: ${this.cache.size} doc(s) from ${this.base} (${fetched} re-fetched)`);
151
155
  }
package/lib/core/wire.mjs CHANGED
@@ -605,29 +605,33 @@ export function contentHtml(text, mentions = []) {
605
605
  return '<p>' + html.replace(/\n+/g, '</p><p>') + '</p>';
606
606
  }
607
607
 
608
- export function noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions = [],
609
- visibility = 'public', summary = null, updated = null, container = null }) {
610
- const id = (container || urls.notes) + slug;
611
- const who = mentions.map(m => m.actor);
612
- // Mastodon's four shapes. Public and unlisted are world-readable; private
613
- // (followers-only) and direct carry no Public address at all and belong in
614
- // the owner-only container. Mastodon addresses mentions in cc (to, for a
615
- // direct post) and will not notify anyone it does not find there or in the
616
- // tags.
617
- const addressing = {
608
+ // Mastodon's four shapes. Public and unlisted are world-readable; private
609
+ // (followers-only) and direct carry no Public address at all and belong in
610
+ // the owner-only container. Mastodon addresses mentions in cc (to, for a
611
+ // direct post) and will not notify anyone it does not find there or in the
612
+ // tags.
613
+ export function addressing(urls, visibility, who = []) {
614
+ return {
618
615
  public: { to: [PUBLIC], cc: [urls.followers, ...who] },
619
616
  unlisted: { to: [urls.followers], cc: [PUBLIC, ...who] },
620
617
  private: { to: [urls.followers], cc: who },
621
618
  direct: { to: who, cc: [] },
622
619
  }[visibility] || { to: [PUBLIC], cc: [urls.followers, ...who] };
620
+ }
621
+
622
+ export function noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions = [],
623
+ visibility = 'public', summary = null, updated = null, container = null }) {
624
+ const id = (container || urls.notes) + slug;
625
+ const who = mentions.map(m => m.actor);
626
+ const addressed = addressing(urls, visibility, who);
623
627
  const note = {
624
628
  '@context': AS_CTX,
625
629
  id, type: 'Note',
626
630
  attributedTo: urls.actor,
627
631
  content: contentHtml(content, mentions),
628
632
  published,
629
- to: addressing.to,
630
- cc: addressing.cc,
633
+ to: addressed.to,
634
+ cc: addressed.cc,
631
635
  replies: repliesId(id),
632
636
  };
633
637
  if (summary) note.summary = summary; // the content warning
@@ -16,7 +16,7 @@
16
16
  // HTTPS box is another. UN-DEPLOYED — nothing in FediPod runs it.
17
17
 
18
18
  import crypto from 'node:crypto';
19
- import { handleDelivery } from './gateway-core.mjs';
19
+ import { handleDelivery, handleOwnerPost } from './gateway-core.mjs';
20
20
  import { readCapped, safeFetch, isLoopbackHost } from '../shared/safefetch.mjs';
21
21
  import * as podRoot from '../pod/root.mjs';
22
22
  import * as podPolicy from '../pod/policy.mjs';
@@ -619,6 +619,48 @@ async function route(request, ctx) {
619
619
  return { status, headers: {}, body: '' };
620
620
  }
621
621
 
622
+ // Outbox: the owner's own post, from any client that speaks ActivityPub
623
+ // client-to-server (dokieli, say). Cross-origin by nature — such a client is
624
+ // a page on another site — so this route answers a preflight and carries
625
+ // CORS headers; the owner's pod token is the credential, so any origin may
626
+ // ask. GET stays a read: the pod's document for a fronted account, sent on
627
+ // to the pod for a door account whose documents live at their own address.
628
+ if (up.rest === 'ap/outbox') {
629
+ const cors = {
630
+ 'access-control-allow-origin': '*',
631
+ 'access-control-allow-methods': 'GET, POST, OPTIONS',
632
+ 'access-control-allow-headers': 'Authorization, DPoP, Content-Type, Slug, Link',
633
+ 'access-control-expose-headers': 'Location, Accept-Post',
634
+ 'access-control-max-age': '86400',
635
+ };
636
+ if (request.method === 'OPTIONS') {
637
+ // Accept-Post names JSON only: a client that reads it (dokieli) sends
638
+ // JSON-LD when HTML is not offered.
639
+ // body null, not '': a Response refuses any body on a 204, and the
640
+ // function adapter hands `body` straight to one.
641
+ return { status: 204, headers: { ...cors, allow: 'GET, POST, OPTIONS',
642
+ 'accept-post': 'application/ld+json, application/activity+json' }, body: null };
643
+ }
644
+ if (request.method === 'POST') {
645
+ const json = (status, obj, extra = {}) => ({ status,
646
+ headers: { ...cors, 'content-type': 'application/json', 'cache-control': 'no-store', ...extra },
647
+ body: JSON.stringify(obj) });
648
+ const webid = await verifyPodToken(request, pathname, ctx.verifier);
649
+ if (!webid) return json(401, { error: 'a Solid-OIDC token proving this account\'s owner is required' });
650
+ const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
651
+ if (!owner) return json(403, { error: 'this outbox belongs to its owner alone' });
652
+ const { status, reason, location } = await handleOwnerPost(request, identFor(rec),
653
+ { podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), ownerWebId: webid });
654
+ console.log(`door @${up.handle}: owner post → ${status} (${reason})`);
655
+ if (status !== 202) return json(status, { error: reason });
656
+ return json(202, { accepted: true, ...(location ? { object: location } : {}),
657
+ note: 'it goes out when your FediPod agent next runs' }, location ? { location } : {});
658
+ }
659
+ if (rec.inboxOnly && (request.method === 'GET' || request.method === 'HEAD')) {
660
+ return { status: 303, headers: { ...cors, location: rec.podHome + 'ap/outbox', 'cache-control': 'no-store' }, body: '' };
661
+ }
662
+ }
663
+
622
664
  // Everything else is a public GET, served by reading the user's pod and
623
665
  // rewriting pod ids to the front. The actor also gets its handle and inbox
624
666
  // fixed to the front so a consumer cross-checks it consistently.
@@ -110,4 +110,44 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
110
110
  return { status: 202, reason: v.verified ? 'verified' : 'buffered-unverified' };
111
111
  }
112
112
 
113
+ // The outbox door: the owner's own post, taken on their behalf.
114
+ //
115
+ // A client such as dokieli POSTs an activity — or a bare object — to the
116
+ // outbox address the actor advertises. The door holds no key and mints nothing:
117
+ // it checks that the token proves the account's owner (the caller has already
118
+ // done that and hands in `owner`), writes the bytes into the pod inbox exactly
119
+ // as verified mail is written, and stamps them with a receipt whose method is
120
+ // `c2s` and whose actor is this account. The drain hands such an item to the
121
+ // client-to-server dispatcher, which publishes and delivers it — so the post
122
+ // goes out when the agent next runs, the same way inbound mail is read.
123
+ //
124
+ // `slug` is the name the client asked for its new document. It rides in the
125
+ // receipt so the dispatcher can use it, and it is what lets the door answer a
126
+ // Location before anything exists: the object will live at notesPrefix+slug
127
+ // unless that name is taken, in which case the agent mints another.
128
+ export const SLUG_OK = /^[A-Za-z0-9._-]{1,64}$/u;
129
+ export const safeSlug = (s) => (typeof s === 'string' && SLUG_OK.test(s) && !/^\.+$/u.test(s) ? s : null);
130
+
131
+ export async function handleOwnerPost(request, ident, { podPut, ownerWebId, maxBytes = DEFAULT_MAX_BYTES } = {}) {
132
+ if (!ident.hmacSecret) return { status: 409, reason: 'this account has no door secret — attach it again' };
133
+ let raw;
134
+ try { raw = await request.text(); } catch { return { status: 400, reason: 'unreadable body' }; }
135
+ if (Buffer.byteLength(raw) > maxBytes) return { status: 413, reason: 'too large' };
136
+ let doc;
137
+ try { doc = JSON.parse(raw); } catch { return { status: 400, reason: 'unparsable JSON' }; }
138
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc) || !doc.type) {
139
+ return { status: 400, reason: 'a typed ActivityStreams object is required' };
140
+ }
141
+ const slug = safeSlug(request.headers.get('slug'));
142
+ const receipt = signReceipt({
143
+ v: 1, verified: true, method: 'c2s', keyId: ownerWebId || null, actor: ident.actorUrl,
144
+ checks: ['owner-token'], reason: 'owner', gateway: ident.gatewayWebId, ...(slug ? { slug } : {}),
145
+ }, ident.hmacSecret);
146
+ const hash = sha256hex(raw);
147
+ const okA = await inbox.appendVerifiedDelivery(podPut, ident.inboxUrl, hash, raw);
148
+ if (!okA) return { status: 502, reason: 'pod inbox write failed' };
149
+ await inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt);
150
+ return { status: 202, reason: 'accepted', location: slug && ident.notesPrefix ? ident.notesPrefix + slug : null };
151
+ }
152
+
113
153
  export const _internal = { isBlocked, concernsUsAtEdge, httpUrl, sha256hex };
package/lib/pod/actor.mjs CHANGED
@@ -69,8 +69,8 @@ export async function writeProfilePage(pod, urls, html) {
69
69
  * parsed graph does not mention the WebID, and patches exactly the statements
70
70
  * involved rather than rewriting a document full of things that are not ours.
71
71
  */
72
- export function linkInWebIdProfile(pod, { actorUrl, accountName, kind = 'person' }) {
73
- return pod.linkAccountInProfile({ actorUrl, accountName, kind });
72
+ export function linkInWebIdProfile(pod, { actorUrl, accountName, kind = 'person', outbox = null }) {
73
+ return pod.linkAccountInProfile({ actorUrl, accountName, kind, outbox });
74
74
  }
75
75
 
76
76
  // ---- anyone at all ----
@@ -407,7 +407,7 @@ export class PodTransport {
407
407
  * says all of it. The parsed graph must mention the WebID before anything is
408
408
  * written back — an empty or foreign body must never become the new profile.
409
409
  */
410
- async linkAccountInProfile({ actorUrl, accountName, kind = 'person' }) {
410
+ async linkAccountInProfile({ actorUrl, accountName, kind = 'person', outbox = null }) {
411
411
  const docUrl = this.webId.split('#')[0];
412
412
  const res = await this.fetch(docUrl, { headers: { accept: 'text/turtle' } });
413
413
  if (res.status >= 400) throw new Error(`[${this.label}] GET ${docUrl} → ${res.status}`);
@@ -424,11 +424,17 @@ export class PodTransport {
424
424
  [actor, RDF('type'), FOAF('OnlineAccount')],
425
425
  [actor, RDF('type'), kind === 'group' ? AS('Group') : AS('Person')],
426
426
  [actor, FOAF('accountName'), $rdf.literal(accountName)],
427
+ // Where a Solid client posts on this person's behalf (`as:outbox` on the
428
+ // WebID is what dokieli reads); only where a door exists to take it.
429
+ ...(outbox ? [[me, AS('outbox'), $rdf.sym(outbox)]] : []),
427
430
  ];
428
431
  const missing = wanted.filter(([s, p, o]) => !g.holds(s, p, o, doc));
429
432
  // A handle change leaves the old accountName behind; ours is replaced.
430
- const stale = g.statementsMatching(actor, FOAF('accountName'), null, doc)
431
- .filter(st => st.object.value !== accountName);
433
+ // Likewise an outbox that moved.
434
+ const stale = [
435
+ ...g.statementsMatching(actor, FOAF('accountName'), null, doc).filter(st => st.object.value !== accountName),
436
+ ...(outbox ? g.statementsMatching(me, AS('outbox'), null, doc).filter(st => st.object.value !== outbox) : []),
437
+ ];
432
438
  if (!missing.length && !stale.length) return false;
433
439
  // A patch touches these statements and nothing else. Rewriting the whole
434
440
  // profile re-serialises statements that are not ours — the OIDC issuer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod-server",
3
- "version": "0.17.0",
3
+ "version": "0.18.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
@@ -37,6 +37,7 @@ import { RemotePod } from './lib/device/remote.mjs';
37
37
  import { Deliverer } from './lib/core/deliver.mjs';
38
38
  import { Publisher } from './lib/core/publisher/index.mjs';
39
39
  import { Intake } from './lib/core/intake/index.mjs';
40
+ import { C2S } from './lib/client/c2s.mjs';
40
41
  import { TagFeed } from './lib/connections/tagfeed.mjs';
41
42
  import { ImportWorker } from './lib/connections/import.mjs';
42
43
  import { Atproto } from './lib/connections/atproto.mjs';
@@ -122,6 +123,7 @@ export class Agent {
122
123
  update: this.updateInfo || null,
123
124
  inboxCooldownFor: this.intake?.drainCooldownUntil
124
125
  ? Math.max(0, Math.round((this.intake.drainCooldownUntil - Date.now()) / 1000)) : 0,
126
+ stateSkipped: this.store.lastSkipped || [],
125
127
  };
126
128
  }
127
129
 
@@ -336,11 +338,15 @@ export class Agent {
336
338
  });
337
339
  // Intake is constructed even for viewers — its signed fetchAP powers
338
340
  // search/deref; start() (draining) is active-only.
341
+ // The dispatcher the admin surface also builds; this one is for what the
342
+ // Gateway's outbox door took on the owner's behalf and the drain finds.
343
+ this.c2s = new C2S({ agent: this, log: this.log });
339
344
  this.intake = new Intake({
340
345
  config, urls: this.urls, remote: this.remote, store: this.store,
341
346
  deliverer: this.deliverer, publisher: this.publisher, log: this.log, lease: this.lease,
342
347
  archive: this.privateStorage(cred, 'archive'),
343
348
  push: !this.embedded, pollSeconds: this.pollSeconds || null,
349
+ ownerPost: (a, o) => this.c2s.dispatch(a, o),
344
350
  });
345
351
  // The CSV-import worker: paced, resumable, armed only while active.
346
352
  this.importer?.stop();
@@ -514,6 +520,9 @@ export class Agent {
514
520
  this.viewer = false;
515
521
  clearInterval(this.refreshTimer);
516
522
  if (promoted) await this.refreshBeforeActing();
523
+ // Own posts the outbox names and the timeline index lacks come back here,
524
+ // before anything acts on the index.
525
+ await this.publisher.healStatuses().catch(e => this.log(`healing the timeline index: ${e.message}`));
517
526
  this.lease.onLost = () => this.demote();
518
527
  this.lease.startRenewal();
519
528
  this.deliverer.startQueue();
@@ -275,6 +275,8 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
275
275
 
276
276
  <section id="pane-identity" hidden>
277
277
  <dl id="facts"></dl>
278
+ <!-- State documents the agent could not read on its last load, if any. -->
279
+ <p class="err" id="state-skipped" role="alert" hidden></p>
278
280
  <!-- Declared here, and render() puts these on the kind row — the row they
279
281
  belong to is generated, so this is the only place they can be written
280
282
  down. Each reads as the setting it would change, so what it shows IS the
@@ -145,8 +145,16 @@ const INBOX_PROMPT_AT = 500;
145
145
  let dismissed = false;
146
146
 
147
147
  async function renderInbox() {
148
- if (dismissed) return;
149
148
  const { json: st } = await api('/status');
149
+ // A state document the last load could not read is a timeline or a contact
150
+ // list quietly missing; the page says which, under the facts.
151
+ const skipped = st?.stateSkipped || [];
152
+ const line = $('state-skipped');
153
+ line.hidden = !skipped.length;
154
+ line.textContent = skipped.length
155
+ ? `${skipped.length} state document${skipped.length === 1 ? '' : 's'} could not be read on the last load: ${skipped.join(', ')}.`
156
+ : '';
157
+ if (dismissed) return;
150
158
  const box = st?.inbox;
151
159
  const panel = $('pane-inbox');
152
160
  if (!box || box.count < INBOX_PROMPT_AT) { panel.hidden = true; return; }
package/web/app/README.md CHANGED
@@ -7,7 +7,7 @@ See `claude/plans/browser-agent.md` for the whole design and status.
7
7
  |---|---|
8
8
  | `pod-auth.mjs` | The pod side of sign-in, browser-native: create a CSS account + pod, mint a client credential, and a DPoP-bound `fetch` that writes to the pod. The twin of `lib/device/account.mjs` + `vendor/idp-grant.cjs`. |
9
9
  | `keystore.mjs` | WebCrypto RSA/Ed25519 key generation, and wrapping the keys under the account password (PBKDF2-SHA256 + AES-GCM-256). The pod holds only the wrapped form, so the pod's host cannot sign as you. |
10
- | `keys-browser.mjs` | Importing a keys record for signing, and finding one: this browser's opened copy in IndexedDB first, else the pod's. A wrapped one the browser has not opened yet raises `KeyPasswordNeeded`, which `boot.mjs` answers with the unlock pane — once per browser. |
10
+ | `keys-browser.mjs` | Importing a keys record for signing, and finding one: this browser's opened copy in IndexedDB first, else the pod's. A wrapped one the browser has not opened yet raises `KeyPasswordNeeded`, which `boot.mjs` answers with the unlock pane — once per browser. The same pane offers a new key wrapped under the password used now, for someone who no longer has the sign-up password. |
11
11
  | `signup.mjs` | The `fedipod setup` flow, in the browser, up to publish: account, pod, credential, keys locked on the pod (owner-only ACL written *before* the key). Produces the credential/keys/config shapes the agent already reads. |
12
12
  | `shims/fedify-sig.mjs` | Browser stand-in for `@fedify/fedify/sig` (which will not bundle for a browser). `sign()` returns signed headers as data for the relay; `signRequest()` wraps it Fedify-shaped. Proven byte-identical to Fedify. |
13
13
  | `shims/node-crypto.mjs` | Browser stand-in for `node:crypto` — the small synchronous slice the agent uses, via crypto-browserify, plus native WebCrypto. |
package/web/app/agent.mjs CHANGED
@@ -12,6 +12,7 @@ import { PodStore } from '../../lib/core/store.mjs';
12
12
  import { HttpStorage } from '../../lib/core/storage.mjs';
13
13
  import { Publisher } from '../../lib/core/publisher/index.mjs';
14
14
  import { Intake } from '../../lib/core/intake/index.mjs';
15
+ import { C2S } from '../../lib/client/c2s.mjs';
15
16
  import { Lease } from '../../lib/core/lease.mjs';
16
17
  import { MastoApi } from '../../lib/client/masto/index.mjs';
17
18
  import { TagFeed } from '../../lib/connections/tagfeed.mjs';
@@ -84,6 +85,9 @@ export class BrowserAgent {
84
85
  // whole document back over newer state. Read what is actually there
85
86
  // before acting on it.
86
87
  await this.store.load({ force: true }).catch((e) => this.log(`re-reading state: ${e.message}`));
88
+ // Own posts the outbox names and the timeline index lacks come back
89
+ // here, before anything acts on the index.
90
+ await this.publisher.healStatuses().catch((e) => this.log(`healing the timeline index: ${e.message}`));
87
91
  // And start delivering again, since demote() stopped it. startQueue() is
88
92
  // idempotent, so a goActive() that was already active costs nothing.
89
93
  this.deliverer?.startQueue?.();
@@ -257,9 +261,14 @@ export class BrowserAgent {
257
261
  // read-only until the owner acts on it and it takes over. Written with fresh
258
262
  // fetches, never the cached store. Passed into Intake so the drain checks it.
259
263
  this.lease = new Lease({ url: this.urls.state + 'lease.json', fetchImpl: podFetch, log: this.log });
264
+ // The client-to-server dispatcher, here only for what the Gateway's
265
+ // outbox door takes on the owner's behalf: the browser answers no
266
+ // /ap/outbox of its own.
267
+ this.c2s = new C2S({ agent: this, log: this.log });
260
268
  this.intake = new Intake({
261
269
  config: this.store.getConfig(), urls: this.urls, remote: this.remote,
262
270
  store: this.store, deliverer: this.deliverer, publisher: this.publisher, log: this.log, push: true, lease: this.lease,
271
+ ownerPost: (a, o) => this.c2s.dispatch(a, o),
263
272
  });
264
273
  // The Mastodon facade the service worker serves.
265
274
  //
@@ -356,6 +365,7 @@ export class BrowserAgent {
356
365
  podRequests: this.remote?.stats?.() || null,
357
366
  update: null,
358
367
  inboxCooldownFor: 0,
368
+ stateSkipped: this.store?.lastSkipped || [],
359
369
  };
360
370
  }
361
371
 
package/web/app/boot.mjs CHANGED
@@ -16,7 +16,7 @@ import { podBaseOfWebId } from '../../lib/pod/urls.mjs';
16
16
  import { podLayout } from '../../lib/pod/root.mjs';
17
17
  import { BrowserRemotePod } from './pod-remote.mjs';
18
18
  import { beginLogin, completeLogin, getSession, signOut } from './oidc-session.mjs';
19
- import { unwrapKeys, isKeyEnvelope } from './keystore.mjs';
19
+ import { generateKeys, wrapKeys, unwrapKeys, isKeyEnvelope } from './keystore.mjs';
20
20
  import { cacheOpenedKeys } from './keys-browser.mjs';
21
21
 
22
22
  const REDIRECT = `${location.origin}/`; // the app root doubles as the OIDC callback
@@ -64,30 +64,50 @@ async function bootWorker({ reset = false } = {}) {
64
64
  //
65
65
  // The unwrap happens HERE, in the page, and not in the worker: the worker boots
66
66
  // itself whenever the browser restarts it, with nobody present to type anything.
67
- window.fedipodUnlock = async (password) => {
68
- if (!password) throw new Error('Enter your account password.');
67
+ //
68
+ // Both paths below read the account's config and key the same way: with the
69
+ // session, as the owner, through the transport rather than the bare session —
70
+ // a pod read like any other, with the retry ladder that exists because the pod
71
+ // host throttles bursts.
72
+ async function readAccountState() {
69
73
  const session = await getSession();
70
74
  if (!session) throw new Error('Sign in first.');
71
- // The config on the pod says where this account's state lives; the key sits
72
- // beside it. Both are read with the session, as the owner.
73
75
  const podFromWebId = podBaseOfWebId(session.webId); // a suffix-based host, or its own host
74
76
  const state = `${podFromWebId}${AP_ROOT}ap-state/`;
75
- // Through the transport rather than the bare session: this is a pod read
76
- // like any other, and going round it skipped the retry ladder that exists
77
- // because the pod host throttles bursts.
78
77
  const remote = new BrowserRemotePod(session, { webId: session.webId, role: 'signup', log: () => {} });
79
78
  const urls = { state };
80
79
  const [cfg, doc] = await Promise.all([
81
80
  podState.readConfig(remote, urls), podState.readWrappedKeys(remote, urls),
82
81
  ]);
83
- if (!cfg || !doc) throw new Error(`could not read this account's config and key under ${state}`);
82
+ if (!cfg) throw new Error(`could not read this account's config under ${state}`);
83
+ const actorUrl = `${cfg.remotePod}${cfg.root || AP_ROOT}ap/actor`;
84
+ return { remote, urls, cfg, doc, actorUrl };
85
+ }
86
+
87
+ window.fedipodUnlock = async (password) => {
88
+ if (!password) throw new Error('Enter your password.');
89
+ const { doc, actorUrl } = await readAccountState();
90
+ if (!doc) throw new Error('could not read this account\'s key on the pod');
84
91
  if (!isKeyEnvelope(doc)) throw new Error('this account\'s key is not locked — nothing to unlock');
85
92
  const rec = await unwrapKeys(doc, password); // throws 'wrong password'
86
- const actorUrl = `${cfg.remotePod}${cfg.root || AP_ROOT}ap/actor`;
87
93
  await cacheOpenedKeys(actorUrl, rec);
88
94
  await bootWorker();
89
95
  };
90
96
 
97
+ // The same pane, for someone who no longer has the sign-up password: a new key,
98
+ // wrapped under the password they use now, written over the pod's copy. Nothing
99
+ // is unwrapped, so the old password is never needed. The boot that follows
100
+ // publishes the new public key (agent.goActive → publishProfile).
101
+ window.fedipodNewKey = async (password) => {
102
+ if (!password) throw new Error('Enter the password you use for your pod now.');
103
+ const { remote, urls, cfg, actorUrl } = await readAccountState();
104
+ const keys = await generateKeys();
105
+ keys.mintedFor = cfg.gateway?.frontActor || actorUrl; // one key, one actor (signup.mjs)
106
+ await podState.writeWrappedKeys(remote, urls, await wrapKeys(keys, password));
107
+ await cacheOpenedKeys(actorUrl, keys);
108
+ await bootWorker();
109
+ };
110
+
91
111
  // New account: create the account, pod, key, config and gateway attach (this
92
112
  // needs the password once), then redirect to the pod's login to establish the
93
113
  // durable session. The agent boots on return, reading config + key from the pod.
@@ -213,6 +233,25 @@ if (typeof document !== 'undefined') (async () => {
213
233
  };
214
234
  $('unlock-go')?.addEventListener('click', doUnlock);
215
235
  $('unlock-password')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') doUnlock(); });
236
+ // The new-key path: one click reveals the confirmation, the second acts.
237
+ $('unlock-newkey')?.addEventListener('click', () => {
238
+ $('unlock-newkey-confirm').hidden = false;
239
+ $('unlock-password').focus();
240
+ });
241
+ const doNewKey = async () => {
242
+ $('unlock-error').textContent = '';
243
+ const btn = $('unlock-newkey-go'); btn.disabled = true;
244
+ try {
245
+ await window.fedipodNewKey($('unlock-password').value);
246
+ $('unlock-password').value = '';
247
+ location.href = '/admin/client/';
248
+ } catch (err) {
249
+ $('unlock-error').textContent = err.message || String(err);
250
+ btn.disabled = false;
251
+ $('unlock-password').select();
252
+ }
253
+ };
254
+ $('unlock-newkey-go')?.addEventListener('click', doNewKey);
216
255
 
217
256
 
218
257
  // The client shell's bar returns here for two things, and neither continues