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.
- package/lib/client/c2s.mjs +95 -61
- package/lib/core/contexts/anno.json +126 -0
- package/lib/core/contexts/index.mjs +4 -0
- package/lib/core/contexts/map.json +2 -1
- package/lib/core/intake/index.mjs +32 -5
- package/lib/core/publisher/index.mjs +15 -1
- package/lib/core/publisher/notes.mjs +84 -2
- package/lib/core/publisher/questions.mjs +1 -0
- package/lib/core/publisher/restore.mjs +27 -2
- package/lib/core/social.mjs +1 -0
- package/lib/core/store.mjs +4 -0
- package/lib/core/wire.mjs +16 -12
- package/lib/gateway/front-core.mjs +43 -1
- package/lib/gateway/gateway-core.mjs +40 -0
- package/lib/pod/actor.mjs +2 -2
- package/lib/pod/transport.mjs +9 -3
- package/package.json +1 -1
- package/run-agent.mjs +9 -0
- package/web/admin/index.html +2 -0
- package/web/admin/upkeep.js +9 -1
- package/web/app/README.md +1 -1
- package/web/app/agent.mjs +10 -0
- package/web/app/boot.mjs +49 -10
- package/web/app/dist/boot.js +47 -7
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +1325 -661
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/index.html +16 -6
- package/web/app/site/admin/index.html +2 -0
- package/web/app/site/admin/upkeep.js +9 -1
- package/web/app/site/boot.js +89 -37
- package/web/app/site/index.html +16 -6
- package/web/app/site/sw.js +1326 -662
- package/web/front/#new-account.html# +0 -43
- package/web/front/new-account.html~ +0 -50
|
@@ -99,7 +99,28 @@ export function assertDirectAddressed(content, mentions) {
|
|
|
99
99
|
throw e;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 =
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/lib/core/social.mjs
CHANGED
|
@@ -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
|
|
package/lib/core/store.mjs
CHANGED
|
@@ -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
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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:
|
|
630
|
-
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 ----
|
package/lib/pod/transport.mjs
CHANGED
|
@@ -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
|
-
|
|
431
|
-
|
|
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
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();
|
package/web/admin/index.html
CHANGED
|
@@ -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
|
package/web/admin/upkeep.js
CHANGED
|
@@ -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
|
-
|
|
68
|
-
|
|
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
|
|
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
|