fedipod-server 0.16.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/README.md +37 -15
- package/dist/claims.d.ts +10 -11
- package/dist/claims.js +19 -17
- package/dist/directory.d.ts +11 -3
- package/dist/directory.js +28 -14
- package/dist/handler.d.ts +30 -7
- package/dist/handler.js +133 -42
- package/dist/handler.jsonld +14 -6
- package/dist/streaming-handler.js +7 -2
- 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 -1
- package/run-agent.mjs +10 -1
- 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
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-server",
|
|
3
|
-
"version": "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",
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"build:components": "componentsjs-generator -s src -c dist/components -r fps",
|
|
56
56
|
"test": "npm run build && node --test test/*.mjs",
|
|
57
57
|
"test:e2e": "node test/e2e/live-agent.mjs",
|
|
58
|
+
"test:e2e:suffix": "node test/e2e/live-suffix.mjs",
|
|
58
59
|
"prepublishOnly": "npm test",
|
|
59
60
|
"prepack": "node scripts/pack-tree.mjs copy",
|
|
60
61
|
"postpack": "node scripts/pack-tree.mjs clean"
|
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();
|
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. |
|
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?.();
|
|
@@ -189,7 +193,7 @@ export class BrowserAgent {
|
|
|
189
193
|
// builds its own urls from `config.root` (publisher.mjs), and a config
|
|
190
194
|
// without one falls to the Node default — so an account set up elsewhere
|
|
191
195
|
// and signed into here would keep its state under `fedipod/` while every
|
|
192
|
-
// document it published landed under
|
|
196
|
+
// document it published landed under a different root. One root, decided
|
|
193
197
|
// once, carried by the config everything downstream reads.
|
|
194
198
|
this.store.setConfig({ ...(this.store.getConfig() || {}), ...cfg, root });
|
|
195
199
|
config = this.store.getConfig();
|
|
@@ -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
|
package/web/app/dist/boot.js
CHANGED
|
@@ -33075,7 +33075,7 @@ var PodTransport = class {
|
|
|
33075
33075
|
* says all of it. The parsed graph must mention the WebID before anything is
|
|
33076
33076
|
* written back — an empty or foreign body must never become the new profile.
|
|
33077
33077
|
*/
|
|
33078
|
-
async linkAccountInProfile({ actorUrl, accountName, kind = "person" }) {
|
|
33078
|
+
async linkAccountInProfile({ actorUrl, accountName, kind = "person", outbox = null }) {
|
|
33079
33079
|
const docUrl = this.webId.split("#")[0];
|
|
33080
33080
|
const res = await this.fetch(docUrl, { headers: { accept: "text/turtle" } });
|
|
33081
33081
|
if (res.status >= 400) throw new Error(`[${this.label}] GET ${docUrl} \u2192 ${res.status}`);
|
|
@@ -33091,10 +33091,16 @@ var PodTransport = class {
|
|
|
33091
33091
|
[me, FOAF("account"), actor],
|
|
33092
33092
|
[actor, RDF3("type"), FOAF("OnlineAccount")],
|
|
33093
33093
|
[actor, RDF3("type"), kind === "group" ? AS("Group") : AS("Person")],
|
|
33094
|
-
[actor, FOAF("accountName"), literal2(accountName)]
|
|
33094
|
+
[actor, FOAF("accountName"), literal2(accountName)],
|
|
33095
|
+
// Where a Solid client posts on this person's behalf (`as:outbox` on the
|
|
33096
|
+
// WebID is what dokieli reads); only where a door exists to take it.
|
|
33097
|
+
...outbox ? [[me, AS("outbox"), namedNode2(outbox)]] : []
|
|
33095
33098
|
];
|
|
33096
33099
|
const missing = wanted.filter(([s, p, o]) => !g.holds(s, p, o, doc));
|
|
33097
|
-
const stale =
|
|
33100
|
+
const stale = [
|
|
33101
|
+
...g.statementsMatching(actor, FOAF("accountName"), null, doc).filter((st2) => st2.object.value !== accountName),
|
|
33102
|
+
...outbox ? g.statementsMatching(me, AS("outbox"), null, doc).filter((st2) => st2.object.value !== outbox) : []
|
|
33103
|
+
];
|
|
33098
33104
|
if (!missing.length && !stale.length) return false;
|
|
33099
33105
|
const deletes = stale.map((st2) => [st2.subject, st2.predicate, st2.object]);
|
|
33100
33106
|
if (await this.patchDocument(docUrl, missing, deletes)) return true;
|
|
@@ -33202,12 +33208,54 @@ var BrowserRemotePod = class extends PodTransport {
|
|
|
33202
33208
|
// lib/pod/state.mjs
|
|
33203
33209
|
var readWrappedKeys = (pod, urls) => pod.getJson(urls.state + "keys.json");
|
|
33204
33210
|
var readConfig = (pod, urls) => pod.getJson(urls.state + "config.json");
|
|
33211
|
+
var writeWrappedKeys = (pod, urls, envelope) => pod.putJson(urls.state + "keys.json", envelope, "application/json");
|
|
33205
33212
|
var writeConfig = (pod, urls, config) => pod.putJson(urls.state + "config.json", config, "application/json");
|
|
33206
33213
|
async function provisionKey(pod, { stateUrl, keysUrl, envelope }) {
|
|
33207
33214
|
await pod.setAcl(stateUrl, []);
|
|
33208
33215
|
await pod.putJson(keysUrl, envelope, "application/json");
|
|
33209
33216
|
}
|
|
33210
33217
|
|
|
33218
|
+
// lib/pod/root.mjs
|
|
33219
|
+
var OWNER_LOOKUP_MS = 5e3;
|
|
33220
|
+
var PUBLIC_DOC_MAX_BYTES = 1024 * 1024;
|
|
33221
|
+
async function podLayout(fetchImpl, providerOrigin, { timeoutMs = OWNER_LOOKUP_MS } = {}) {
|
|
33222
|
+
let origin;
|
|
33223
|
+
try {
|
|
33224
|
+
origin = new URL(providerOrigin).origin;
|
|
33225
|
+
} catch {
|
|
33226
|
+
return null;
|
|
33227
|
+
}
|
|
33228
|
+
let res;
|
|
33229
|
+
try {
|
|
33230
|
+
res = await fetchImpl(
|
|
33231
|
+
`${origin}/.well-known/solid`,
|
|
33232
|
+
{ headers: { accept: "text/turtle" }, signal: AbortSignal.timeout(timeoutMs) }
|
|
33233
|
+
);
|
|
33234
|
+
} catch {
|
|
33235
|
+
return null;
|
|
33236
|
+
}
|
|
33237
|
+
if (res.status === 501) return "host";
|
|
33238
|
+
if (res.status !== 200) return null;
|
|
33239
|
+
let body = "";
|
|
33240
|
+
try {
|
|
33241
|
+
body = await readCapped(res, 64 * 1024);
|
|
33242
|
+
} catch {
|
|
33243
|
+
return null;
|
|
33244
|
+
}
|
|
33245
|
+
return /ns\/pim\/space#Storage|pim:Storage/u.test(body) ? "path" : null;
|
|
33246
|
+
}
|
|
33247
|
+
async function resourceExists(fetchImpl, url, { timeoutMs = OWNER_LOOKUP_MS } = {}) {
|
|
33248
|
+
try {
|
|
33249
|
+
const res = await fetchImpl(
|
|
33250
|
+
url,
|
|
33251
|
+
{ headers: { accept: "application/activity+json" }, signal: AbortSignal.timeout(timeoutMs) }
|
|
33252
|
+
);
|
|
33253
|
+
return !!res && res.status === 200;
|
|
33254
|
+
} catch {
|
|
33255
|
+
return false;
|
|
33256
|
+
}
|
|
33257
|
+
}
|
|
33258
|
+
|
|
33211
33259
|
// web/app/idb-kv.mjs
|
|
33212
33260
|
var DB = "fedipod-accounts";
|
|
33213
33261
|
function open() {
|
|
@@ -33310,6 +33358,7 @@ async function signUp(answers, { onStep = () => {
|
|
|
33310
33358
|
acct.running("checking your pod");
|
|
33311
33359
|
const head = await fetch(brought, { method: "HEAD" }).catch(() => null);
|
|
33312
33360
|
if (!head || head.status >= 400) throw new Error(`the pod at ${brought} did not answer (HTTP ${head?.status || "no response"})`);
|
|
33361
|
+
if (await resourceExists(fetch, actorUrlFor(brought))) throw new Error("The pod already hosts a FediPod account. If you want a second account, put it on a different pod.");
|
|
33313
33362
|
prog.pod = brought;
|
|
33314
33363
|
acct.skip("using the pod you brought");
|
|
33315
33364
|
}
|
|
@@ -33450,36 +33499,6 @@ function podBaseOfWebId(webId) {
|
|
|
33450
33499
|
return `${u.origin}${dir.endsWith("/") ? dir : dir + "/"}`;
|
|
33451
33500
|
}
|
|
33452
33501
|
|
|
33453
|
-
// lib/pod/root.mjs
|
|
33454
|
-
var OWNER_LOOKUP_MS = 5e3;
|
|
33455
|
-
var PUBLIC_DOC_MAX_BYTES = 1024 * 1024;
|
|
33456
|
-
async function podLayout(fetchImpl, providerOrigin, { timeoutMs = OWNER_LOOKUP_MS } = {}) {
|
|
33457
|
-
let origin;
|
|
33458
|
-
try {
|
|
33459
|
-
origin = new URL(providerOrigin).origin;
|
|
33460
|
-
} catch {
|
|
33461
|
-
return null;
|
|
33462
|
-
}
|
|
33463
|
-
let res;
|
|
33464
|
-
try {
|
|
33465
|
-
res = await fetchImpl(
|
|
33466
|
-
`${origin}/.well-known/solid`,
|
|
33467
|
-
{ headers: { accept: "text/turtle" }, signal: AbortSignal.timeout(timeoutMs) }
|
|
33468
|
-
);
|
|
33469
|
-
} catch {
|
|
33470
|
-
return null;
|
|
33471
|
-
}
|
|
33472
|
-
if (res.status === 501) return "host";
|
|
33473
|
-
if (res.status !== 200) return null;
|
|
33474
|
-
let body = "";
|
|
33475
|
-
try {
|
|
33476
|
-
body = await readCapped(res, 64 * 1024);
|
|
33477
|
-
} catch {
|
|
33478
|
-
return null;
|
|
33479
|
-
}
|
|
33480
|
-
return /ns\/pim\/space#Storage|pim:Storage/u.test(body) ? "path" : null;
|
|
33481
|
-
}
|
|
33482
|
-
|
|
33483
33502
|
// web/app/oidc-session.mjs
|
|
33484
33503
|
var DB2 = "fedipod-oidc";
|
|
33485
33504
|
var STORE = "session";
|
|
@@ -33725,8 +33744,7 @@ async function bootWorker({ reset = false } = {}) {
|
|
|
33725
33744
|
worker.postMessage({ type: "boot", frontOrigin: location.origin });
|
|
33726
33745
|
await booted;
|
|
33727
33746
|
}
|
|
33728
|
-
|
|
33729
|
-
if (!password) throw new Error("Enter your account password.");
|
|
33747
|
+
async function readAccountState() {
|
|
33730
33748
|
const session = await getSession();
|
|
33731
33749
|
if (!session) throw new Error("Sign in first.");
|
|
33732
33750
|
const podFromWebId = podBaseOfWebId(session.webId);
|
|
@@ -33738,13 +33756,28 @@ window.fedipodUnlock = async (password) => {
|
|
|
33738
33756
|
readConfig(remote, urls),
|
|
33739
33757
|
readWrappedKeys(remote, urls)
|
|
33740
33758
|
]);
|
|
33741
|
-
if (!cfg
|
|
33759
|
+
if (!cfg) throw new Error(`could not read this account's config under ${state}`);
|
|
33760
|
+
const actorUrl = `${cfg.remotePod}${cfg.root || AP_ROOT}ap/actor`;
|
|
33761
|
+
return { remote, urls, cfg, doc, actorUrl };
|
|
33762
|
+
}
|
|
33763
|
+
window.fedipodUnlock = async (password) => {
|
|
33764
|
+
if (!password) throw new Error("Enter your password.");
|
|
33765
|
+
const { doc, actorUrl } = await readAccountState();
|
|
33766
|
+
if (!doc) throw new Error("could not read this account's key on the pod");
|
|
33742
33767
|
if (!isKeyEnvelope(doc)) throw new Error("this account's key is not locked \u2014 nothing to unlock");
|
|
33743
33768
|
const rec = await unwrapKeys(doc, password);
|
|
33744
|
-
const actorUrl = `${cfg.remotePod}${cfg.root || AP_ROOT}ap/actor`;
|
|
33745
33769
|
await cacheOpenedKeys(actorUrl, rec);
|
|
33746
33770
|
await bootWorker();
|
|
33747
33771
|
};
|
|
33772
|
+
window.fedipodNewKey = async (password) => {
|
|
33773
|
+
if (!password) throw new Error("Enter the password you use for your pod now.");
|
|
33774
|
+
const { remote, urls, cfg, actorUrl } = await readAccountState();
|
|
33775
|
+
const keys = await generateKeys();
|
|
33776
|
+
keys.mintedFor = cfg.gateway?.frontActor || actorUrl;
|
|
33777
|
+
await writeWrappedKeys(remote, urls, await wrapKeys(keys, password));
|
|
33778
|
+
await cacheOpenedKeys(actorUrl, keys);
|
|
33779
|
+
await bootWorker();
|
|
33780
|
+
};
|
|
33748
33781
|
window.fedipodSignup = async ({ onStep, ...answers }) => {
|
|
33749
33782
|
await signUp(answers, { onStep, frontOrigin: location.origin });
|
|
33750
33783
|
const { authorizationUrl } = await beginLogin({ issuer: answers.issuer, redirectUri: REDIRECT });
|
|
@@ -33849,6 +33882,25 @@ if (typeof document !== "undefined") (async () => {
|
|
|
33849
33882
|
$("unlock-password")?.addEventListener("keydown", (e) => {
|
|
33850
33883
|
if (e.key === "Enter") doUnlock();
|
|
33851
33884
|
});
|
|
33885
|
+
$("unlock-newkey")?.addEventListener("click", () => {
|
|
33886
|
+
$("unlock-newkey-confirm").hidden = false;
|
|
33887
|
+
$("unlock-password").focus();
|
|
33888
|
+
});
|
|
33889
|
+
const doNewKey = async () => {
|
|
33890
|
+
$("unlock-error").textContent = "";
|
|
33891
|
+
const btn = $("unlock-newkey-go");
|
|
33892
|
+
btn.disabled = true;
|
|
33893
|
+
try {
|
|
33894
|
+
await window.fedipodNewKey($("unlock-password").value);
|
|
33895
|
+
$("unlock-password").value = "";
|
|
33896
|
+
location.href = "/admin/client/";
|
|
33897
|
+
} catch (err) {
|
|
33898
|
+
$("unlock-error").textContent = err.message || String(err);
|
|
33899
|
+
btn.disabled = false;
|
|
33900
|
+
$("unlock-password").select();
|
|
33901
|
+
}
|
|
33902
|
+
};
|
|
33903
|
+
$("unlock-newkey-go")?.addEventListener("click", doNewKey);
|
|
33852
33904
|
if (params.has("signout") || params.has("add")) {
|
|
33853
33905
|
if (params.has("signout")) {
|
|
33854
33906
|
try {
|