fedipod 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/device-agent.md +99 -0
- package/gateway.md +16 -0
- package/groups.md +1 -1
- package/gui.md +1 -1
- package/lib/client/c2s.mjs +95 -61
- package/lib/client/masto/index.mjs +7 -1
- package/lib/client/masto/timelines.mjs +1 -1
- package/lib/client/oidc-auth.mjs +5 -3
- 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 +20 -13
- package/lib/device/admin/routes/gateway.mjs +1 -1
- package/lib/device/admin/surface.mjs +25 -17
- package/lib/device/cli/commands/setup.mjs +9 -1
- package/lib/device/setup.mjs +6 -0
- package/lib/gateway/front-core.mjs +66 -12
- package/lib/gateway/gateway-core.mjs +40 -0
- package/lib/pod/actor.mjs +2 -2
- package/lib/pod/root.mjs +11 -0
- package/lib/pod/transport.mjs +9 -3
- package/lib/server/embed.mjs +23 -6
- package/package.json +2 -2
- package/run-agent.mjs +10 -1
- package/scripts/refresh-contexts.mjs +5 -0
- package/vendor/gate.cjs +5 -2
- 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 +11 -1
- package/web/app/boot.mjs +49 -10
- package/web/app/dist/boot.js +89 -37
- package/web/app/dist/boot.js.map +3 -3
- package/web/app/dist/sw.js +1326 -662
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/index.html +16 -6
- package/web/app/signup.mjs +3 -1
- 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
|
@@ -76,14 +76,14 @@ const ROUTES = [owner, setup, lifecycle, gateway, social, connections];
|
|
|
76
76
|
// a fediverse instance must let strangers reach /api and /oauth, so the gate
|
|
77
77
|
// guards the operator's door (basePath) instead of the whole surface.
|
|
78
78
|
export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
79
|
-
port = null, handle = null, embedded = false, basePath = '/',
|
|
79
|
+
port = null, handle = null, embedded = false, basePath = '/', mount = '',
|
|
80
80
|
publicOrigin = null, scheme = null,
|
|
81
81
|
versionOnDisk = () => localVersion(projectRoot) }) {
|
|
82
82
|
const json = (res, status, obj) => sendJson(res, status, obj, allowed);
|
|
83
|
-
const masto = new MastoApi({ agent, log, allowed, scheme, embedded });
|
|
83
|
+
const masto = new MastoApi({ agent, log, allowed, scheme, embedded, mount });
|
|
84
84
|
// The spec's own write API (§6), beside the facade. Its bearer fallback is
|
|
85
85
|
// the facade's token, so the two surfaces share one notion of the operator.
|
|
86
|
-
const c2s = new C2S({ agent, log, auth: makeC2sAuth({ agent, masto, log, scheme }) });
|
|
86
|
+
const c2s = new C2S({ agent, log, auth: makeC2sAuth({ agent, masto, log, scheme, mount }) });
|
|
87
87
|
const streaming = new Streaming({ masto, log, allowed, gate, gateOptional: embedded });
|
|
88
88
|
// Asked per request, not once here: startAdmin runs before connect, so the
|
|
89
89
|
// kind is not known yet at mount time.
|
|
@@ -104,21 +104,29 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
104
104
|
} catch (e) { log(`streaming broadcast: ${e.message}`); }
|
|
105
105
|
};
|
|
106
106
|
|
|
107
|
-
// A path as the browser must ask for it:
|
|
108
|
-
|
|
107
|
+
// A path as the browser must ask for it: under this identity's mount (a
|
|
108
|
+
// suffix pod's own path, or nothing) and behind the door, prefixed with it.
|
|
109
|
+
const atPath = (p_) => mount + (basePath === '/' ? p_ : basePath.slice(0, -1) + p_);
|
|
109
110
|
|
|
110
111
|
// What every route may reach: the agent and the deployment's facts.
|
|
111
112
|
const ctx = { agent, log, allowed, embedded, port, handle, publicOrigin, versionOnDisk, isGroup, json, setup: setup_ };
|
|
112
113
|
|
|
113
114
|
const handler = async (req, res) => {
|
|
114
115
|
const url = new URL(req.url, 'http://localhost');
|
|
116
|
+
// A suffix pod's surface answers under its mount (its own path on a shared
|
|
117
|
+
// host). Strip it once, here, so every route below is matched relative to
|
|
118
|
+
// the mount and a host-root/subdomain pod (empty mount) is unchanged. The
|
|
119
|
+
// full path stays on `url`/`req.url` for self-URLs that fold the mount back
|
|
120
|
+
// in themselves (the pagination base, the DPoP htu).
|
|
121
|
+
let p = url.pathname;
|
|
122
|
+
if (mount && (p === mount || p.startsWith(mount + '/'))) p = p.slice(mount.length) || '/';
|
|
115
123
|
// Mastodon-style: the bearer-gated client API and the OAuth + nodeinfo
|
|
116
124
|
// routes answer any origin — a browser client is served the way any
|
|
117
125
|
// instance serves it. CORS headers and the preflight make that work; the
|
|
118
126
|
// bearer stays the only credential, and the Host check below (which is
|
|
119
127
|
// what stops DNS rebinding) still runs.
|
|
120
|
-
const apiPath =
|
|
121
|
-
||
|
|
128
|
+
const apiPath = p.startsWith('/api/') || p.startsWith('/oauth/')
|
|
129
|
+
|| p === '/.well-known/nodeinfo' || p === '/nodeinfo/2.0';
|
|
122
130
|
if (apiPath) {
|
|
123
131
|
res.setHeader('access-control-allow-origin', '*');
|
|
124
132
|
res.setHeader('access-control-expose-headers', 'Link');
|
|
@@ -142,11 +150,11 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
142
150
|
res.end('forbidden\n');
|
|
143
151
|
return;
|
|
144
152
|
}
|
|
145
|
-
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
153
|
+
// Embedded, the operator's door is one path on the pod's origin (under the
|
|
154
|
+
// mount, when there is one). Behind it is everything that was the admin
|
|
155
|
+
// server; in front of it are the protocol routes, which have to answer
|
|
156
|
+
// strangers because that is what makes the pod an instance other software
|
|
157
|
+
// can talk to.
|
|
150
158
|
let atDoor = !embedded;
|
|
151
159
|
if (embedded && basePath !== '/'
|
|
152
160
|
&& (p === basePath.slice(0, -1) || p.startsWith(basePath))) {
|
|
@@ -171,7 +179,7 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
171
179
|
// above still decides who gets this far.
|
|
172
180
|
if (p === '/.well-known/oauth-authorization-server') {
|
|
173
181
|
const scheme = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
|
|
174
|
-
return json(res, 200, masto.authorizationServerMetadata(`${scheme}://${req.headers.host}`));
|
|
182
|
+
return json(res, 200, masto.authorizationServerMetadata(`${scheme}://${req.headers.host}${mount}`));
|
|
175
183
|
}
|
|
176
184
|
if (atDoor && gate(req, res)) return;
|
|
177
185
|
if (p === '/api/v1/streaming/health') {
|
|
@@ -180,7 +188,7 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
180
188
|
// NodeInfo on the agent origin — clients probe it at login.
|
|
181
189
|
if (p === '/.well-known/nodeinfo') {
|
|
182
190
|
return json(res, 200, nodeinfoPointer(
|
|
183
|
-
`${req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'}://${req.headers.host}/nodeinfo/2.0`));
|
|
191
|
+
`${req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'}://${req.headers.host}${mount}/nodeinfo/2.0`));
|
|
184
192
|
}
|
|
185
193
|
if (p === '/nodeinfo/2.0') {
|
|
186
194
|
return json(res, 200, nodeinfoDoc({
|
|
@@ -206,8 +214,8 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
206
214
|
// Our own pages come before the group check: a group is set up in the
|
|
207
215
|
// browser like anything else, and it has a record to edit. It still
|
|
208
216
|
// serves no fediverse client — see the 404 two lines down.
|
|
209
|
-
const
|
|
210
|
-
if (
|
|
217
|
+
const wmount = webMount(p);
|
|
218
|
+
if (wmount) {
|
|
211
219
|
// Without the slash a page's own relative <script src> resolves one
|
|
212
220
|
// level up and 404s — and that is true at any depth, so ask the
|
|
213
221
|
// filesystem rather than only special-casing the mount itself.
|
|
@@ -217,7 +225,7 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
217
225
|
res.end();
|
|
218
226
|
return;
|
|
219
227
|
}
|
|
220
|
-
return serveWeb(res, p,
|
|
228
|
+
return serveWeb(res, p, wmount, allowed);
|
|
221
229
|
}
|
|
222
230
|
// The bare URL means "show me what this agent wants from me now".
|
|
223
231
|
// Keyed on the credential FILE, never on configured(): a healthy
|
|
@@ -18,7 +18,7 @@ export async function setup() {
|
|
|
18
18
|
if (process.stdin.isTTY && !has('cli') && !IDENTITY_FLAGS.some(f => args.includes('--' + f))) {
|
|
19
19
|
return runBrowserSetup();
|
|
20
20
|
}
|
|
21
|
-
const root = flag('root');
|
|
21
|
+
const root = flag('root') || 'fedipod/'; // new installs default to the fedipod/ container
|
|
22
22
|
const kind = has('group') ? 'group' : 'person';
|
|
23
23
|
const approveJoins = has('group') && has('approve-joins');
|
|
24
24
|
const summary = flag('summary');
|
|
@@ -48,6 +48,14 @@ if (!newAccount && !pod) {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
if (!newAccount && !pod) { console.error('no pod given'); process.exit(2); }
|
|
51
|
+
if (!newAccount) {
|
|
52
|
+
const { resourceExists } = await import(new URL('../../../../lib/pod/root.mjs', import.meta.url));
|
|
53
|
+
const { apUrls, DEFAULT_ROOT: DR } = await import(new URL('../../../../lib/core/wire.mjs', import.meta.url));
|
|
54
|
+
if (await resourceExists(fetch, apUrls(pod, DR).actor)) {
|
|
55
|
+
console.error('The pod already hosts a FediPod account. If you want a second account, put it on a different pod.');
|
|
56
|
+
process.exit(2);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
51
59
|
|
|
52
60
|
const issuer = flag('issuer') || await ask('Solid identity provider', 'https://solidcommunity.net');
|
|
53
61
|
// Before the password is asked for, let alone sent. The issuer is where it
|
package/lib/device/setup.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { hashPassword } from '../client/masto/index.mjs';
|
|
|
19
19
|
import { webfingerHost, apUrls, DEFAULT_ROOT } from '../core/wire.mjs';
|
|
20
20
|
import { rootOf, recordLastUsed, writeJsonAtomic } from './home.mjs';
|
|
21
21
|
import { insecureUrlReason } from '../shared/safefetch.mjs';
|
|
22
|
+
import { resourceExists } from '../pod/root.mjs';
|
|
22
23
|
import { CURRENT_LAYOUT, isCurrent } from './migrate.mjs';
|
|
23
24
|
|
|
24
25
|
const SOLID = $rdf.Namespace('http://www.w3.org/ns/solid/terms#');
|
|
@@ -222,6 +223,8 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
222
223
|
gateway = null, shape = 'pod', gatewayOrigin = 'https://fedipod.net',
|
|
223
224
|
} = answers;
|
|
224
225
|
let { pod, root } = answers;
|
|
226
|
+
if (!root) root = 'fedipod/'; // new installs default to the fedipod/ container; a
|
|
227
|
+
// resuming run overwrites this with the credential's own root below.
|
|
225
228
|
let accountWebId = null; // what createAccountWithPod reported, when it ran
|
|
226
229
|
// The private half always starts here, beside the credential and the keys —
|
|
227
230
|
// not on the pod. Every activity you receive would otherwise cost the pod
|
|
@@ -263,6 +266,9 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
263
266
|
// reachable, and no silent 401 later on a pod whose profile is empty.
|
|
264
267
|
const usable = await checkPod(pod);
|
|
265
268
|
if (!usable.ok) throw new Error(usable.error);
|
|
269
|
+
if (await (deps.resourceExists || resourceExists)(deps.fetch || fetch, apUrls(pod, root || DEFAULT_ROOT).actor)) {
|
|
270
|
+
throw new Error('The pod already hosts a FediPod account. If you want a second account, put it on a different pod.');
|
|
271
|
+
}
|
|
266
272
|
skip('account', 'using the pod you already have');
|
|
267
273
|
}
|
|
268
274
|
|
|
@@ -16,10 +16,11 @@
|
|
|
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';
|
|
23
|
+
import { podBaseOfWebId } from '../pod/urls.mjs';
|
|
23
24
|
|
|
24
25
|
// The one WebFinger document, spelled out here rather than imported from
|
|
25
26
|
// wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
|
|
@@ -513,20 +514,31 @@ async function route(request, ctx) {
|
|
|
513
514
|
if (!/^https?:\/\/\S+\/$/.test(podBase)) {
|
|
514
515
|
return j(400, { error: 'podBase must be a URL ending in /' });
|
|
515
516
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
// of
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
517
|
+
let podUrl;
|
|
518
|
+
try { podUrl = new URL(podBase); } catch { return j(400, { error: 'podBase is not a URL' }); }
|
|
519
|
+
// A pod may be host-root (its own origin) or on a PATH of a shared host — a
|
|
520
|
+
// suffix pod, which the Server runs at `https://server/aisha/`. What is
|
|
521
|
+
// refused is the gateway's own origin root itself: that is where the
|
|
522
|
+
// gateway lives, not a pod, and admitting it would (with the ownership
|
|
523
|
+
// fallback below) let a co-tenant claim the whole origin.
|
|
524
|
+
if (ctx.frontOrigin) {
|
|
525
|
+
try {
|
|
526
|
+
if (podUrl.origin === new URL(ctx.frontOrigin).origin && podUrl.pathname === '/') {
|
|
527
|
+
return j(403, { error: 'that is this gateway, not a pod — name the pod that holds your data' });
|
|
528
|
+
}
|
|
529
|
+
} catch { /* no usable frontOrigin: the checks below still apply */ }
|
|
530
|
+
}
|
|
531
|
+
if (/(^|\/)\.internal(\/|$)/u.test(podUrl.pathname) || /(^|\/)\.\.(\/|$)/u.test(podUrl.pathname)) {
|
|
532
|
+
return j(403, { error: 'that is not a pod address' });
|
|
533
|
+
}
|
|
524
534
|
const webid = await verifyPodToken(request, pathname, ctx.verifier);
|
|
525
535
|
if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
|
|
526
|
-
// The pod's own server names its owner when it can
|
|
527
|
-
// the proof
|
|
536
|
+
// The pod's own server names its owner when it can, and where it does that
|
|
537
|
+
// is the proof. Where it does not, the WebID must live in EXACTLY this pod
|
|
538
|
+
// — its own pod base equal to podBase, not merely starting with it, which on
|
|
539
|
+
// a path server an ancestor of another's pod would.
|
|
528
540
|
const owners = await podOwners(podBase, ctx.fetchImpl || fetch);
|
|
529
|
-
const proven = owners.length ? owners.includes(webid) : webid
|
|
541
|
+
const proven = owners.length ? owners.includes(webid) : podBaseOfWebId(webid) === podBase;
|
|
530
542
|
if (!proven) {
|
|
531
543
|
return j(403, { error: 'the token proves a different pod than the one you listed' });
|
|
532
544
|
}
|
|
@@ -607,6 +619,48 @@ async function route(request, ctx) {
|
|
|
607
619
|
return { status, headers: {}, body: '' };
|
|
608
620
|
}
|
|
609
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
|
+
|
|
610
664
|
// Everything else is a public GET, served by reading the user's pod and
|
|
611
665
|
// rewriting pod ids to the front. The actor also gets its handle and inbox
|
|
612
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/root.mjs
CHANGED
|
@@ -66,6 +66,17 @@ export async function podLayout(fetchImpl, providerOrigin, { timeoutMs = OWNER_L
|
|
|
66
66
|
return /ns\/pim\/space#Storage|pim:Storage/u.test(body) ? 'path' : null;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// Whether a document is there — one unauthenticated GET, 200 or not. The caller
|
|
70
|
+
// names the URL; asked before a second setup, so a pod that already serves an
|
|
71
|
+
// actor at the app's container refuses another rather than growing a duplicate.
|
|
72
|
+
export async function resourceExists(fetchImpl, url, { timeoutMs = OWNER_LOOKUP_MS } = {}) {
|
|
73
|
+
try {
|
|
74
|
+
const res = await fetchImpl(url,
|
|
75
|
+
{ headers: { accept: 'application/activity+json' }, signal: AbortSignal.timeout(timeoutMs) });
|
|
76
|
+
return !!res && res.status === 200;
|
|
77
|
+
} catch { return false; }
|
|
78
|
+
}
|
|
79
|
+
|
|
69
80
|
export async function probeAnswers(podUrl, fetchImpl = fetch) {
|
|
70
81
|
try {
|
|
71
82
|
const res = await fetchImpl(podUrl, { method: 'HEAD' });
|
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/lib/server/embed.mjs
CHANGED
|
@@ -47,7 +47,8 @@ const mintSecret = () => crypto.randomBytes(32).toString('base64');
|
|
|
47
47
|
*/
|
|
48
48
|
export async function ensureDoorSecret(session, podBase, { rotate = false, dataDir = null, handle = null, log = () => {} } = {}) {
|
|
49
49
|
const base = podBase.endsWith('/') ? podBase : podBase + '/';
|
|
50
|
-
|
|
50
|
+
// Under the identity's own tree (fedipod/), where the gate reads it back.
|
|
51
|
+
const url = apUrls(base, 'fedipod/').state + 'door-secret.json';
|
|
51
52
|
const onHost = dataDir && handle ? path.join(dataDir, handle, 'door-secret.json') : null;
|
|
52
53
|
|
|
53
54
|
if (!rotate) {
|
|
@@ -136,7 +137,7 @@ function ensureCredential(home, { podBase, webId }) {
|
|
|
136
137
|
const rec = {
|
|
137
138
|
webId,
|
|
138
139
|
remotePod: podBase.endsWith('/') ? podBase : podBase + '/',
|
|
139
|
-
root: '
|
|
140
|
+
root: 'fedipod/',
|
|
140
141
|
keysMode: 'pod',
|
|
141
142
|
};
|
|
142
143
|
writeJsonAtomic(file, rec, { mode: 0o600 });
|
|
@@ -275,7 +276,7 @@ export async function startEmbeddedAgent({
|
|
|
275
276
|
pollSeconds = null,
|
|
276
277
|
autoAcceptFollows = true,
|
|
277
278
|
gateToken = null,
|
|
278
|
-
uiPath = '/
|
|
279
|
+
uiPath = '/fp/',
|
|
279
280
|
}) {
|
|
280
281
|
const base = podBase.endsWith('/') ? podBase : podBase + '/';
|
|
281
282
|
const handle = handleFor(base);
|
|
@@ -323,7 +324,7 @@ export async function startEmbeddedAgent({
|
|
|
323
324
|
throw new Error(`no pod at ${base} yet — its owner profile is not there`);
|
|
324
325
|
}
|
|
325
326
|
log(`no identity on ${base} yet — provisioning @${handle}`);
|
|
326
|
-
await agent.bootstrap({ handle, name: handle, kind: 'person' });
|
|
327
|
+
await agent.bootstrap({ handle, name: handle, kind: 'person', root: cred.root });
|
|
327
328
|
if (autoAcceptFollows) {
|
|
328
329
|
agent.store.setConfig({ ...agent.store.getConfig(), autoAcceptFollows: true });
|
|
329
330
|
await agent.store.flush();
|
|
@@ -342,15 +343,31 @@ export async function startEmbeddedAgent({
|
|
|
342
343
|
// speaks, the write API, nodeinfo, and behind the door the admin routes and
|
|
343
344
|
// the web client. Same code the standalone agent serves, minus the routes
|
|
344
345
|
// that only mean something to a process of one's own.
|
|
346
|
+
//
|
|
347
|
+
// A pod that lives on a PATH of its host (a suffix pod, e.g.
|
|
348
|
+
// https://server.example/aisha/) shares its origin with the front and with
|
|
349
|
+
// every other suffix pod, so its whole surface answers UNDER that path: the
|
|
350
|
+
// mount is the pod's own pathname, and it is stripped before a route is
|
|
351
|
+
// matched and folded back into every self-URL. A host-root or subdomain pod
|
|
352
|
+
// has an empty mount and everything is exactly as it was.
|
|
345
353
|
const authorities = new FixedAuthorities(base);
|
|
346
354
|
agent.authorities = authorities;
|
|
355
|
+
const mount = new URL(base).pathname.replace(/\/+$/u, '');
|
|
347
356
|
const surface = buildAdminSurface({
|
|
348
357
|
agent,
|
|
349
358
|
log,
|
|
350
|
-
|
|
359
|
+
// The door cookie is named the same for every identity; on a shared origin
|
|
360
|
+
// (suffix pods) it has to be scoped to this identity's own door path so two
|
|
361
|
+
// co-tenants do not overwrite each other's. A host-root/subdomain pod keeps
|
|
362
|
+
// the whole-origin cookie it always had.
|
|
363
|
+
gate: makeGate(gateToken, {
|
|
364
|
+
secureCookie: authorities.secure,
|
|
365
|
+
cookiePath: mount ? mount + uiPath : '/',
|
|
366
|
+
}),
|
|
351
367
|
allowed: authorities,
|
|
352
368
|
embedded: true,
|
|
353
369
|
basePath: uiPath,
|
|
370
|
+
mount,
|
|
354
371
|
publicOrigin: base,
|
|
355
372
|
scheme: new URL(base).protocol,
|
|
356
373
|
});
|
|
@@ -399,7 +416,7 @@ export async function startEmbeddedAgent({
|
|
|
399
416
|
// podHome and actorUrl are the identity's own locations on the pod. They are
|
|
400
417
|
// returned rather than rebuilt by the caller so the root name lives here.
|
|
401
418
|
return {
|
|
402
|
-
agent, handle, home, surface, host: authorities.host,
|
|
419
|
+
agent, handle, home, surface, host: authorities.host, mount,
|
|
403
420
|
podHome: urls.home, actorUrl: urls.actor, inboxUrl: urls.inbox, stop,
|
|
404
421
|
};
|
|
405
422
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Standalone single-actor ActivityPub agent whose wire face, RDF truth and state all live on a Solid pod (CSS). Bundles a Phanpy UI and a Mastodon client-API facade.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"cli.md",
|
|
28
28
|
"groups.md",
|
|
29
29
|
"architecture.svg",
|
|
30
|
-
"
|
|
30
|
+
"device-agent.md",
|
|
31
31
|
"architecture.md",
|
|
32
32
|
"gateway.md",
|
|
33
33
|
"browser.svg"
|
package/run-agent.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// run-agent.mjs — fedipod: a standalone single-actor ActivityPub
|
|
2
2
|
// agent. The remote pod is a RELAY: it serves the public wire face
|
|
3
|
-
// (/
|
|
3
|
+
// (/fedipod/ap/) and buffers inbound mail in a public-append inbox
|
|
4
4
|
// while this process is off, and it keeps one private document, the lease,
|
|
5
5
|
// because a lock only one machine can reach coordinates nothing.
|
|
6
6
|
//
|
|
@@ -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();
|
|
@@ -24,13 +24,18 @@ for (const m of module_.matchAll(/"([^"]+)":\s*(\w+),/gu)) {
|
|
|
24
24
|
if (idents[m[2]]) map[m[1]] = idents[m[2]];
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// Held beside fedify's set, not from it: fetched once by hand and kept.
|
|
28
|
+
const EXTRA = { 'http://www.w3.org/ns/anno.jsonld': 'anno.json' };
|
|
29
|
+
|
|
27
30
|
for (const file of Object.values(map)) fs.copyFileSync(path.join(src, file), path.join(dest, file));
|
|
31
|
+
Object.assign(map, EXTRA);
|
|
28
32
|
fs.writeFileSync(path.join(dest, 'map.json'), `${JSON.stringify(map, null, 2)}\n`);
|
|
29
33
|
|
|
30
34
|
const names = Object.fromEntries(Object.keys(map).map((u, i) => [u, `ctx${i}`]));
|
|
31
35
|
const header = fs.readFileSync(path.join(dest, 'index.mjs'), 'utf8').split('\nimport ')[0];
|
|
32
36
|
let out = `${header}\n`;
|
|
33
37
|
for (const [url, file] of Object.entries(map)) out += `import ${names[url]} from './${file}' with { type: 'json' };\n`;
|
|
38
|
+
// (EXTRA entries ride along in `map` above, so index.mjs and map.json keep them.)
|
|
34
39
|
out += '\n/** URL → the context document itself. Nothing outside this map is ever resolved. */\nexport const CONTEXTS = {\n';
|
|
35
40
|
for (const url of Object.keys(map)) out += ` '${url}': ${names[url]},\n`;
|
|
36
41
|
out += '};\n';
|
package/vendor/gate.cjs
CHANGED
|
@@ -85,7 +85,7 @@ function cookieValue(header, name) {
|
|
|
85
85
|
// AP_ALLOWED_HOSTS has nothing but this token, so the gate has to be total.
|
|
86
86
|
// `token` may be a function, resolved per request: an identity's secret can
|
|
87
87
|
// rotate while the server runs, and the very next request sees the new one.
|
|
88
|
-
function makeGate(token, { allowOrigins = [], publicEndpoints = false, secureCookie = false } = {}) {
|
|
88
|
+
function makeGate(token, { allowOrigins = [], publicEndpoints = false, secureCookie = false, cookiePath = '/' } = {}) {
|
|
89
89
|
const tokenNow = () => (typeof token === 'function' ? token() : token);
|
|
90
90
|
// gate(req, res) → true when the gate handled the response (caller stops).
|
|
91
91
|
function gate(req, res) {
|
|
@@ -102,7 +102,10 @@ function makeGate(token, { allowOrigins = [], publicEndpoints = false, secureCoo
|
|
|
102
102
|
url.searchParams.delete(COOKIE);
|
|
103
103
|
url.searchParams.delete('dk-bless');
|
|
104
104
|
res.writeHead(302, {
|
|
105
|
-
|
|
105
|
+
// Path scopes the cookie to this identity's own door: on a shared
|
|
106
|
+
// origin (suffix pods) two co-tenants must not clobber each other's,
|
|
107
|
+
// and a whole-origin cookie would. Defaults to '/'.
|
|
108
|
+
'set-cookie': `${COOKIE}=${t}; Path=${cookiePath}; HttpOnly; SameSite=Strict; Max-Age=31536000`
|
|
106
109
|
+ (secureCookie ? '; Secure' : ''),
|
|
107
110
|
'location': url.pathname + url.search,
|
|
108
111
|
});
|
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. |
|