fedipod 1.48.0 → 1.49.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 +12 -71
- package/gateway.md +3 -3
- package/gui.md +2 -1
- package/lib/client/masto/accounts.mjs +18 -4
- package/lib/client/masto/index.mjs +1 -0
- package/lib/client/masto/oauth.mjs +62 -4
- package/lib/client/oidc-auth.mjs +20 -2
- package/lib/client/webpush.mjs +14 -4
- package/lib/core/place.mjs +10 -0
- package/lib/core/publisher/index.mjs +14 -2
- package/lib/core/store.mjs +6 -2
- package/lib/core/wire.mjs +3 -1
- package/lib/gateway/front-core.mjs +42 -45
- package/lib/gateway/gateway-core.mjs +6 -1
- package/lib/gateway/held-mail.mjs +23 -0
- package/lib/gateway/masto-gateway.mjs +54 -7
- package/lib/gateway/notices.mjs +1 -1
- package/lib/gateway/outbox-door.mjs +3 -3
- package/lib/gateway/private-read.mjs +7 -1
- package/lib/gateway/quiet.mjs +13 -3
- package/lib/gateway/state-api.mjs +7 -3
- package/lib/gateway/token-claims.mjs +66 -4
- package/lib/pod/actor.mjs +5 -0
- package/lib/pod/transport.mjs +28 -0
- package/lib/pod/type-index.mjs +23 -0
- package/lib/server/embed.mjs +2 -2
- package/lib/shared/safefetch.mjs +11 -0
- package/lib/surface/surface.mjs +2 -1
- package/package.json +2 -2
- package/web/admin/index.html +2 -1
- package/web/admin/oauth-signin.mjs +26 -3
- package/web/app/agent.mjs +29 -4
- package/web/app/dist/boot.js +27 -0
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +106 -10
- package/web/app/dist/sw.js.map +3 -3
- package/web/front/run.js +1 -1
|
@@ -18,16 +18,17 @@
|
|
|
18
18
|
import crypto from 'node:crypto';
|
|
19
19
|
import { handleDelivery } from './gateway-core.mjs';
|
|
20
20
|
import { ownerPostAtDoor } from './outbox-door.mjs';
|
|
21
|
-
import { readCapped, safeFetch, isLoopbackHost } from '../shared/safefetch.mjs';
|
|
21
|
+
import { readCapped, safeFetch, isLoopbackHost, namesPrivateAddress } from '../shared/safefetch.mjs';
|
|
22
22
|
import * as podRoot from '../pod/root.mjs';
|
|
23
23
|
import * as podPolicy from '../pod/policy.mjs';
|
|
24
24
|
import { podBaseOfWebId } from '../pod/urls.mjs';
|
|
25
25
|
import { routeQuietApi, noteOpened, noteReceived, closedState, accountState, closedAnswer, provedOwner } from './quiet.mjs';
|
|
26
|
-
import { holdsMail, mayBeAway, isPresent, holdingPut, afterHeld, routeHereApi, routeKeeperApi } from './held-mail.mjs';
|
|
26
|
+
import { holdsMail, mayBeAway, isPresent, holdingPut, afterHeld, routeHereApi, routeKeeperApi, routePushApi } from './held-mail.mjs';
|
|
27
27
|
import { routeNoticesApi } from './notices.mjs';
|
|
28
28
|
import { withSecurityHeaders } from './headers.mjs';
|
|
29
|
-
import {
|
|
30
|
-
|
|
29
|
+
import { claimedWebId, verifyPodToken } from './token-claims.mjs';
|
|
30
|
+
|
|
31
|
+
export { verifyPodToken };
|
|
31
32
|
import { answerPrivateRead } from './private-read.mjs';
|
|
32
33
|
import { announced, overLimit, relayOne } from './relay-extras.mjs';
|
|
33
34
|
|
|
@@ -35,16 +36,15 @@ import { announced, overLimit, relayOne } from './relay-extras.mjs';
|
|
|
35
36
|
// wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
|
|
36
37
|
// friends), which a serverless front must never carry — it crashed the
|
|
37
38
|
// deployed function before it answered its first request.
|
|
38
|
-
const jrd = ({ handle, host, actor, aliases = [], page = null, subscribe = null }) => ({
|
|
39
|
+
const jrd = ({ handle, host, actor, aliases = [], page = null, subscribe = null, avatar = null }) => ({
|
|
39
40
|
subject: `acct:${handle}@${host}`,
|
|
40
41
|
...(aliases.length ? { aliases } : {}),
|
|
41
42
|
links: [
|
|
42
43
|
{ rel: 'self', type: 'application/activity+json', href: actor },
|
|
43
44
|
...(page ? [{ rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: page }] : []),
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
// with whatever they are following — it must not be escaped.
|
|
45
|
+
...(avatar ? [{ rel: 'http://webfinger.net/rel/avatar', href: avatar }] : []), // the account's picture
|
|
46
|
+
// Remote follow: another server sends its reader to this template to follow
|
|
47
|
+
// from there. `{uri}` is a literal that server fills in; never escape it.
|
|
48
48
|
...(subscribe ? [{ rel: 'http://ostatus.org/schema/1.0/subscribe', template: subscribe }] : []),
|
|
49
49
|
],
|
|
50
50
|
});
|
|
@@ -57,29 +57,6 @@ const profilePageOf = (rec) => (rec?.podHome ? rec.podHome + 'ap/profile.html' :
|
|
|
57
57
|
|
|
58
58
|
const AP_CT = 'application/activity+json';
|
|
59
59
|
|
|
60
|
-
// Verify a Solid-OIDC token (DPoP-bound) and return its WebID, or null. The
|
|
61
|
-
// verifier is injected so tests stub it; in production it is the same library
|
|
62
|
-
// the agent's own C2S auth uses.
|
|
63
|
-
export async function verifyPodToken(request, pathname, verifier) {
|
|
64
|
-
const authz = request.headers.get('authorization');
|
|
65
|
-
if (!authz) return null;
|
|
66
|
-
try {
|
|
67
|
-
const dpop = request.headers.get('dpop');
|
|
68
|
-
// Solid-OIDC binds the token to a key the client proves on every request;
|
|
69
|
-
// a token shown without the proof is one anyone who saw it could show.
|
|
70
|
-
if (!dpop) { console.log(`front: pod token without a DPoP proof refused on ${pathname}`); return null; }
|
|
71
|
-
const v = verifier || await podTokenVerifier();
|
|
72
|
-
const url = request.url;
|
|
73
|
-
const { webid } = await v(authz, { header: dpop, method: request.method, url });
|
|
74
|
-
return webid || null;
|
|
75
|
-
} catch (e) {
|
|
76
|
-
// Said aloud: a token the front will not take is otherwise a bare 401 to
|
|
77
|
-
// the caller and nothing at all here.
|
|
78
|
-
console.log(`front: pod token refused on ${pathname}: ${e?.message || e}`);
|
|
79
|
-
return null;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
60
|
// Is this WebID served by the claimed pod? A pod owner's WebID lives on the pod
|
|
84
61
|
// origin — that is the whole proof: a token for a WebID under podHome could
|
|
85
62
|
// only be minted by someone who controls that pod's identity provider.
|
|
@@ -105,6 +82,13 @@ function webidUnderPod(webid, podHome) {
|
|
|
105
82
|
} catch { return false; }
|
|
106
83
|
}
|
|
107
84
|
|
|
85
|
+
// Whether a WebID is this row's owner: the one recorded, or for a row from
|
|
86
|
+
// before WebIDs were recorded, one living on the row's pod.
|
|
87
|
+
const ownsRow = (rec) => (webid) => (rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome));
|
|
88
|
+
|
|
89
|
+
// A front on this machine: a test run, whose pods and identity providers are here too.
|
|
90
|
+
const frontOnThisMachine = (ctx) => { try { return isLoopbackHost(new URL(ctx.frontOrigin).hostname); } catch { return false; } };
|
|
91
|
+
|
|
108
92
|
/**
|
|
109
93
|
* Whether this WebID may speak for this pod.
|
|
110
94
|
*
|
|
@@ -362,7 +346,7 @@ async function route(request, ctx) {
|
|
|
362
346
|
// Solid-OIDC token — and must be the WebID the deploy names as admin.
|
|
363
347
|
if (pathname === '/api/roster') {
|
|
364
348
|
if (!ctx.listDirectory || !ctx.adminWebId) return j(501, { error: 'this front has no roster to offer' });
|
|
365
|
-
const webid = await verifyPodToken(request, pathname, ctx.verifier);
|
|
349
|
+
const webid = await verifyPodToken(request, pathname, ctx.verifier, { only: (w) => w === ctx.adminWebId });
|
|
366
350
|
if (!webid) return j(401, { error: 'a Solid-OIDC token is required' });
|
|
367
351
|
if (webid !== ctx.adminWebId) return j(403, { error: 'that WebID is not the admin of this front' });
|
|
368
352
|
const rows = await ctx.listDirectory();
|
|
@@ -382,7 +366,7 @@ async function route(request, ctx) {
|
|
|
382
366
|
// for good; a row seeded in the deploy's environment can only be removed there.
|
|
383
367
|
if (pathname === '/api/revoke' && request.method === 'POST') {
|
|
384
368
|
if (!ctx.removeDirectory || !ctx.adminWebId) return j(501, { error: 'this front cannot revoke accounts' });
|
|
385
|
-
const webid = await verifyPodToken(request, pathname, ctx.verifier);
|
|
369
|
+
const webid = await verifyPodToken(request, pathname, ctx.verifier, { only: (w) => w === ctx.adminWebId });
|
|
386
370
|
if (!webid) return j(401, { error: 'a Solid-OIDC token is required' });
|
|
387
371
|
if (webid !== ctx.adminWebId) return j(403, { error: 'that WebID is not the admin of this front' });
|
|
388
372
|
let body;
|
|
@@ -471,7 +455,12 @@ async function route(request, ctx) {
|
|
|
471
455
|
}
|
|
472
456
|
const badPod = podHomeProblem(podHome, ctx.frontOrigin);
|
|
473
457
|
if (badPod) return j(400, { error: badPod });
|
|
474
|
-
|
|
458
|
+
// Anyone may sign up, so the pod and the token's addresses are a
|
|
459
|
+
// stranger's to name, and checking them fetches them. A front on the
|
|
460
|
+
// internet fetches only public ones; one on this machine, for testing, its own.
|
|
461
|
+
const publicOnly = !frontOnThisMachine(ctx);
|
|
462
|
+
if (publicOnly && await namesPrivateAddress(podHome)) return j(400, { error: 'that pod address is not on the public internet' });
|
|
463
|
+
const webid = await verifyPodToken(request, pathname, ctx.verifier, { publicOnly });
|
|
475
464
|
if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
|
|
476
465
|
if (!await provesPod(webid, podHome, ctx.fetchImpl)) {
|
|
477
466
|
return j(403, { error: 'the token proves a different pod than the one you listed' });
|
|
@@ -572,10 +561,9 @@ async function route(request, ctx) {
|
|
|
572
561
|
return withApiCors(j(400, { error: 'movedTo must be an https …/ap/actor URL' }));
|
|
573
562
|
}
|
|
574
563
|
if (ctx.frontOrigin && mu.origin === new URL(ctx.frontOrigin).origin) return withApiCors(j(400, { error: 'that address is at this gateway' }));
|
|
575
|
-
const webid = await verifyPodToken(request, pathname, ctx.verifier);
|
|
564
|
+
const webid = await verifyPodToken(request, pathname, ctx.verifier, { only: ownsRow(rec) });
|
|
576
565
|
if (!webid) return withApiCors(j(401, { error: 'a Solid-OIDC token proving the pod is required' }));
|
|
577
|
-
|
|
578
|
-
if (!owner) return withApiCors(j(403, { error: "the token proves a different pod than this account's" }));
|
|
566
|
+
if (!ownsRow(rec)(webid)) return withApiCors(j(403, { error: "the token proves a different pod than this account's" }));
|
|
579
567
|
const movedAt = rec.movedTo === movedTo && rec.movedAt ? rec.movedAt : new Date().toISOString();
|
|
580
568
|
await ctx.putDirectory(handle, { ...rec, movedTo, movedAt });
|
|
581
569
|
console.log(`front @${handle}: moved to ${movedTo}`);
|
|
@@ -585,9 +573,9 @@ async function route(request, ctx) {
|
|
|
585
573
|
// The owner's say over an account that goes quiet (quiet.mjs).
|
|
586
574
|
const quiet = await routeQuietApi(request, pathname, ctx, { j, verifyPodToken, webidUnderPod, apiPreflight });
|
|
587
575
|
if (quiet) return withApiCors(quiet);
|
|
588
|
-
// The owner's app saying it is open,
|
|
589
|
-
const
|
|
590
|
-
|
|
576
|
+
// The owner's app saying it is open, taking what was held, and naming what to push (held-mail.mjs).
|
|
577
|
+
const owned = { j, verifyPodToken, webidUnderPod, apiPreflight, provedOwner };
|
|
578
|
+
const here = await routeHereApi(request, pathname, ctx, owned) || await routeKeeperApi(request, pathname, ctx, owned) || await routePushApi(request, pathname, ctx, owned);
|
|
591
579
|
if (here) return withApiCors(here);
|
|
592
580
|
|
|
593
581
|
// The relay: the front sends requests a browser has already signed. A page
|
|
@@ -604,10 +592,9 @@ async function route(request, ctx) {
|
|
|
604
592
|
const handle = String(body.handle || '').toLowerCase();
|
|
605
593
|
const rec = await ctx.lookup(handle);
|
|
606
594
|
if (!rec) { console.log(`relay: no account named ${handle}`); return withApiCors(j(404, { error: 'no such account' })); }
|
|
607
|
-
const webid = await verifyPodToken(request, pathname, ctx.verifier);
|
|
595
|
+
const webid = await verifyPodToken(request, pathname, ctx.verifier, { only: ownsRow(rec) });
|
|
608
596
|
if (!webid) { console.log(`relay @${handle}: no usable pod token`); return withApiCors(j(401, { error: 'a Solid-OIDC token proving the pod is required' })); }
|
|
609
|
-
|
|
610
|
-
if (!owner) { console.log(`relay @${handle}: token is for ${webid}, not this account's pod`); return withApiCors(j(403, { error: "the token proves a different pod than this account's" })); }
|
|
597
|
+
if (!ownsRow(rec)(webid)) { console.log(`relay @${handle}: token is for ${webid}, not this account's pod`); return withApiCors(j(403, { error: "the token proves a different pod than this account's" })); }
|
|
611
598
|
const items = Array.isArray(body.requests) ? body.requests : [];
|
|
612
599
|
if (!items.length) return withApiCors(j(400, { error: 'requests must be a non-empty list' }));
|
|
613
600
|
// Acting through the relay is being here. Hourly at most; see noteOpened.
|
|
@@ -678,6 +665,16 @@ async function route(request, ctx) {
|
|
|
678
665
|
if (/(^|\/)\.internal(\/|$)/u.test(podUrl.pathname) || /(^|\/)\.\.(\/|$)/u.test(podUrl.pathname)) {
|
|
679
666
|
return j(403, { error: 'that is not a pod address' });
|
|
680
667
|
}
|
|
668
|
+
// A server that runs identities runs them for its own pods only, and says
|
|
669
|
+
// which those are. Asked before anything is fetched: the checks below read
|
|
670
|
+
// the addresses the request names, and a stranger names them.
|
|
671
|
+
if (ctx.agentControl.serves) {
|
|
672
|
+
if (!await ctx.agentControl.serves(podBase)) return j(403, { error: 'that pod is not on this server' });
|
|
673
|
+
const claimed = claimedWebId(request);
|
|
674
|
+
if (claimed && !await ctx.agentControl.serves(claimed)) {
|
|
675
|
+
return j(403, { error: 'the token is for a WebID on another server' });
|
|
676
|
+
}
|
|
677
|
+
}
|
|
681
678
|
// The pod's own server names its owner when it can, and where it does that
|
|
682
679
|
// is the proof. Where it does not, the WebID must live in EXACTLY this pod
|
|
683
680
|
// — its own pod base equal to podBase, not merely starting with it, which on
|
|
@@ -758,7 +755,7 @@ async function route(request, ctx) {
|
|
|
758
755
|
// the pod (and its login) without a lookup only the host could answer.
|
|
759
756
|
const podActor = rec.inboxOnly ? [] : [rec.podHome + 'ap/actor'];
|
|
760
757
|
const out = j(200, jrd({ handle: m[1], host: ctx.host, actor: rec.actorUrl, aliases: podActor,
|
|
761
|
-
page: profilePageOf(rec), subscribe: subscribeTemplate(ctx.frontOrigin) }),
|
|
758
|
+
page: profilePageOf(rec), subscribe: subscribeTemplate(ctx.frontOrigin), avatar: rec.icon || null }),
|
|
762
759
|
'application/jrd+json');
|
|
763
760
|
// RFC 7033: a JRD is read from any origin.
|
|
764
761
|
out.headers['access-control-allow-origin'] = '*';
|
|
@@ -112,7 +112,10 @@ export function asSigned(request, bytes, origin) {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
// `origin` is the door's own, the one senders address (see asSigned).
|
|
115
|
-
|
|
115
|
+
// `admit(verified)`, when given, says whether the inbox has room for this
|
|
116
|
+
// delivery now; one without room is answered 503 and written nowhere, so its
|
|
117
|
+
// sender tries again later. Without it every delivery that passes is written.
|
|
118
|
+
export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch, maxBytes = DEFAULT_MAX_BYTES, paused = false, origin = null, admit = null } = {}) {
|
|
116
119
|
// The body, once, as the bytes the sender digested; the text of it is what
|
|
117
120
|
// is filed and hashed, as before.
|
|
118
121
|
let bytes, raw;
|
|
@@ -157,6 +160,8 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
|
|
|
157
160
|
failed = `signature ${v.why} (${v.method}); host ${request.headers.get('host') || 'none'}${fwd ? ` forwarded-for ${fwd}` : ''}, date ${has('date')}, digest ${has('digest') === 'yes' || has('content-digest') === 'yes' ? 'yes' : 'no'}; ${params.slice(0, 300)}`;
|
|
158
161
|
}
|
|
159
162
|
|
|
163
|
+
if (admit && !admit(v.verified === true)) return { status: 503, reason: 'inbox full — try later' };
|
|
164
|
+
|
|
160
165
|
// No per-user secret on record = no receipt: the delivery still filters and
|
|
161
166
|
// forwards, it just reads as unverified at the drain — never a failure.
|
|
162
167
|
const receipt = ident.hmacSecret
|
|
@@ -213,3 +213,26 @@ export async function routeHereApi(request, pathname, ctx, deps) {
|
|
|
213
213
|
// `hold`: with it off, a browser working from a copy goes back to the pod.
|
|
214
214
|
return j(200, { ok: true, handle, flushed, hold: holdOn(ctx) });
|
|
215
215
|
}
|
|
216
|
+
|
|
217
|
+
// FediPod open in a browser reads its own mail, so nothing is held for the
|
|
218
|
+
// door to push: it names the notifications it made, and they are pushed to the
|
|
219
|
+
// phones and browsers its owner signed up (masto-gateway.mjs: pushMade).
|
|
220
|
+
// Proved like /api/here. Returns a response, or null for other paths.
|
|
221
|
+
const NOTIFICATION_ID = /^[0-9a-f]{16}$/u;
|
|
222
|
+
export async function routePushApi(request, pathname, ctx, deps) {
|
|
223
|
+
if (pathname !== '/api/push') return null;
|
|
224
|
+
const { j } = deps;
|
|
225
|
+
if (request.method === 'OPTIONS') return deps.apiPreflight();
|
|
226
|
+
if (request.method !== 'POST') return null;
|
|
227
|
+
let body;
|
|
228
|
+
try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
|
|
229
|
+
const handle = String(body.handle || '').toLowerCase();
|
|
230
|
+
const rec = await ctx.lookup(handle);
|
|
231
|
+
if (!rec) return j(404, { error: 'no such account' });
|
|
232
|
+
const who = await deps.provedOwner(request, pathname, ctx, rec, deps);
|
|
233
|
+
if (who.error) return who.error;
|
|
234
|
+
const ids = [...new Set((Array.isArray(body.ids) ? body.ids : []).filter((id) => NOTIFICATION_ID.test(String(id))))].slice(0, 50);
|
|
235
|
+
if (!ids.length || !ctx.startPush || !await ctx.pushWanted?.(handle).catch(() => false)) return j(200, { ok: true, handle, pushing: 0 });
|
|
236
|
+
await ctx.startPush(handle, { ids }).catch((e) => console.log(`push @${handle}: not started: ${e?.message || e}`));
|
|
237
|
+
return j(200, { ok: true, handle, pushing: ids.length });
|
|
238
|
+
}
|
|
@@ -29,7 +29,10 @@
|
|
|
29
29
|
// keeps for all its accounts, made the first time it is needed. When the door
|
|
30
30
|
// holds a delivery that becomes a notification (gateway-core: notifies), it
|
|
31
31
|
// starts a short run (push-background.mjs, pushHeld below) that reads the held
|
|
32
|
-
// mail into the copy and pushes each notification it makes.
|
|
32
|
+
// mail into the copy and pushes each notification it makes. While FediPod is
|
|
33
|
+
// open in a browser, it reads the mail itself and names the notifications it
|
|
34
|
+
// made (held-mail.mjs: /api/push); those are pushed from the account as it
|
|
35
|
+
// reads (pushMade below).
|
|
33
36
|
import crypto from 'node:crypto';
|
|
34
37
|
import { MastoApi } from '../client/masto/index.mjs';
|
|
35
38
|
import { instanceConfig } from '../client/masto/instance.mjs';
|
|
@@ -49,7 +52,10 @@ import { noteOpened } from './quiet.mjs';
|
|
|
49
52
|
import { Push } from '../client/webpush.mjs';
|
|
50
53
|
import webpush from 'web-push';
|
|
51
54
|
|
|
55
|
+
// An app stays signed in for ninety days from its last use; the mark of use
|
|
56
|
+
// moves at most once a day.
|
|
52
57
|
const TOKEN_TTL_MS = 90 * 86400_000;
|
|
58
|
+
const TOKEN_USE_MARK_MS = 86400_000;
|
|
53
59
|
const CODE_TTL_MS = 10 * 60_000;
|
|
54
60
|
const MAIL_EVERY_MS = 30_000;
|
|
55
61
|
const SIGNIN_PAGE = '/app-signin/';
|
|
@@ -194,8 +200,11 @@ export async function vapidKeys(kv) {
|
|
|
194
200
|
}
|
|
195
201
|
|
|
196
202
|
class GatewayPush extends Push {
|
|
197
|
-
constructor({ keys, ...o }) { super(o); this.keys = keys; }
|
|
203
|
+
constructor({ keys, keep = true, ...o }) { super(o); this.keys = keys; this.keep = keep; }
|
|
198
204
|
vapid() { return this.keys; }
|
|
205
|
+
// Pushing from an account only read: a sign-up the push service has dropped
|
|
206
|
+
// is let go by the next push that acts on the account.
|
|
207
|
+
save(s) { if (this.keep) super.save(s); }
|
|
199
208
|
}
|
|
200
209
|
|
|
201
210
|
// ---- the account behind a token ----
|
|
@@ -282,10 +291,10 @@ function readingAgent(at, store, acting, log) {
|
|
|
282
291
|
// contact address push services accept (https or mailto).
|
|
283
292
|
const pushContact = (ctx) => (/^https:\/\//u.test(ctx.frontOrigin || '') ? `${ctx.frontOrigin.replace(/\/$/u, '')}/` : `mailto:postmaster@${ctx.host || 'localhost'}`);
|
|
284
293
|
|
|
285
|
-
function facade(agent, token, log, keys = null, contact = null) {
|
|
294
|
+
function facade(agent, token, log, keys = null, contact = null, { keep = true } = {}) {
|
|
286
295
|
const api = new MastoApi({ agent, log, scheme: 'https', streaming: false, webPush: !!keys, scheduling: true, allowed: noAuthorities });
|
|
287
296
|
api.tokenOf = () => token;
|
|
288
|
-
if (keys) api._push = new GatewayPush({ store: agent.store, keys, log, subject: () => contact });
|
|
297
|
+
if (keys) api._push = new GatewayPush({ store: agent.store, keys, log, subject: () => contact, keep });
|
|
289
298
|
return api;
|
|
290
299
|
}
|
|
291
300
|
|
|
@@ -390,6 +399,40 @@ export async function pushHeld(ctx, handle, rec, { log = console.log } = {}) {
|
|
|
390
399
|
return n;
|
|
391
400
|
}
|
|
392
401
|
|
|
402
|
+
/**
|
|
403
|
+
* For FediPod open in a browser, which read mail that went straight to the pod
|
|
404
|
+
* and names the notifications it made: each is read from the account as it is
|
|
405
|
+
* now, without acting, and pushed once, while it is new. One that came from
|
|
406
|
+
* another network (Bluesky, a connected account) is not pushed, as it is not
|
|
407
|
+
* while FediPod is closed.
|
|
408
|
+
*/
|
|
409
|
+
const PUSH_FRESH_MS = 15 * 60_000;
|
|
410
|
+
const PUSHED_KEPT = 200;
|
|
411
|
+
export async function pushMade(ctx, handle, rec, ids, { log = console.log, now = Date.now() } = {}) {
|
|
412
|
+
if (!ids?.length || !await pushWanted(ctx, handle)) return 0;
|
|
413
|
+
const key = `pushed/${handle}`;
|
|
414
|
+
const done = (await getJson(ctx.mastoKv, key)) || [];
|
|
415
|
+
const want = new Set(ids.filter((id) => !done.includes(id)));
|
|
416
|
+
if (!want.size) return 0;
|
|
417
|
+
await waitOutClosing(ctx.copyKv, handle);
|
|
418
|
+
const at = await reachAccount(ctx, handle, rec, { log });
|
|
419
|
+
if (at.skipped) return 0;
|
|
420
|
+
const store = await readingStore(ctx, handle, at, log);
|
|
421
|
+
const agent = readingAgent(at, store, async () => { throw new Error('only reading'); }, log);
|
|
422
|
+
if (!agent) return 0;
|
|
423
|
+
// On the pod, a rewritten document leaves the listing as it was: what the
|
|
424
|
+
// push reads is asked for again, if it changed.
|
|
425
|
+
if (!at.inCopy) await store.refresh(['notifications.json', 'actors.json', 'statuses.json', 'webpush.json'], { ifChanged: true });
|
|
426
|
+
const fresh = store.getNotifications()
|
|
427
|
+
.filter((n) => want.has(n.id) && !n.bsky && !n.via && now - Date.parse(n.at) < PUSH_FRESH_MS);
|
|
428
|
+
if (!fresh.length) return 0;
|
|
429
|
+
const api = facade(agent, null, log, await vapidKeys(ctx.mastoKv), pushContact(ctx), { keep: false });
|
|
430
|
+
for (const n of fresh) await api.pushNotify(n).catch((e) => log(`push @${handle}: ${e.message}`));
|
|
431
|
+
await ctx.mastoKv.set(key, JSON.stringify([...fresh.map((n) => n.id), ...done].slice(0, PUSHED_KEPT)));
|
|
432
|
+
log(`@${handle}: ${fresh.length} made in FediPod pushed`);
|
|
433
|
+
return fresh.length;
|
|
434
|
+
}
|
|
435
|
+
|
|
393
436
|
// A request from an app, answered by the account's facade over its copy, or
|
|
394
437
|
// with the hold off, over the pod.
|
|
395
438
|
async function answerForAccount(request, url, ctx, tok, rec, log, tokenHash) {
|
|
@@ -562,12 +605,14 @@ async function route(request, pathname, ctx, deps, log) {
|
|
|
562
605
|
|
|
563
606
|
// The pod sign-in, proved: a code for the app.
|
|
564
607
|
if (pathname === '/api/authorize' && request.method === 'POST') {
|
|
565
|
-
const webid = await deps.verifyPodToken(request, pathname, ctx.verifier);
|
|
566
|
-
if (!webid) return json(401, { error: 'sign in at your pod first' });
|
|
567
608
|
const body = await request.clone().json().catch(() => ({}));
|
|
568
609
|
const found = await accountFor(ctx, body.address, host);
|
|
569
610
|
if (found.error) return json(found.error[0], { error: found.error[1] });
|
|
570
611
|
const { handle, rec } = found;
|
|
612
|
+
// A token for anyone but this account's owner is refused before it is
|
|
613
|
+
// checked: checking fetches the addresses it names.
|
|
614
|
+
const webid = await deps.verifyPodToken(request, pathname, ctx.verifier, { only: (w) => w === rec.webId });
|
|
615
|
+
if (!webid) return json(401, { error: 'sign in at your pod first' });
|
|
571
616
|
if (keptBefore(ctx, rec)) return json(503, { error: MOVING });
|
|
572
617
|
if (!usable(ctx, rec)) return json(409, { error: notReady(rec) });
|
|
573
618
|
if (rec.webId !== webid) return json(403, { error: `you signed in as ${webid}, and this account belongs to ${rec.webId}` });
|
|
@@ -635,13 +680,15 @@ async function route(request, pathname, ctx, deps, log) {
|
|
|
635
680
|
const bearer = /^Bearer (.+)$/u.exec(request.headers.get('authorization') || '')?.[1];
|
|
636
681
|
const tokenHash = bearer ? sha(bearer) : null;
|
|
637
682
|
const tok = tokenHash ? await getJson(kv, `token/${tokenHash}`) : null;
|
|
638
|
-
|
|
683
|
+
const lastUse = tok ? tok.usedAt || tok.at : 0;
|
|
684
|
+
if (!tok || Date.now() - lastUse > TOKEN_TTL_MS) {
|
|
639
685
|
// A page of this site asking with a token the gateway never gave out is
|
|
640
686
|
// FediPod's own client in a browser whose worker is not answering yet:
|
|
641
687
|
// told to try again, not that it is signed out.
|
|
642
688
|
if (request.headers.get('sec-fetch-site') === 'same-origin') return json(503, { error: 'FediPod is starting in this browser; try again' });
|
|
643
689
|
return json(401, { error: 'The access token is invalid' });
|
|
644
690
|
}
|
|
691
|
+
if (Date.now() - lastUse > TOKEN_USE_MARK_MS) await putJson(kv, `token/${tokenHash}`, { ...tok, usedAt: Date.now() }).catch(() => {});
|
|
645
692
|
if (pathname === '/api/v1/apps/verify_credentials') {
|
|
646
693
|
const app = await appOf(kv, tok.clientId);
|
|
647
694
|
return json(200, { name: app?.name || 'an app', website: app?.website || null, scopes: String(tok.scope).split(/\s+/u) });
|
package/lib/gateway/notices.mjs
CHANGED
|
@@ -49,7 +49,7 @@ export async function routeNoticesApi(request, pathname, ctx, { j, verifyPodToke
|
|
|
49
49
|
}
|
|
50
50
|
if (request.method !== 'POST') return { status: 405, headers: {}, body: '' };
|
|
51
51
|
if (!ctx.putNotice || !ctx.deleteNotice || !ctx.adminWebId) return j(501, { error: 'this front keeps no notices' });
|
|
52
|
-
const webid = await verifyPodToken(request, pathname, ctx.verifier);
|
|
52
|
+
const webid = await verifyPodToken(request, pathname, ctx.verifier, { only: (w) => w === ctx.adminWebId });
|
|
53
53
|
if (!webid) return j(401, { error: 'a Solid-OIDC token is required' });
|
|
54
54
|
if (webid !== ctx.adminWebId) return j(403, { error: 'that WebID is not the admin of this front' });
|
|
55
55
|
let body;
|
|
@@ -11,10 +11,10 @@ export async function ownerPostAtDoor(request, pathname, ctx, rec, handle, { cor
|
|
|
11
11
|
const json = (status, obj, extra = {}) => ({ status,
|
|
12
12
|
headers: { ...cors, 'content-type': 'application/json', 'cache-control': 'no-store', ...extra },
|
|
13
13
|
body: JSON.stringify(obj) });
|
|
14
|
-
const
|
|
14
|
+
const isOwner = (w) => (rec.webId ? w === rec.webId : webidUnderPod(w, rec.podHome));
|
|
15
|
+
const webid = await verifyPodToken(request, pathname, ctx.verifier, { only: isOwner });
|
|
15
16
|
if (!webid) return json(401, { error: 'a Solid-OIDC token proving this account\'s owner is required' });
|
|
16
|
-
|
|
17
|
-
if (!owner) return json(403, { error: 'this outbox belongs to its owner alone' });
|
|
17
|
+
if (!isOwner(webid)) return json(403, { error: 'this outbox belongs to its owner alone' });
|
|
18
18
|
const onPod = (u) => u.replace(rec.actorUrl.replace(/ap\/actor$/u, ''), rec.podHome);
|
|
19
19
|
const { status, reason, location, object } = await handleOwnerPost(request, identFor(rec),
|
|
20
20
|
{ podPut: (u, b, ct) => ctx.podPut(handle, u, b, ct), ownerWebId: webid,
|
|
@@ -17,6 +17,7 @@ import { verifyHttpSignature, makeSafeLoader } from './httpsig.mjs';
|
|
|
17
17
|
import { asSigned } from './gateway-core.mjs';
|
|
18
18
|
import { senderKeys } from './caches.mjs';
|
|
19
19
|
import { keptNow } from './copy.mjs';
|
|
20
|
+
import { sameOrigin } from '../core/intake/activity.mjs';
|
|
20
21
|
|
|
21
22
|
const AP_CT = 'application/activity+json';
|
|
22
23
|
const PUBLIC_NAMES = new Set(['https://www.w3.org/ns/activitystreams#Public', 'as:Public', 'Public']);
|
|
@@ -51,7 +52,12 @@ export async function signedReader(request, { fetchImpl = fetch, origin = null }
|
|
|
51
52
|
const v = await verifyHttpSignature(asSigned(request, new Uint8Array(0), origin), {
|
|
52
53
|
documentLoader: makeSafeLoader({ fetchImpl }), keyCache: senderKeys,
|
|
53
54
|
});
|
|
54
|
-
|
|
55
|
+
if (!v.verified || typeof v.actor !== 'string') return { actor: null, reason: v.reason };
|
|
56
|
+
// A key document names its owner, and anyone can publish one naming anyone.
|
|
57
|
+
// Only a key on the owner's own server speaks for them — the drain's rule too
|
|
58
|
+
// (receiptVouchesFor in lib/core/intake/verify.mjs).
|
|
59
|
+
if (!sameOrigin(v.keyId, v.actor)) return { actor: null, reason: 'key-not-on-signer-origin' };
|
|
60
|
+
return { actor: v.actor, reason: null };
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
// Refused reads are told nothing, and never held at the edge.
|
package/lib/gateway/quiet.mjs
CHANGED
|
@@ -63,6 +63,10 @@ export async function noteReceived(ctx, key, rec, bytes) {
|
|
|
63
63
|
.catch((e) => console.log(`front @${key}: count not written: ${e?.message || e}`));
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// The account's picture as its owner's browser names it, for the address's
|
|
67
|
+
// WebFinger answer (front-core.mjs): an http(s) address, or none.
|
|
68
|
+
const pictureOf = (v) => (typeof v === 'string' && v.length <= 2048 && /^https?:\/\/\S+$/u.test(v) ? v : null);
|
|
69
|
+
|
|
66
70
|
// The owner is here. `always` is a sign-in; the relay stamps at most hourly,
|
|
67
71
|
// so a busy hour of posting is one write. The count that went with the old
|
|
68
72
|
// stamp is dropped: it is over, and a store should not keep one per hour.
|
|
@@ -110,10 +114,10 @@ export const closedAnswer = (headers = {}, why = 'this address is closed') => ({
|
|
|
110
114
|
// WebID is the row's, or lives on the row's pod for a row from before WebIDs
|
|
111
115
|
// were recorded.
|
|
112
116
|
export async function provedOwner(request, pathname, ctx, rec, { j, verifyPodToken, webidUnderPod }) {
|
|
113
|
-
const
|
|
117
|
+
const isOwner = (w) => (rec.webId ? w === rec.webId : webidUnderPod(w, rec.podHome));
|
|
118
|
+
const webid = await verifyPodToken(request, pathname, ctx.verifier, { only: isOwner });
|
|
114
119
|
if (!webid) return { error: j(401, { error: 'a Solid-OIDC token proving the pod is required' }) };
|
|
115
|
-
|
|
116
|
-
if (!owner) return { error: j(403, { error: "the token proves a different pod than this account's" }) };
|
|
120
|
+
if (!isOwner(webid)) return { error: j(403, { error: "the token proves a different pod than this account's" }) };
|
|
117
121
|
return { webid };
|
|
118
122
|
}
|
|
119
123
|
|
|
@@ -142,6 +146,12 @@ export async function routeQuietApi(request, pathname, ctx, deps) {
|
|
|
142
146
|
if (who.error) return who.error;
|
|
143
147
|
const before = await closedState(ctx, handle, rec);
|
|
144
148
|
if (before.closed) return j(410, { error: 'this address is closed', closedAt: before.closedAt, closedBy: before.closedBy });
|
|
149
|
+
// The picture rides on the same write as the stamp. Anything that is not
|
|
150
|
+
// an address, or null, is not a picture, and changes nothing.
|
|
151
|
+
if ('icon' in body && (body.icon === null || pictureOf(body.icon)) && pictureOf(body.icon) !== (rec.icon || null)) {
|
|
152
|
+
const { icon: _was, ...rest } = rec;
|
|
153
|
+
rec = pictureOf(body.icon) ? { ...rest, icon: pictureOf(body.icon) } : rest;
|
|
154
|
+
}
|
|
145
155
|
rec = await noteOpened(ctx, handle, rec, { always: true });
|
|
146
156
|
// Whom the app would name if its owner lets the gateway act for them while
|
|
147
157
|
// it is closed, and whether they have (held-mail.mjs, keeper.mjs); and
|
|
@@ -162,8 +162,6 @@ export async function routeStateApi(request, pathname, ctx, deps) {
|
|
|
162
162
|
|
|
163
163
|
if (pathname === '/api/state/open') {
|
|
164
164
|
if (request.method !== 'POST') return j(405, { error: 'POST' });
|
|
165
|
-
const webid = await deps.verifyPodToken(request, pathname, ctx.verifier);
|
|
166
|
-
if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
|
|
167
165
|
let body = {};
|
|
168
166
|
try { body = JSON.parse((await request.clone().text()) || '{}'); } catch { return j(400, { error: 'bad JSON' }); }
|
|
169
167
|
// The account: named, or the one browser account kept here for this WebID.
|
|
@@ -171,8 +169,14 @@ export async function routeStateApi(request, pathname, ctx, deps) {
|
|
|
171
169
|
// Read fresh: FediPod asks this the moment it has turned keeping on, and a
|
|
172
170
|
// row read a minute earlier would still say the account is not kept.
|
|
173
171
|
let rec = handle ? await (ctx.lookupFresh || ctx.lookup)(handle) : null;
|
|
172
|
+
// A token for somebody with no account here is refused before it is
|
|
173
|
+
// checked: checking fetches the addresses it names.
|
|
174
|
+
const dir = handle ? {} : (await ctx.listDirectory?.() || {});
|
|
175
|
+
const only = handle ? (w) => !!rec && w === rec.webId : (w) => Object.values(dir).some((r) => r?.webId === w);
|
|
176
|
+
const webid = await deps.verifyPodToken(request, pathname, ctx.verifier, { only });
|
|
177
|
+
if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
|
|
174
178
|
if (!handle) {
|
|
175
|
-
const rows = Object.entries(
|
|
179
|
+
const rows = Object.entries(dir)
|
|
176
180
|
.filter(([, r]) => r?.webId === webid && r.openedAt && r.keeper && !r.movedTo && !r.closedAt);
|
|
177
181
|
if (rows.length > 1) return j(409, { error: 'more than one account here for this sign-in; name one', handles: rows.map(([h]) => h) });
|
|
178
182
|
if (rows.length === 1) [[handle, rec]] = rows;
|
|
@@ -5,12 +5,74 @@
|
|
|
5
5
|
// the claim decides nothing but the address. Verifying it here would let
|
|
6
6
|
// anyone make the front fetch signing keys from a server they name, on every
|
|
7
7
|
// read.
|
|
8
|
+
//
|
|
9
|
+
// And checking one: verifyPodToken, which the front's routes share.
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
import { podTokenVerifier } from './caches.mjs';
|
|
12
|
+
import { namesPrivateAddress, safeFetch, readCapped } from '../shared/safefetch.mjs';
|
|
13
|
+
|
|
14
|
+
function claimsOf(request) {
|
|
10
15
|
const token = /^(?:DPoP|Bearer)\s+([\w-]+)\.([\w-]+)\./u.exec(request.headers.get('authorization') || '');
|
|
11
16
|
if (!token) return null;
|
|
17
|
+
try { return JSON.parse(Buffer.from(token[2], 'base64url').toString('utf8')); } catch { return null; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function claimedWebId(request) {
|
|
21
|
+
const webid = claimsOf(request)?.webid;
|
|
22
|
+
return typeof webid === 'string' ? webid : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Whether checking this token would stay off private addresses: the WebID it
|
|
26
|
+
// names, its identity provider, and the key set that provider names. A token
|
|
27
|
+
// the check cannot read fetches nothing. A provider whose settings cannot be
|
|
28
|
+
// read here, by a fetch that refuses private addresses at every redirect, is
|
|
29
|
+
// refused: the check's own fetch follows redirects anywhere.
|
|
30
|
+
async function fetchesArePublic(request) {
|
|
31
|
+
const c = claimsOf(request);
|
|
32
|
+
if (!c) return true;
|
|
33
|
+
if (typeof c.webid === 'string' && await namesPrivateAddress(c.webid)) return false;
|
|
34
|
+
if (typeof c.iss !== 'string') return true;
|
|
35
|
+
if (await namesPrivateAddress(c.iss)) return false;
|
|
36
|
+
let jwks = null;
|
|
37
|
+
try {
|
|
38
|
+
const res = await safeFetch(`${c.iss.replace(/\/$/u, '')}/.well-known/openid-configuration`, { headers: { accept: 'application/json' } });
|
|
39
|
+
jwks = JSON.parse(await readCapped(res, 64 * 1024))?.jwks_uri;
|
|
40
|
+
} catch { return false; }
|
|
41
|
+
return !(typeof jwks === 'string' && await namesPrivateAddress(jwks));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Verify a Solid-OIDC token (DPoP-bound) and return its WebID, or null. The
|
|
45
|
+
// verifier is injected so tests stub it; in production it is the same library
|
|
46
|
+
// the agent's own C2S auth uses.
|
|
47
|
+
// Checking fetches the WebID and the identity provider the token names, and a
|
|
48
|
+
// stranger names them. `only`, when the caller knows whose token it wants,
|
|
49
|
+
// refuses a token naming anyone else before that; `publicOnly`, where any
|
|
50
|
+
// token may arrive, refuses one whose check would reach a private address.
|
|
51
|
+
export async function verifyPodToken(request, pathname, verifier, { only = null, publicOnly = false } = {}) {
|
|
52
|
+
const authz = request.headers.get('authorization');
|
|
53
|
+
if (!authz) return null;
|
|
54
|
+
const claimed = claimedWebId(request);
|
|
55
|
+
if (only && claimed && !only(claimed)) {
|
|
56
|
+
console.log(`front: pod token for somebody else refused unchecked on ${pathname}`);
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
if (publicOnly && !await fetchesArePublic(request)) {
|
|
60
|
+
console.log(`front: pod token naming a private address refused unchecked on ${pathname}`);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
12
63
|
try {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
64
|
+
const dpop = request.headers.get('dpop');
|
|
65
|
+
// Solid-OIDC binds the token to a key the client proves on every request;
|
|
66
|
+
// a token shown without the proof is one anyone who saw it could show.
|
|
67
|
+
if (!dpop) { console.log(`front: pod token without a DPoP proof refused on ${pathname}`); return null; }
|
|
68
|
+
const v = verifier || await podTokenVerifier();
|
|
69
|
+
const url = request.url;
|
|
70
|
+
const { webid } = await v(authz, { header: dpop, method: request.method, url });
|
|
71
|
+
return webid || null;
|
|
72
|
+
} catch (e) {
|
|
73
|
+
// Said aloud: a token the front will not take is otherwise a bare 401 to
|
|
74
|
+
// the caller and nothing at all here.
|
|
75
|
+
console.log(`front: pod token refused on ${pathname}: ${e?.message || e}`);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
16
78
|
}
|
package/lib/pod/actor.mjs
CHANGED
|
@@ -73,6 +73,11 @@ export function linkInWebIdProfile(pod, { actorUrl, accountName, kind = 'person'
|
|
|
73
73
|
return pod.linkAccountInProfile({ actorUrl, accountName, kind, outbox });
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/** The same link taken out again, for an account retired for good. */
|
|
77
|
+
export async function unlinkInWebIdProfile(pod, { actorUrl, outbox = null }) {
|
|
78
|
+
return pod.unlinkAccountInProfile({ actorUrl, outbox });
|
|
79
|
+
}
|
|
80
|
+
|
|
76
81
|
// ---- anyone at all ----
|
|
77
82
|
|
|
78
83
|
/**
|
package/lib/pod/transport.mjs
CHANGED
|
@@ -534,6 +534,34 @@ export class PodTransport {
|
|
|
534
534
|
return true;
|
|
535
535
|
}
|
|
536
536
|
|
|
537
|
+
/**
|
|
538
|
+
* The account taken out of the WebID profile again, as linkAccountInProfile
|
|
539
|
+
* wrote it: the foaf:account link, what the profile says of the actor, and
|
|
540
|
+
* `outbox` when it is the one named. From every document of the profile that
|
|
541
|
+
* holds any of it, each write checked. Returns how many documents changed.
|
|
542
|
+
*/
|
|
543
|
+
async unlinkAccountInProfile({ actorUrl, outbox = null }) {
|
|
544
|
+
const me = $rdf.sym(this.webId);
|
|
545
|
+
const actor = $rdf.sym(actorUrl);
|
|
546
|
+
let changed = 0;
|
|
547
|
+
for (const d of await this.profileDocs(podBaseOfWebId(this.webId))) {
|
|
548
|
+
if (!d.g) continue;
|
|
549
|
+
const gone = [
|
|
550
|
+
...d.g.statementsMatching(me, FOAF('account'), actor),
|
|
551
|
+
...d.g.statementsMatching(actor, RDF('type'), FOAF('OnlineAccount')),
|
|
552
|
+
...d.g.statementsMatching(actor, RDF('type'), AS('Person')),
|
|
553
|
+
...d.g.statementsMatching(actor, RDF('type'), AS('Group')),
|
|
554
|
+
...d.g.statementsMatching(actor, FOAF('accountName'), null),
|
|
555
|
+
...(outbox ? d.g.statementsMatching(me, AS('outbox'), $rdf.sym(outbox)) : []),
|
|
556
|
+
].map((st) => [st.subject, st.predicate, st.object]);
|
|
557
|
+
if (!gone.length) continue;
|
|
558
|
+
const status = await this.writeRdfChecked(d.url, d.g, { deletes: gone, mustDescribe: d.profile ? this.webId : null });
|
|
559
|
+
if (status >= 300) throw new Error(`not written: ${d.url} → ${status}`);
|
|
560
|
+
changed++;
|
|
561
|
+
}
|
|
562
|
+
return changed;
|
|
563
|
+
}
|
|
564
|
+
|
|
537
565
|
/**
|
|
538
566
|
* An N3 Patch of exactly these statements, or false when the pod will not
|
|
539
567
|
* take one and the caller should write the document instead.
|
package/lib/pod/type-index.mjs
CHANGED
|
@@ -65,6 +65,29 @@ export async function register(pod, indexUrl, actorUrl) {
|
|
|
65
65
|
return true;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Take the account's registration out again. A registration that names other
|
|
70
|
+
* instances too keeps them and loses only this one. True when one was there.
|
|
71
|
+
*/
|
|
72
|
+
export async function unregister(pod, indexUrl, actorUrl) {
|
|
73
|
+
const g = await pod.readRdf(indexUrl);
|
|
74
|
+
if (!g) return false;
|
|
75
|
+
const actor = pod.sym(actorUrl);
|
|
76
|
+
const instance = pod.sym(SOLID + 'instance');
|
|
77
|
+
const gone = [];
|
|
78
|
+
for (const reg of g.each(null, instance, actor)) {
|
|
79
|
+
if (!g.holds(reg, pod.sym(SOLID + 'forClass'), pod.sym(AS_ACTOR))) continue;
|
|
80
|
+
const others = g.each(reg, instance, null).filter((i) => i.value !== actorUrl);
|
|
81
|
+
for (const st of others.length ? g.statementsMatching(reg, instance, actor) : g.statementsMatching(reg, null, null)) {
|
|
82
|
+
gone.push([st.subject, st.predicate, st.object]);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (!gone.length) return false;
|
|
86
|
+
const status = await pod.writeRdfChecked(indexUrl, g, { deletes: gone, mustDescribe: indexUrl });
|
|
87
|
+
if (status >= 300) throw new Error(`the type index at ${indexUrl} did not let the registration go (${status})`);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
68
91
|
/**
|
|
69
92
|
* Make a public type index and name it from the profile. Only ever called
|
|
70
93
|
* after the person has said yes. An index already at the usual place is kept,
|