fedipod 1.7.0 → 1.10.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 +14 -3
- package/gateway.md +16 -0
- package/groups.md +1 -0
- package/lib/client/c2s.mjs +112 -64
- package/lib/client/masto/index.mjs +9 -1
- package/lib/client/masto/render.mjs +5 -3
- package/lib/client/masto/statuses.mjs +7 -4
- package/lib/client/oidc-auth.mjs +10 -3
- package/lib/core/as2.mjs +1 -1
- 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/deliver.mjs +24 -1
- package/lib/core/intake/activities.mjs +54 -0
- package/lib/core/intake/index.mjs +34 -5
- package/lib/core/intake/notes.mjs +7 -0
- package/lib/core/publisher/collections.mjs +7 -4
- package/lib/core/publisher/index.mjs +49 -27
- package/lib/core/publisher/notes.mjs +154 -7
- package/lib/core/publisher/questions.mjs +11 -7
- package/lib/core/publisher/restore.mjs +27 -2
- package/lib/core/social.mjs +1 -0
- package/lib/core/store.mjs +13 -0
- package/lib/core/wire.mjs +71 -20
- package/lib/device/admin/static.mjs +2 -2
- package/lib/device/admin/surface.mjs +11 -2
- package/lib/gateway/front-core.mjs +76 -6
- package/lib/gateway/gateway-core.mjs +40 -0
- package/lib/gateway/httpsig.mjs +7 -3
- package/lib/pod/actor.mjs +2 -2
- package/lib/pod/transport.mjs +9 -3
- package/package.json +1 -1
- package/run-agent.mjs +11 -0
- package/scripts/refresh-contexts.mjs +5 -0
- package/scripts/stage-site.mjs +1 -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 +12 -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 +1604 -722
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/index.html +16 -6
- package/web/app/site/_redirects +1 -0
- 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 +1605 -723
- package/web/front/#new-account.html# +0 -43
- package/web/front/new-account.html~ +0 -50
|
@@ -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';
|
|
@@ -26,11 +26,17 @@ import { podBaseOfWebId } from '../pod/urls.mjs';
|
|
|
26
26
|
// wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
|
|
27
27
|
// friends), which a serverless front must never carry — it crashed the
|
|
28
28
|
// deployed function before it answered its first request.
|
|
29
|
-
const jrd = ({ handle, host, actor, aliases = [] }) => ({
|
|
29
|
+
const jrd = ({ handle, host, actor, aliases = [], page = null }) => ({
|
|
30
30
|
subject: `acct:${handle}@${host}`,
|
|
31
31
|
...(aliases.length ? { aliases } : {}),
|
|
32
|
-
links: [
|
|
32
|
+
links: [
|
|
33
|
+
{ rel: 'self', type: 'application/activity+json', href: actor },
|
|
34
|
+
...(page ? [{ rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: page }] : []),
|
|
35
|
+
],
|
|
33
36
|
});
|
|
37
|
+
// The public profile page of any account the front answers for lives on
|
|
38
|
+
// its pod, whether the address is the front's or the pod's own.
|
|
39
|
+
const profilePageOf = (rec) => (rec?.podHome ? rec.podHome + 'ap/profile.html' : null);
|
|
34
40
|
|
|
35
41
|
const AP_CT = 'application/activity+json';
|
|
36
42
|
|
|
@@ -41,10 +47,13 @@ async function verifyPodToken(request, pathname, verifier) {
|
|
|
41
47
|
const authz = request.headers.get('authorization');
|
|
42
48
|
if (!authz) return null;
|
|
43
49
|
try {
|
|
44
|
-
const v = verifier || (await import('@solid/access-token-verifier')).createSolidTokenVerifier();
|
|
45
50
|
const dpop = request.headers.get('dpop');
|
|
51
|
+
// Solid-OIDC binds the token to a key the client proves on every request;
|
|
52
|
+
// a token shown without the proof is one anyone who saw it could show.
|
|
53
|
+
if (!dpop) { console.log(`front: pod token without a DPoP proof refused on ${pathname}`); return null; }
|
|
54
|
+
const v = verifier || (await import('@solid/access-token-verifier')).createSolidTokenVerifier();
|
|
46
55
|
const url = request.url;
|
|
47
|
-
const { webid } = await v(authz,
|
|
56
|
+
const { webid } = await v(authz, { header: dpop, method: request.method, url });
|
|
48
57
|
return webid || null;
|
|
49
58
|
} catch (e) {
|
|
50
59
|
// Said aloud: a token the front will not take is otherwise a bare 401 to
|
|
@@ -597,8 +606,27 @@ async function route(request, ctx) {
|
|
|
597
606
|
// is the alias, so a client signing in by the fronted address can find
|
|
598
607
|
// the pod (and its login) without a lookup only the host could answer.
|
|
599
608
|
const podActor = rec.inboxOnly ? [] : [rec.podHome + 'ap/actor'];
|
|
600
|
-
|
|
609
|
+
const out = j(200, jrd({ handle: m[1], host: ctx.host, actor: rec.actorUrl, aliases: podActor, page: profilePageOf(rec) }),
|
|
601
610
|
'application/jrd+json');
|
|
611
|
+
// RFC 7033: a JRD is read from any origin.
|
|
612
|
+
out.headers['access-control-allow-origin'] = '*';
|
|
613
|
+
return out;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// The short address a person types: https://<host>/@handle opens the
|
|
617
|
+
// profile page, the way most Fediverse servers spell it. /@handle@pod-host
|
|
618
|
+
// is the same for an account whose address is its pod's, which the front
|
|
619
|
+
// holds under that full address — the form other servers use for a
|
|
620
|
+
// profile that lives elsewhere.
|
|
621
|
+
const at = /^\/@([^/@]+)(?:@([^/@]+))?$/u.exec(pathname);
|
|
622
|
+
if (at) {
|
|
623
|
+
const handle = decodeURIComponent(at[1]).toLowerCase();
|
|
624
|
+
const host = at[2] ? decodeURIComponent(at[2]).toLowerCase() : null;
|
|
625
|
+
const key = host && host !== String(ctx.host).toLowerCase() ? `${handle}@${host}` : handle;
|
|
626
|
+
const rec = await ctx.lookup(key);
|
|
627
|
+
const page = rec && profilePageOf(rec);
|
|
628
|
+
if (!page) return notFound();
|
|
629
|
+
return { status: 302, headers: { location: page, 'cache-control': 'no-store' }, body: '' };
|
|
602
630
|
}
|
|
603
631
|
|
|
604
632
|
const up = parseUserPath(pathname);
|
|
@@ -619,6 +647,48 @@ async function route(request, ctx) {
|
|
|
619
647
|
return { status, headers: {}, body: '' };
|
|
620
648
|
}
|
|
621
649
|
|
|
650
|
+
// Outbox: the owner's own post, from any client that speaks ActivityPub
|
|
651
|
+
// client-to-server (dokieli, say). Cross-origin by nature — such a client is
|
|
652
|
+
// a page on another site — so this route answers a preflight and carries
|
|
653
|
+
// CORS headers; the owner's pod token is the credential, so any origin may
|
|
654
|
+
// ask. GET stays a read: the pod's document for a fronted account, sent on
|
|
655
|
+
// to the pod for a door account whose documents live at their own address.
|
|
656
|
+
if (up.rest === 'ap/outbox') {
|
|
657
|
+
const cors = {
|
|
658
|
+
'access-control-allow-origin': '*',
|
|
659
|
+
'access-control-allow-methods': 'GET, POST, OPTIONS',
|
|
660
|
+
'access-control-allow-headers': 'Authorization, DPoP, Content-Type, Slug, Link',
|
|
661
|
+
'access-control-expose-headers': 'Location, Accept-Post',
|
|
662
|
+
'access-control-max-age': '86400',
|
|
663
|
+
};
|
|
664
|
+
if (request.method === 'OPTIONS') {
|
|
665
|
+
// Accept-Post names JSON only: a client that reads it (dokieli) sends
|
|
666
|
+
// JSON-LD when HTML is not offered.
|
|
667
|
+
// body null, not '': a Response refuses any body on a 204, and the
|
|
668
|
+
// function adapter hands `body` straight to one.
|
|
669
|
+
return { status: 204, headers: { ...cors, allow: 'GET, POST, OPTIONS',
|
|
670
|
+
'accept-post': 'application/ld+json, application/activity+json' }, body: null };
|
|
671
|
+
}
|
|
672
|
+
if (request.method === 'POST') {
|
|
673
|
+
const json = (status, obj, extra = {}) => ({ status,
|
|
674
|
+
headers: { ...cors, 'content-type': 'application/json', 'cache-control': 'no-store', ...extra },
|
|
675
|
+
body: JSON.stringify(obj) });
|
|
676
|
+
const webid = await verifyPodToken(request, pathname, ctx.verifier);
|
|
677
|
+
if (!webid) return json(401, { error: 'a Solid-OIDC token proving this account\'s owner is required' });
|
|
678
|
+
const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
|
|
679
|
+
if (!owner) return json(403, { error: 'this outbox belongs to its owner alone' });
|
|
680
|
+
const { status, reason, location } = await handleOwnerPost(request, identFor(rec),
|
|
681
|
+
{ podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), ownerWebId: webid });
|
|
682
|
+
console.log(`door @${up.handle}: owner post → ${status} (${reason})`);
|
|
683
|
+
if (status !== 202) return json(status, { error: reason });
|
|
684
|
+
return json(202, { accepted: true, ...(location ? { object: location } : {}),
|
|
685
|
+
note: 'it goes out when your FediPod agent next runs' }, location ? { location } : {});
|
|
686
|
+
}
|
|
687
|
+
if (rec.inboxOnly && (request.method === 'GET' || request.method === 'HEAD')) {
|
|
688
|
+
return { status: 303, headers: { ...cors, location: rec.podHome + 'ap/outbox', 'cache-control': 'no-store' }, body: '' };
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
622
692
|
// Everything else is a public GET, served by reading the user's pod and
|
|
623
693
|
// rewriting pod ids to the front. The actor also gets its handle and inbox
|
|
624
694
|
// 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/gateway/httpsig.mjs
CHANGED
|
@@ -58,10 +58,14 @@ export async function verifyHttpSignature(request, { documentLoader, keyCache, t
|
|
|
58
58
|
} catch {
|
|
59
59
|
result = null; // the library itself failed — no more informative than a bad signature
|
|
60
60
|
}
|
|
61
|
+
// Which signature the sender used: RFC 9421 carries a Signature-Input
|
|
62
|
+
// header beside Signature; the cavage draft carries Signature alone. The
|
|
63
|
+
// library picks the verifier by the same header.
|
|
64
|
+
const method = request.headers.has('signature-input') ? 'rfc9421' : 'draft-cavage';
|
|
61
65
|
if (result?.verified) {
|
|
62
66
|
const key = result.key;
|
|
63
67
|
return {
|
|
64
|
-
verified: true, method
|
|
68
|
+
verified: true, method,
|
|
65
69
|
keyId: key.id?.href ?? null, actor: key.ownerId?.href ?? null, reason: null,
|
|
66
70
|
checks: { signature: true, digest: true, dateSkew: true, keyFetched: true },
|
|
67
71
|
};
|
|
@@ -75,12 +79,12 @@ export async function verifyHttpSignature(request, { documentLoader, keyCache, t
|
|
|
75
79
|
}
|
|
76
80
|
if (kind === 'keyFetchError') {
|
|
77
81
|
return {
|
|
78
|
-
verified: false, method
|
|
82
|
+
verified: false, method, keyId: null, actor: null,
|
|
79
83
|
reason: 'key-unfetchable', checks: { signature: false, keyFetched: false },
|
|
80
84
|
};
|
|
81
85
|
}
|
|
82
86
|
return {
|
|
83
|
-
verified: false, method
|
|
87
|
+
verified: false, method, keyId: null, actor: null,
|
|
84
88
|
reason: 'bad-signature', checks: { signature: false },
|
|
85
89
|
};
|
|
86
90
|
}
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.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",
|
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
|
|
|
@@ -323,12 +325,14 @@ export class Agent {
|
|
|
323
325
|
actorId: this.urls.actor, edPrivate: keys.edPrivate,
|
|
324
326
|
proofKeyId: assertionKeyId(this.urls),
|
|
325
327
|
log: this.log, passive: this.viewer,
|
|
328
|
+
onGone: () => this.publisher.publishCollections({ followers: true }),
|
|
326
329
|
});
|
|
327
330
|
this.publisher = new Publisher({
|
|
328
331
|
config, remote: this.remote, store: this.store,
|
|
329
332
|
deliverer: this.deliverer, publicKeyPem: keys.rsaPublicPem,
|
|
330
333
|
assertionKey: keys.edPublicMultibase, log: this.log,
|
|
331
334
|
resolveMention: (h) => resolveHandle(this, h),
|
|
335
|
+
resolveActor: (u) => this.intake.fetchAP(u),
|
|
332
336
|
// Inside a pod server the client surface answers on the pod's own
|
|
333
337
|
// origin, so it can be advertised. Standalone it is on loopback, and
|
|
334
338
|
// naming it in a world-readable actor would send clients nowhere.
|
|
@@ -336,11 +340,15 @@ export class Agent {
|
|
|
336
340
|
});
|
|
337
341
|
// Intake is constructed even for viewers — its signed fetchAP powers
|
|
338
342
|
// search/deref; start() (draining) is active-only.
|
|
343
|
+
// The dispatcher the admin surface also builds; this one is for what the
|
|
344
|
+
// Gateway's outbox door took on the owner's behalf and the drain finds.
|
|
345
|
+
this.c2s = new C2S({ agent: this, log: this.log });
|
|
339
346
|
this.intake = new Intake({
|
|
340
347
|
config, urls: this.urls, remote: this.remote, store: this.store,
|
|
341
348
|
deliverer: this.deliverer, publisher: this.publisher, log: this.log, lease: this.lease,
|
|
342
349
|
archive: this.privateStorage(cred, 'archive'),
|
|
343
350
|
push: !this.embedded, pollSeconds: this.pollSeconds || null,
|
|
351
|
+
ownerPost: (a, o) => this.c2s.dispatch(a, o),
|
|
344
352
|
});
|
|
345
353
|
// The CSV-import worker: paced, resumable, armed only while active.
|
|
346
354
|
this.importer?.stop();
|
|
@@ -514,6 +522,9 @@ export class Agent {
|
|
|
514
522
|
this.viewer = false;
|
|
515
523
|
clearInterval(this.refreshTimer);
|
|
516
524
|
if (promoted) await this.refreshBeforeActing();
|
|
525
|
+
// Own posts the outbox names and the timeline index lacks come back here,
|
|
526
|
+
// before anything acts on the index.
|
|
527
|
+
await this.publisher.healStatuses().catch(e => this.log(`healing the timeline index: ${e.message}`));
|
|
517
528
|
this.lease.onLost = () => this.demote();
|
|
518
529
|
this.lease.startRenewal();
|
|
519
530
|
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/scripts/stage-site.mjs
CHANGED
|
@@ -94,6 +94,7 @@ fs.writeFileSync(path.join(site, '_redirects'), [
|
|
|
94
94
|
'/api/agent /.netlify/functions/front 200',
|
|
95
95
|
'/u/* /.netlify/functions/front 200',
|
|
96
96
|
'/.well-known/* /.netlify/functions/front 200',
|
|
97
|
+
'/@* /.netlify/functions/front 200',
|
|
97
98
|
'/install /.netlify/functions/front 200',
|
|
98
99
|
'/run /.netlify/functions/front 200',
|
|
99
100
|
'/roster /.netlify/functions/front 200',
|
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?.();
|
|
@@ -219,6 +223,7 @@ export class BrowserAgent {
|
|
|
219
223
|
// The relay finds the account by the front's own key for it, which for a
|
|
220
224
|
// mail-door account is the full address, not the bare handle.
|
|
221
225
|
handle: doorKeyOf(config.gateway?.url) || config.handle, sessionFetch: session.fetch,
|
|
226
|
+
onGone: () => this.publisher.publishCollections({ followers: true }),
|
|
222
227
|
});
|
|
223
228
|
|
|
224
229
|
this.publisher = new Publisher({
|
|
@@ -228,6 +233,7 @@ export class BrowserAgent {
|
|
|
228
233
|
// gives its publisher. Without it no mention from the browser ever
|
|
229
234
|
// resolved: a direct message went to nobody, a mention notified no one.
|
|
230
235
|
resolveMention: (h) => resolveHandle(this, h),
|
|
236
|
+
resolveActor: (u) => this.intake.fetchAP(u),
|
|
231
237
|
});
|
|
232
238
|
|
|
233
239
|
// The Bluesky connection, stamped to this actor. The same client the Node
|
|
@@ -257,9 +263,14 @@ export class BrowserAgent {
|
|
|
257
263
|
// read-only until the owner acts on it and it takes over. Written with fresh
|
|
258
264
|
// fetches, never the cached store. Passed into Intake so the drain checks it.
|
|
259
265
|
this.lease = new Lease({ url: this.urls.state + 'lease.json', fetchImpl: podFetch, log: this.log });
|
|
266
|
+
// The client-to-server dispatcher, here only for what the Gateway's
|
|
267
|
+
// outbox door takes on the owner's behalf: the browser answers no
|
|
268
|
+
// /ap/outbox of its own.
|
|
269
|
+
this.c2s = new C2S({ agent: this, log: this.log });
|
|
260
270
|
this.intake = new Intake({
|
|
261
271
|
config: this.store.getConfig(), urls: this.urls, remote: this.remote,
|
|
262
272
|
store: this.store, deliverer: this.deliverer, publisher: this.publisher, log: this.log, push: true, lease: this.lease,
|
|
273
|
+
ownerPost: (a, o) => this.c2s.dispatch(a, o),
|
|
263
274
|
});
|
|
264
275
|
// The Mastodon facade the service worker serves.
|
|
265
276
|
//
|
|
@@ -356,6 +367,7 @@ export class BrowserAgent {
|
|
|
356
367
|
podRequests: this.remote?.stats?.() || null,
|
|
357
368
|
update: null,
|
|
358
369
|
inboxCooldownFor: 0,
|
|
370
|
+
stateSkipped: this.store?.lastSkipped || [],
|
|
359
371
|
};
|
|
360
372
|
}
|
|
361
373
|
|
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,6 +33208,7 @@ 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, []);
|
|
@@ -33737,8 +33744,7 @@ async function bootWorker({ reset = false } = {}) {
|
|
|
33737
33744
|
worker.postMessage({ type: "boot", frontOrigin: location.origin });
|
|
33738
33745
|
await booted;
|
|
33739
33746
|
}
|
|
33740
|
-
|
|
33741
|
-
if (!password) throw new Error("Enter your account password.");
|
|
33747
|
+
async function readAccountState() {
|
|
33742
33748
|
const session = await getSession();
|
|
33743
33749
|
if (!session) throw new Error("Sign in first.");
|
|
33744
33750
|
const podFromWebId = podBaseOfWebId(session.webId);
|
|
@@ -33750,13 +33756,28 @@ window.fedipodUnlock = async (password) => {
|
|
|
33750
33756
|
readConfig(remote, urls),
|
|
33751
33757
|
readWrappedKeys(remote, urls)
|
|
33752
33758
|
]);
|
|
33753
|
-
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");
|
|
33754
33767
|
if (!isKeyEnvelope(doc)) throw new Error("this account's key is not locked \u2014 nothing to unlock");
|
|
33755
33768
|
const rec = await unwrapKeys(doc, password);
|
|
33756
|
-
const actorUrl = `${cfg.remotePod}${cfg.root || AP_ROOT}ap/actor`;
|
|
33757
33769
|
await cacheOpenedKeys(actorUrl, rec);
|
|
33758
33770
|
await bootWorker();
|
|
33759
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
|
+
};
|
|
33760
33781
|
window.fedipodSignup = async ({ onStep, ...answers }) => {
|
|
33761
33782
|
await signUp(answers, { onStep, frontOrigin: location.origin });
|
|
33762
33783
|
const { authorizationUrl } = await beginLogin({ issuer: answers.issuer, redirectUri: REDIRECT });
|
|
@@ -33861,6 +33882,25 @@ if (typeof document !== "undefined") (async () => {
|
|
|
33861
33882
|
$("unlock-password")?.addEventListener("keydown", (e) => {
|
|
33862
33883
|
if (e.key === "Enter") doUnlock();
|
|
33863
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);
|
|
33864
33904
|
if (params.has("signout") || params.has("add")) {
|
|
33865
33905
|
if (params.has("signout")) {
|
|
33866
33906
|
try {
|