fedipod 1.36.6 → 1.40.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.
Files changed (94) hide show
  1. package/README.md +29 -8
  2. package/architecture.md +8 -0
  3. package/bin/fedipod.mjs +6 -0
  4. package/cli.md +7 -1
  5. package/device-agent.md +3 -3
  6. package/gateway.md +69 -8
  7. package/groups.md +2 -1
  8. package/gui.md +3 -1
  9. package/lib/client/c2s.mjs +97 -43
  10. package/lib/client/masto/accounts.mjs +1 -0
  11. package/lib/client/masto/bridge.mjs +61 -0
  12. package/lib/client/masto/index.mjs +4 -1
  13. package/lib/client/masto/oauth.mjs +1 -1
  14. package/lib/client/masto/statuses.mjs +3 -0
  15. package/lib/connections/acctfeed.mjs +13 -9
  16. package/lib/connections/bskyfeed.mjs +13 -9
  17. package/lib/connections/tagfeed.mjs +14 -9
  18. package/lib/core/deliver.mjs +17 -1
  19. package/lib/core/intake/activities.mjs +18 -2
  20. package/lib/core/intake/group.mjs +3 -1
  21. package/lib/core/intake/index.mjs +226 -48
  22. package/lib/core/intake/notes.mjs +12 -5
  23. package/lib/core/intake/verify.mjs +11 -0
  24. package/lib/core/lease.mjs +15 -1
  25. package/lib/core/place.mjs +82 -0
  26. package/lib/core/pod-only.mjs +4 -0
  27. package/lib/core/publisher/collections.mjs +37 -2
  28. package/lib/core/publisher/index.mjs +25 -1
  29. package/lib/core/publisher/notes.mjs +73 -7
  30. package/lib/core/publisher/own.mjs +143 -0
  31. package/lib/core/publisher/questions.mjs +5 -3
  32. package/lib/core/scheduled.mjs +39 -0
  33. package/lib/core/social.mjs +62 -32
  34. package/lib/core/storage.mjs +67 -0
  35. package/lib/core/store.mjs +77 -5
  36. package/lib/core/wire.mjs +51 -15
  37. package/lib/device/admin/routes/lifecycle.mjs +1 -1
  38. package/lib/device/admin/routes/setup.mjs +17 -1
  39. package/lib/device/cli/commands/setup.mjs +50 -8
  40. package/lib/device/cli/context.mjs +1 -1
  41. package/lib/device/migrate.mjs +1 -1
  42. package/lib/device/setup.mjs +45 -8
  43. package/lib/gateway/account-agent.mjs +111 -0
  44. package/lib/gateway/copy.mjs +381 -0
  45. package/lib/gateway/front-core.mjs +74 -63
  46. package/lib/gateway/gateway-core.mjs +107 -9
  47. package/lib/gateway/held-mail.mjs +198 -0
  48. package/lib/gateway/keeper-due.mjs +39 -0
  49. package/lib/gateway/keeper-session.mjs +10 -0
  50. package/lib/gateway/keeper.mjs +72 -0
  51. package/lib/gateway/masto-gateway.mjs +510 -0
  52. package/lib/gateway/quiet.mjs +7 -2
  53. package/lib/gateway/relay-extras.mjs +89 -0
  54. package/lib/gateway/state-api.mjs +207 -0
  55. package/lib/gateway/token-claims.mjs +16 -0
  56. package/lib/pod/containers.mjs +17 -0
  57. package/lib/pod/location.mjs +52 -0
  58. package/lib/pod/notes.mjs +2 -4
  59. package/lib/pod/transport.mjs +214 -24
  60. package/lib/pod/type-index.mjs +101 -0
  61. package/lib/pod/urls.mjs +6 -0
  62. package/lib/server/embed.mjs +7 -6
  63. package/lib/session/README.md +5 -5
  64. package/lib/session/demo.html +1 -1
  65. package/lib/session/fedi-account.mjs +19 -10
  66. package/lib/session/package.json +2 -2
  67. package/package.json +2 -2
  68. package/run-agent.mjs +6 -16
  69. package/scripts/stage-site.mjs +17 -4
  70. package/web/admin/actors.js +2 -0
  71. package/web/admin/gateway.js +14 -1
  72. package/web/admin/index.html +13 -0
  73. package/web/admin/oauth-signin.mjs +1 -1
  74. package/web/admin/record.js +4 -1
  75. package/web/admin/setup/index.html +17 -1
  76. package/web/admin/setup/setup.js +18 -5
  77. package/web/app/README.md +2 -2
  78. package/web/app/admin-facade.mjs +12 -2
  79. package/web/app/agent.mjs +228 -51
  80. package/web/app/boot.mjs +75 -32
  81. package/web/app/copy-mode.mjs +223 -0
  82. package/web/app/dist/boot.js +498 -88
  83. package/web/app/dist/boot.js.map +4 -4
  84. package/web/app/dist/sw.js +4016 -2539
  85. package/web/app/dist/sw.js.map +4 -4
  86. package/web/app/index.html +16 -0
  87. package/web/app/signup.mjs +65 -26
  88. package/web/app/sw-src.mjs +15 -53
  89. package/web/app/update.js +3 -2
  90. package/web/app/warm-start.mjs +115 -0
  91. package/web/app-signin/app-signin.mjs +78 -0
  92. package/web/app-signin/index.html +41 -0
  93. package/web/front/run.html +7 -1
  94. package/web/front/run.js +30 -4
@@ -0,0 +1,207 @@
1
+ // state-api.mjs — the owner's browser reaching its account's copy at the
2
+ // gateway (copy.mjs).
3
+ //
4
+ // POST /api/state/open the owner, proved with the pod sign-in:
5
+ // makes the copy if there is none yet, and
6
+ // answers where it is and a token for it
7
+ // GET /api/state/<handle>/ what the copy holds
8
+ // GET /api/state/<handle>/<name> one document (ETag, If-None-Match)
9
+ // PUT /api/state/<handle>/<name> one document, from the lease's holder
10
+ // DELETE … the same
11
+ // GET/PUT /api/state/<handle>/lease.json the copy's lease (If-Match)
12
+ // POST /api/state/<handle>/leave the copy written to the pod and given up,
13
+ // before the owner stops the gateway keeping
14
+ // the account running
15
+ //
16
+ // The token is the gateway's own, signed with a secret only it knows, and
17
+ // names the account and the WebID that asked; it lasts a day. The signing key
18
+ // and the passwords of accounts elsewhere are refused here: the copy does not
19
+ // hold them, and the browser reads them from the pod.
20
+ import crypto from 'node:crypto';
21
+ import { HttpStorage } from '../core/storage.mjs';
22
+ import { CopyStorage, copyMeta, fillCopy, dropCopy, forgetCopy, holds, lockCopy, kvDocFetch, podOnly, safeName, safeHandle, keptBefore } from './copy.mjs';
23
+
24
+ const TOKEN_MS = 24 * 3600_000;
25
+ const b64 = (s) => Buffer.from(s).toString('base64url');
26
+ const sign = (secret, body) => crypto.createHmac('sha256', secret).update(body).digest('base64url');
27
+
28
+ export function stateToken(secret, { handle, webId }, now = Date.now()) {
29
+ const body = b64(JSON.stringify({ h: handle, w: webId, e: now + TOKEN_MS }));
30
+ return { token: `${body}.${sign(secret, body)}`, expiresAt: now + TOKEN_MS };
31
+ }
32
+
33
+ export function readStateToken(secret, token, now = Date.now()) {
34
+ const [body, mac] = String(token || '').split('.');
35
+ if (!body || !mac) return null;
36
+ const want = Buffer.from(sign(secret, body));
37
+ const got = Buffer.from(mac);
38
+ if (want.length !== got.length || !crypto.timingSafeEqual(want, got)) return null;
39
+ try {
40
+ const t = JSON.parse(Buffer.from(body, 'base64url').toString());
41
+ return t.e > now ? { handle: t.h, webId: t.w } : null;
42
+ } catch { return null; }
43
+ }
44
+
45
+ // Where an account's state is on its pod.
46
+ export const stateUrlOf = (rec) => `${rec.podHome.replace(/\/?$/u, '/')}ap-state/`;
47
+
48
+ // Whether this gateway can keep a copy for this account at all.
49
+ export const keepsCopies = (ctx) => !!(ctx.copyKv && ctx.keeperWebId && ctx.keeperFetch && ctx.stateSecret);
50
+
51
+ const FILL_RETRY_MS = 5 * 60_000;
52
+
53
+ /**
54
+ * The copy for an account, made from the pod when there is none: one maker at
55
+ * a time, and after a failed attempt not again for five minutes, so an app
56
+ * checking in every minute does not read the pod every minute. `owner`: the
57
+ * owner's own browser asking, which is not made to wait. Returns { ok } or
58
+ * { ok: false, status, why }.
59
+ */
60
+ export async function ensureCopy(ctx, handle, rec, log = console.log, { owner = false } = {}) {
61
+ if (await copyMeta(ctx.copyKv, handle)) return { ok: true };
62
+ const failedAt = Number((await ctx.copyKv.get(`${handle}/fill-failed-at`))?.text || 0);
63
+ if (!owner && Date.now() - failedAt < FILL_RETRY_MS) {
64
+ return { ok: false, status: 503, why: 'this account could not be opened a moment ago; try again in a few minutes' };
65
+ }
66
+ const podFetch = await ctx.keeperFetch();
67
+ if (!podFetch) return { ok: false, status: 501, why: 'this gateway cannot reach pods for accounts' };
68
+ const unlock = await lockCopy(ctx.copyKv, handle);
69
+ if (!unlock) return { ok: false, status: 503, why: 'the gateway is busy with this account; try again' };
70
+ try {
71
+ if (await copyMeta(ctx.copyKv, handle)) return { ok: true };
72
+ // Marked failed before it starts and cleared when it is done, so an attempt
73
+ // cut off part way (a request's time running out) counts as failed too.
74
+ await ctx.copyKv.set(`${handle}/fill-failed-at`, String(Date.now())).catch(() => {});
75
+ const stateUrl = stateUrlOf(rec);
76
+ const made = await fillCopy(ctx.copyKv, handle, { pod: new HttpStorage(stateUrl, podFetch), podFetch, stateUrl, webId: rec.webId, log })
77
+ .catch((e) => ({ ok: false, why: e.message }));
78
+ if (made.ok) { await ctx.copyKv.delete(`${handle}/fill-failed-at`).catch(() => {}); return { ok: true }; }
79
+ return { ok: false, status: /active on another device/.test(made.why) ? 409 : 502, why: made.why };
80
+ } finally { await unlock(); }
81
+ }
82
+
83
+ /** Write the copy to the pod and give it up. Returns { ok } or { ok: false, why }. */
84
+ export async function leaveCopy(ctx, handle, rec, log = console.log) {
85
+ if (!await copyMeta(ctx.copyKv, handle)) return { ok: true };
86
+ const podFetch = await ctx.keeperFetch();
87
+ if (!podFetch) return { ok: false, why: 'this gateway cannot reach the pod now' };
88
+ const release = await lockCopy(ctx.copyKv, handle);
89
+ if (!release) return { ok: false, why: 'the gateway is busy with this account; try again' };
90
+ try {
91
+ const stateUrl = stateUrlOf(rec);
92
+ return await dropCopy(ctx.copyKv, handle, { pod: new HttpStorage(stateUrl, podFetch), podFetch, stateUrl, log });
93
+ } finally { await release(); }
94
+ }
95
+
96
+ const j = (status, obj) => ({ status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store' }, body: JSON.stringify(obj) });
97
+
98
+ export async function routeStateApi(request, pathname, ctx, deps) {
99
+ if (!pathname.startsWith('/api/state/')) return null;
100
+ if (!keepsCopies(ctx)) return j(501, { error: 'this gateway keeps no copies of accounts' });
101
+
102
+ if (pathname === '/api/state/open') {
103
+ if (request.method !== 'POST') return j(405, { error: 'POST' });
104
+ const webid = await deps.verifyPodToken(request, pathname, ctx.verifier);
105
+ if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
106
+ let body = {};
107
+ try { body = JSON.parse((await request.clone().text()) || '{}'); } catch { return j(400, { error: 'bad JSON' }); }
108
+ // The account: named, or the one browser account kept here for this WebID.
109
+ let handle = String(body.handle || '').toLowerCase();
110
+ let rec = handle ? await ctx.lookup(handle) : null;
111
+ if (!handle) {
112
+ const rows = Object.entries(await ctx.listDirectory?.() || {})
113
+ .filter(([, r]) => r?.webId === webid && r.openedAt && r.keeper && !r.movedTo && !r.closedAt);
114
+ if (rows.length > 1) return j(409, { error: 'more than one account here for this sign-in; name one', handles: rows.map(([h]) => h) });
115
+ if (rows.length === 1) [[handle, rec]] = rows;
116
+ }
117
+ if (!rec) return j(404, { error: 'no account kept here for this sign-in' });
118
+ if (rec.webId !== webid) return j(403, { error: "the token proves a different pod than this account's" });
119
+ if (rec.movedTo || rec.closedAt) return j(410, { error: 'this address is not here any more' });
120
+ if (!rec.keeper) return j(409, { error: 'the gateway does not keep this account running' });
121
+ // Kept under a former identity: a copy that is still here is opened so its
122
+ // owner can hand it over; none is made under the new identity until the
123
+ // owner's rules name it.
124
+ const before = keptBefore(ctx, rec);
125
+ if (before && !await copyMeta(ctx.copyKv, handle)) return j(409, { error: 'kept under the gateway\'s former identity', moving: true });
126
+ const made = before ? { ok: true } : await ensureCopy(ctx, handle, rec, console.log, { owner: true });
127
+ if (!made.ok) return j(made.status, { error: made.why, busy: made.status === 409 });
128
+ const origin = new URL(request.url).origin;
129
+ const { token, expiresAt } = stateToken(ctx.stateSecret, { handle, webId: webid });
130
+ return j(200, { ok: true, handle, base: `${origin}/api/state/${encodeURIComponent(handle)}/`, token, expiresAt,
131
+ podHome: rec.podHome, moving: before });
132
+ }
133
+
134
+ const m = /^\/api\/state\/([^/]+)\/(.*)$/u.exec(pathname);
135
+ if (!m) return j(404, { error: 'no such route' });
136
+ let handle; let name;
137
+ try { handle = decodeURIComponent(m[1]).toLowerCase(); name = decodeURIComponent(m[2] || ''); } catch { return j(400, { error: 'bad path' }); }
138
+ // Plain names only: a name is part of a storage key, and a path in it would
139
+ // reach outside the account (copy.mjs).
140
+ if (!safeHandle(handle) || (name && !['leave', 'forget', 'lease.json'].includes(name) && !safeName(name))) return j(400, { error: 'not a state document' });
141
+ const bearer = /^Bearer (.+)$/u.exec(request.headers.get('authorization') || '')?.[1];
142
+ const who = readStateToken(ctx.stateSecret, bearer);
143
+ if (!who || who.handle !== handle) return j(401, { error: 'a token for this account is required' });
144
+ // The token is the owner's who asked for it; an address given up and taken
145
+ // by somebody else is not theirs any more.
146
+ const owned = await ctx.lookup(handle);
147
+ if (!owned || owned.webId !== who.webId) return j(401, { error: 'this token is not for this account' });
148
+ if (!await copyMeta(ctx.copyKv, handle)) return j(404, { error: 'the gateway keeps no copy of this account now', gone: true });
149
+ const kv = ctx.copyKv;
150
+ const noStore = { 'cache-control': 'no-store' };
151
+
152
+ if (name === 'leave') {
153
+ if (request.method !== 'POST') return j(405, { error: 'POST' });
154
+ const left = await leaveCopy(ctx, handle, owned).catch((e) => ({ ok: false, why: e.message }));
155
+ return left.ok ? j(200, { ok: true }) : j(502, { error: `the copy could not be written to the pod: ${left.why}` });
156
+ }
157
+ // The owner's browser has written the copy to the pod itself (a copy kept
158
+ // under the gateway's former identity, which cannot write it back now).
159
+ // Only the lease's holder may say so, and only for such a copy.
160
+ if (name === 'forget') {
161
+ if (request.method !== 'POST') return j(405, { error: 'POST' });
162
+ if (!keptBefore(ctx, owned)) return j(409, { error: 'this copy is written back by the gateway; ask to leave instead' });
163
+ const holder = request.headers.get('x-fedipod-holder') || '';
164
+ if (!holder || !await holds(kv, handle, holder)) return j(409, { error: 'only the agent holding this account may say so' });
165
+ await forgetCopy(kv, handle, { log: console.log });
166
+ return j(200, { ok: true });
167
+ }
168
+ if (name === 'lease.json') {
169
+ if (request.method !== 'GET' && request.method !== 'PUT') return j(405, { error: 'GET or PUT' });
170
+ const res = await kvDocFetch(kv, `${handle}/lease`)(null, {
171
+ method: request.method,
172
+ headers: { 'if-match': request.headers.get('if-match') || undefined },
173
+ body: request.method === 'PUT' ? await request.text() : undefined,
174
+ });
175
+ return { status: res.status, headers: { ...noStore, ...(res.headers.get('etag') ? { etag: res.headers.get('etag') } : {}),
176
+ ...(res.status === 200 ? { 'content-type': 'application/json' } : {}) }, body: res.status === 200 ? await res.text() : null };
177
+ }
178
+ if (name && podOnly(name)) return j(403, { error: 'this document is kept on the pod only' });
179
+
180
+ const holder = request.headers.get('x-fedipod-holder') || null;
181
+ const copy = new CopyStorage(kv, handle, { holder });
182
+ const ifNone = request.headers.get('if-none-match') || null;
183
+ if (!name) {
184
+ if (request.method !== 'GET') return j(405, { error: 'GET' });
185
+ const l = await copy.list('', { etag: ifNone });
186
+ if (l.notModified) return { status: 304, headers: { ...noStore, etag: l.etag }, body: null };
187
+ return { status: 200, headers: { ...noStore, 'content-type': 'application/json', etag: l.etag }, body: JSON.stringify({ names: l.names }) };
188
+ }
189
+ if (request.method === 'GET') {
190
+ const r = await copy.read(name, { etag: ifNone });
191
+ if (r.notModified) return { status: 304, headers: { ...noStore, etag: r.etag }, body: null };
192
+ if (!r.ok) return j(404, { error: 'no such document' });
193
+ return { status: 200, headers: { ...noStore, 'content-type': 'application/json', etag: r.etag }, body: r.body };
194
+ }
195
+ if (request.method === 'PUT') {
196
+ if (!holder) return j(400, { error: 'x-fedipod-holder is required' });
197
+ const w = await copy.write(name, await request.text());
198
+ if (w.lost) return j(409, { error: w.why });
199
+ if (!w.ok) return j(503, { error: w.why });
200
+ return { status: 204, headers: { ...noStore, etag: w.etag }, body: null };
201
+ }
202
+ if (request.method === 'DELETE') {
203
+ if (!holder) return j(400, { error: 'x-fedipod-holder is required' });
204
+ return (await copy.remove(name)) ? { status: 204, headers: noStore, body: null } : j(409, { error: 'another agent holds this account now' });
205
+ }
206
+ return j(405, { error: 'GET, PUT or DELETE' });
207
+ }
@@ -0,0 +1,16 @@
1
+ // token-claims.mjs — who a Solid access token says it is for, unchecked.
2
+ //
3
+ // Only for choosing where to send a read that the pod checks anyway: the
4
+ // owner's full outbox lives on the pod under the owner's own rule, so reading
5
+ // the claim decides nothing but the address. Verifying it here would let
6
+ // anyone make the front fetch signing keys from a server they name, on every
7
+ // read.
8
+
9
+ export function claimedWebId(request) {
10
+ const token = /^(?:DPoP|Bearer)\s+([\w-]+)\.([\w-]+)\./u.exec(request.headers.get('authorization') || '');
11
+ if (!token) return null;
12
+ try {
13
+ const claims = JSON.parse(Buffer.from(token[2], 'base64url').toString('utf8'));
14
+ return typeof claims?.webid === 'string' ? claims.webid : null;
15
+ } catch { return null; }
16
+ }
@@ -63,6 +63,23 @@ export async function provisionPrivate(pod, urls) {
63
63
  return true;
64
64
  }
65
65
 
66
+ /**
67
+ * The account's standing rules, written again only where the pod's differ:
68
+ * its folder, its state and its private posts (the owner's alone), and the
69
+ * notes and media anyone may read. Whoever else a rule names beside the owner
70
+ * is the transport's to say (a keeper: lib/gateway/keeper.mjs), so this is how
71
+ * granting or withdrawing one reaches the rules already on the pod.
72
+ */
73
+ export async function restateRules(pod, urls) {
74
+ for (const url of [urls.home, urls.state, urls.home + 'ap/private/']) await pod.setAcl(url, [], { ifChanged: true });
75
+ for (const url of [urls.notes, urls.media]) await pod.setAcl(url, ['Read'], { ifChanged: true });
76
+ // The public documents that state their own rule — the actor, the
77
+ // collections and their pages — keep what they grant, with the names updated.
78
+ for (const child of await pod.listContainer(urls.home + 'ap/')) {
79
+ if (!child.url.endsWith('/')) await pod.restateAcl(child.url);
80
+ }
81
+ }
82
+
66
83
  /**
67
84
  * Check the private trees are actually private, and put back any that are not.
68
85
  *
@@ -0,0 +1,52 @@
1
+ // location.mjs — where in a pod an application's container goes.
2
+ //
3
+ // The container's own name is the caller's. The person says which container of
4
+ // their pod holds it, as a path on the pod's own origin: `/` for a subdomained
5
+ // pod, `/jeff/` for a suffixed one, or any container below either. Everything then lives under `<that><name>`, and the root kept is that
6
+ // place relative to the pod.
7
+
8
+ // The pod's own furniture: its profile, its settings, its well-known documents.
9
+ const RESERVED = new Set(['profile', 'settings', '.well-known']);
10
+ const SEGMENT = /^[A-Za-z0-9._-]+$/u;
11
+
12
+ /** The prefilled answer: the pod's own root, as a path. */
13
+ export function podRootPath(podBase) {
14
+ return new URL(podBase).pathname;
15
+ }
16
+
17
+ /**
18
+ * The container path someone typed, checked against their pod. Returns
19
+ * `{ root }` — the root relative to the pod, ending in `name` — or
20
+ * `{ problem }` saying what is wrong, in words for them.
21
+ */
22
+ export function rootFromContainer(podBase, typed, name) {
23
+ const base = new URL(podBase);
24
+ let path = String(typed ?? '').trim() || base.pathname;
25
+ if (!path.startsWith('/')) path = '/' + path;
26
+ if (!path.endsWith('/')) path += '/';
27
+ if (!path.startsWith(base.pathname)) {
28
+ return { problem: `that is not in your pod — your pod starts at ${base.pathname}` };
29
+ }
30
+ const inside = path.slice(base.pathname.length);
31
+ const segments = inside.split('/').filter(Boolean);
32
+ if (segments.some(s => s === '.' || s === '..')) return { problem: 'a container path cannot go up with ..' };
33
+ if (segments.some(s => !SEGMENT.test(s))) {
34
+ return { problem: 'container names are letters, digits, dots, dashes and underscores' };
35
+ }
36
+ if (segments.length && RESERVED.has(segments[0])) {
37
+ return { problem: `${base.pathname}${segments[0]}/ belongs to your pod itself — choose another container` };
38
+ }
39
+ if (segments.length > 8) return { problem: 'that is nested too deep — eight containers at most' };
40
+ return { root: (segments.length ? segments.join('/') + '/' : '') + name };
41
+ }
42
+
43
+ /** Where the account lives, in full: the pod plus its root. */
44
+ export const homeOf = (podBase, root) => podBase + root;
45
+
46
+ /** The root back from a pod actor's address: `<pod><root>ap/actor`. */
47
+ export function rootOfActor(podBase, actorUrl) {
48
+ const a = String(actorUrl || '');
49
+ if (!a.startsWith(podBase) || !a.endsWith('ap/actor')) return null;
50
+ const root = a.slice(podBase.length, -'ap/actor'.length);
51
+ return root.endsWith('/') ? root : null;
52
+ }
package/lib/pod/notes.mjs CHANGED
@@ -57,9 +57,6 @@ export async function writeTombstone(pod, noteId, doc) {
57
57
  await pod.setAcl(noteId, PUBLIC_READ);
58
58
  }
59
59
 
60
- /** Withdraw the Create. Returns whether the pod actually removed it. */
61
- export const dropCreate = (pod, createId) => pod.delete(createId).catch(() => false);
62
-
63
60
  /** The replies collection, once the note it belonged to is gone. */
64
61
  export const dropReplies = (pod, repliesId) => pod.delete(repliesId).catch(() => {});
65
62
 
@@ -73,7 +70,8 @@ export const dropReplies = (pod, repliesId) => pod.delete(repliesId).catch(() =>
73
70
  export async function list(pod, urls) {
74
71
  const children = await pod.listContainer(urls.notes);
75
72
  return children
76
- .filter((c) => !/(-create|-replies)$/.test(c.url) && !c.url.endsWith('.keep'))
73
+ .filter((c) => !/(-create|-replies|-delete|-update-[^/]+)$/.test(c.url) && !/\/(announce|undo)-\d+$/.test(c.url)
74
+ && !c.url.endsWith('.keep'))
77
75
  // A defensive copy: the transport hands back the same array on a 304, so a
78
76
  // caller that sorts in place would corrupt it for every later reader.
79
77
  .map((c) => ({ ...c }));
@@ -15,6 +15,7 @@
15
15
  import * as $rdf from 'rdflib';
16
16
  import { readCapped, retryAfterMs } from './http.mjs';
17
17
  import { linkTargets, REL } from './links.mjs';
18
+ import { podBaseOfWebId } from './urls.mjs';
18
19
 
19
20
  const LDP = $rdf.Namespace('http://www.w3.org/ns/ldp#');
20
21
  const DC = $rdf.Namespace('http://purl.org/dc/terms/');
@@ -23,6 +24,17 @@ const RDF = $rdf.Namespace('http://www.w3.org/1999/02/22-rdf-syntax-ns#');
23
24
  const ACL = $rdf.Namespace('http://www.w3.org/ns/auth/acl#');
24
25
  const FOAF = $rdf.Namespace('http://xmlns.com/foaf/0.1/');
25
26
  const AS = $rdf.Namespace('https://www.w3.org/ns/activitystreams#');
27
+ const RDFS = $rdf.Namespace('http://www.w3.org/2000/01/rdf-schema#');
28
+
29
+ // An inserted term must be something a reader can take for what it says it
30
+ // is: an absolute http(s) IRI, or a literal.
31
+ function termProblem(t) {
32
+ if (t?.termType === 'Literal') return null;
33
+ if (t?.termType !== 'NamedNode') return `${t?.value ?? t} is not an IRI or a literal`;
34
+ try {
35
+ return /^https?:$/.test(new URL(t.value).protocol) ? null : `${t.value} is not an http(s) IRI`;
36
+ } catch { return `${t.value} is not an absolute IRI`; }
37
+ }
26
38
 
27
39
  // A pod whose access rules are ACP policies, not WAC authorizations. This
28
40
  // library writes WAC; over an ACP resource that would be noise where the pod's
@@ -109,6 +121,14 @@ export class PodTransport {
109
121
  // request built from an advertised id lands on the pod — one choke point.
110
122
  // Unset → identity, byte-for-byte the unfronted behaviour.
111
123
  this.toPod = null;
124
+ // Whose rules these are. Every rule written names `aclOwner` as its owner
125
+ // (the transport's own WebID unless told otherwise), and gives each keeper
126
+ // the same access: a gateway the owner lets act for them while their app
127
+ // is closed (lib/gateway/keeper.mjs). A keeper writes a rule only when the
128
+ // pod's differs (`aclIfChanged`), so it restates nothing the owner wrote.
129
+ this.aclOwner = null;
130
+ this.keepers = [];
131
+ this.aclIfChanged = false;
112
132
  }
113
133
 
114
134
  /** Who is talking, and from where — the prefix on every log line and error. */
@@ -332,8 +352,10 @@ export class PodTransport {
332
352
  // moderators read a queue that names people, without it being public.
333
353
  readAgents.forEach((webId, i) =>
334
354
  authorize($rdf.sym(url + `#r${i}`), ACL('agent'), $rdf.sym(webId), ['Read']));
335
- authorize($rdf.sym(url + '#owner'), ACL('agent'), $rdf.sym(this.webId),
355
+ authorize($rdf.sym(url + '#owner'), ACL('agent'), $rdf.sym(this.aclOwner || this.webId),
336
356
  ['Read', 'Write', 'Control']);
357
+ (this.keepers || []).forEach((webId, i) =>
358
+ authorize($rdf.sym(url + `#keeper${i}`), ACL('agent'), $rdf.sym(webId), ['Read', 'Write', 'Control']));
337
359
  return $rdf.serialize(doc, g, url, 'text/turtle');
338
360
  }
339
361
 
@@ -349,10 +371,41 @@ export class PodTransport {
349
371
  // `ifChanged`: read the rule the pod holds and write only when it differs.
350
372
  // For a rule restated at every start — the inbox door — a read is the
351
373
  // whole cost, where a write took the pod's lock to say the same thing.
352
- if (opts.ifChanged && await this.aclSame(url, doc)) return { status: 304, unchanged: true };
374
+ if ((opts.ifChanged || this.aclIfChanged) && await this.aclSame(url, doc)) return { status: 304, unchanged: true };
353
375
  return this.put(url, doc, 'text/turtle');
354
376
  }
355
377
 
378
+ /**
379
+ * A rule already on the pod, stated again with this transport's owner and
380
+ * keepers, keeping what it grants the public (#public), agents who may only
381
+ * append (#gw…) and agents who may only read (#r…). A resource with no rule
382
+ * of its own inherits one and is left alone; so is a rule with anything this
383
+ * file did not write, which it cannot restate faithfully.
384
+ */
385
+ async restateAcl(targetUrl) {
386
+ const podTarget = this.toPod ? this.toPod(targetUrl) : targetUrl;
387
+ const url = await this.aclUrlFor(podTarget);
388
+ if (!await this.aclWritable(url)) return null;
389
+ const res = await this.fetch(url, { headers: { accept: 'text/turtle' } });
390
+ if (res.status !== 200) return null;
391
+ const g = $rdf.graph();
392
+ try { $rdf.parse(await res.text(), g, url, 'text/turtle'); } catch { return null; }
393
+ const modes = (auth) => g.each(auth, ACL('mode'), null).map((m) => m.value.slice(ACL('').value.length));
394
+ let publicModes = [];
395
+ const appendAgents = [];
396
+ const readAgents = [];
397
+ for (const auth of g.each(null, RDF('type'), ACL('Authorization'))) {
398
+ const frag = auth.value.includes('#') ? auth.value.slice(auth.value.lastIndexOf('#') + 1) : '';
399
+ if (frag === 'owner' || /^keeper\d+$/u.test(frag)) continue;
400
+ if (frag === 'public') { publicModes = modes(auth); continue; }
401
+ const agent = g.any(auth, ACL('agent'), null)?.value;
402
+ if (/^gw\d+$/u.test(frag) && agent) { appendAgents.push(agent); continue; }
403
+ if (/^r\d+$/u.test(frag) && agent) { readAgents.push(agent); continue; }
404
+ return null;
405
+ }
406
+ return this.setAcl(targetUrl, publicModes, { appendAgents, readAgents, ifChanged: true });
407
+ }
408
+
356
409
  // Whether the pod's rule at `aclUrl` states exactly what `doc` states.
357
410
  // Compared as graphs, not bytes: the pod serialises what it holds its own
358
411
  // way. Every rule this file writes names its subjects, so triple sets are
@@ -445,16 +498,11 @@ export class PodTransport {
445
498
  * written back — an empty or foreign body must never become the new profile.
446
499
  */
447
500
  async linkAccountInProfile({ actorUrl, accountName, kind = 'person', outbox = null }) {
448
- const docUrl = this.webId.split('#')[0];
449
- const res = await this.fetch(docUrl, { headers: { accept: 'text/turtle' } });
450
- if (res.status >= 400) throw new Error(`[${this.label}] GET ${docUrl} → ${res.status}`);
451
- const g = $rdf.graph();
452
- $rdf.parse(await res.text(), g, docUrl, 'text/turtle');
453
- const doc = $rdf.sym(docUrl);
454
501
  const me = $rdf.sym(this.webId);
455
- if (!g.statementsMatching(me, null, null, doc).length) {
456
- throw new Error(`profile at ${docUrl} does not mention ${this.webId} — not rewriting it`);
457
- }
502
+ const podBase = podBaseOfWebId(this.webId);
503
+ // The profile and what its seeAlso names: what either says is said.
504
+ const docs = await this.profileDocs(podBase);
505
+ const says = (s, p, o) => docs.some(({ g }) => g?.holds(s, p, o));
458
506
  const actor = $rdf.sym(actorUrl);
459
507
  const wanted = [
460
508
  [me, FOAF('account'), actor],
@@ -465,22 +513,24 @@ export class PodTransport {
465
513
  // WebID is what dokieli reads); only where a door exists to take it.
466
514
  ...(outbox ? [[me, AS('outbox'), $rdf.sym(outbox)]] : []),
467
515
  ];
468
- const missing = wanted.filter(([s, p, o]) => !g.holds(s, p, o, doc));
516
+ const missing = wanted.filter(([s, p, o]) => !says(s, p, o));
469
517
  // A handle change leaves the old accountName behind; ours is replaced.
470
518
  // Likewise an outbox that moved.
471
- const stale = [
472
- ...g.statementsMatching(actor, FOAF('accountName'), null, doc).filter(st => st.object.value !== accountName),
473
- ...(outbox ? g.statementsMatching(me, AS('outbox'), null, doc).filter(st => st.object.value !== outbox) : []),
474
- ];
519
+ const stale = [];
520
+ for (const { g } of docs) {
521
+ for (const st of g?.statementsMatching(actor, FOAF('accountName'), null) || []) {
522
+ if (st.object.value !== accountName) stale.push([st.subject, st.predicate, st.object]);
523
+ }
524
+ if (outbox) {
525
+ for (const st of g?.statementsMatching(me, AS('outbox'), null) || []) {
526
+ if (st.object.value !== outbox) stale.push([st.subject, st.predicate, st.object]);
527
+ }
528
+ }
529
+ }
475
530
  if (!missing.length && !stale.length) return false;
476
- // A patch touches these statements and nothing else. Rewriting the whole
477
- // profile re-serialises statements that are not ours — the OIDC issuer
478
- // among them — and a server is entitled to refuse a write that would.
479
- const deletes = stale.map(st => [st.subject, st.predicate, st.object]);
480
- if (await this.patchDocument(docUrl, missing, deletes)) return true;
481
- for (const st of stale) g.remove(st);
482
- for (const [s, p, o] of missing) g.add(s, p, o, doc);
483
- await this.put(docUrl, $rdf.serialize(doc, g, docUrl, 'text/turtle'), 'text/turtle');
531
+ // Checked before it is written, to the profile or else to a document its
532
+ // seeAlso names (writeAboutWebId); a patch carries only these statements.
533
+ await this.writeAboutWebId(podBase, { inserts: missing, deletes: stale });
484
534
  return true;
485
535
  }
486
536
 
@@ -522,4 +572,144 @@ export class PodTransport {
522
572
  if (res.status === 405 || res.status === 415 || res.status === 501) return false;
523
573
  throw new Error(`[${this.label}] PATCH ${docUrl} → ${res.status}`);
524
574
  }
575
+
576
+ // ---- writing a profile or a type index: valid RDF, or nothing ----
577
+ //
578
+ // Two rules. A change is written only if the document it leaves behind is
579
+ // valid RDF: the graph as it would be afterwards is serialised and parsed
580
+ // back, and the subject that must stay described still is. And a profile
581
+ // that will not take a write is not the end: the statements go to the first
582
+ // document its rdfs:seeAlso names, inside the same pod, that takes them —
583
+ // under the same check. A profile is what every Solid app signs in through;
584
+ // one left broken breaks them all. Reading follows the same links.
585
+
586
+ /** An IRI or a literal as a term, for the operations that pass plain values. */
587
+ sym(iri) {
588
+ try { return $rdf.sym(iri); } catch { throw new Error(`not written: ${iri} is not an absolute IRI`); }
589
+ }
590
+ literal(value) { return $rdf.literal(value); }
591
+
592
+ /** A document as a graph, or null when it is not there. */
593
+ async readRdf(docUrl) {
594
+ const res = await this.fetch(docUrl, { headers: { accept: 'text/turtle' } });
595
+ if (res.status === 404 || res.status === 410) return null;
596
+ if (res.status >= 400) throw new Error(`[${this.label}] GET ${docUrl} → ${res.status}`);
597
+ const g = $rdf.graph();
598
+ $rdf.parse(await res.text(), g, docUrl, 'text/turtle');
599
+ return g;
600
+ }
601
+
602
+ /**
603
+ * The document as it would be after the change, checked. Returns the Turtle
604
+ * to write, or throws saying why nothing may be written.
605
+ */
606
+ checkedRdf(g, docUrl, { inserts = [], deletes = [], mustDescribe = null }) {
607
+ const doc = $rdf.sym(docUrl);
608
+ for (const [s, p, o] of inserts) {
609
+ const bad = termProblem(s) || termProblem(p) || termProblem(o)
610
+ || (s?.termType === 'Literal' || p?.termType !== 'NamedNode' ? 'a literal subject or predicate' : null);
611
+ if (bad) throw new Error(`not written: ${bad}`);
612
+ }
613
+ const after = $rdf.graph();
614
+ for (const st of g.statementsMatching(null, null, null, doc)) after.add(st.subject, st.predicate, st.object, doc);
615
+ for (const [s, p, o] of deletes) for (const st of after.statementsMatching(s, p, o, doc)) after.remove(st);
616
+ for (const [s, p, o] of inserts) if (!after.holds(s, p, o, doc)) after.add(s, p, o, doc);
617
+ let text;
618
+ try { text = $rdf.serialize(doc, after, docUrl, 'text/turtle'); } catch (e) {
619
+ throw new Error(`not written: ${docUrl} would not serialise (${e.message})`);
620
+ }
621
+ const back = $rdf.graph();
622
+ try { $rdf.parse(text, back, docUrl, 'text/turtle'); } catch (e) {
623
+ throw new Error(`not written: ${docUrl} would not parse back (${e.message})`);
624
+ }
625
+ if (back.statementsMatching(null, null, null, doc).length !== after.statementsMatching(null, null, null, doc).length) {
626
+ throw new Error(`not written: ${docUrl} would not read back as written`);
627
+ }
628
+ if (mustDescribe && !back.statementsMatching($rdf.sym(mustDescribe), null, null, doc).length) {
629
+ throw new Error(`not written: ${docUrl} would no longer describe ${mustDescribe}`);
630
+ }
631
+ return text;
632
+ }
633
+
634
+ /**
635
+ * Change one document, or refuse. `g` is the document as read (null for a
636
+ * new one). A PATCH carries only these statements; where the pod cannot
637
+ * patch, the whole checked document is written. Returns the write's status.
638
+ */
639
+ async writeRdfChecked(docUrl, g, { inserts = [], deletes = [], mustDescribe = null }) {
640
+ const current = g || $rdf.graph();
641
+ const doc = $rdf.sym(docUrl);
642
+ const todo = inserts.filter(([s, p, o]) => !current.holds(s, p, o, doc));
643
+ const gone = deletes.filter(([s, p, o]) => current.holds(s, p, o, doc));
644
+ if (!todo.length && !gone.length) return 200;
645
+ const text = this.checkedRdf(current, docUrl, { inserts: todo, deletes: gone, mustDescribe });
646
+ if (g) {
647
+ let res = null;
648
+ try {
649
+ res = await this.fetch(docUrl, { method: 'PATCH', headers: { 'content-type': 'text/n3' },
650
+ body: this.n3Patch(docUrl, todo, gone) });
651
+ } catch { res = null; }
652
+ if (res && res.status < 300) return res.status;
653
+ // A pod that cannot patch gets the whole checked document; any other
654
+ // answer — a refusal, a conflict — is the answer.
655
+ if (res && ![405, 415, 501].includes(res.status)) return res.status;
656
+ }
657
+ const put = await this.fetch(docUrl, { method: 'PUT', headers: { 'content-type': 'text/turtle' }, body: text });
658
+ return put.status;
659
+ }
660
+
661
+ /**
662
+ * The profile and the documents its rdfs:seeAlso names inside this pod,
663
+ * each with its graph, the profile first. What any of them says about the
664
+ * WebID is what the profile says.
665
+ */
666
+ async profileDocs(podBase) {
667
+ const profileUrl = this.webId.split('#')[0];
668
+ const g = await this.readRdf(profileUrl);
669
+ if (!g) throw new Error(`no profile at ${profileUrl}`);
670
+ const out = [{ url: profileUrl, g, profile: true }];
671
+ const seen = new Set([profileUrl]);
672
+ for (const st of g.statementsMatching(null, RDFS('seeAlso'), null)) {
673
+ const url = st.object.value.split('#')[0];
674
+ if (seen.has(url) || !url.startsWith(podBase)) continue; // inside this pod only
675
+ seen.add(url);
676
+ out.push({ url, g: await this.readRdf(url).catch(() => null), profile: false });
677
+ }
678
+ return out;
679
+ }
680
+
681
+ /** Every value the WebID has for `predicate`, across the profile and its seeAlso documents. */
682
+ webIdValues(docs, predicate) {
683
+ const me = $rdf.sym(this.webId);
684
+ const p = $rdf.sym(predicate);
685
+ const out = [];
686
+ for (const { g } of docs) for (const st of g?.statementsMatching(me, p, null) || []) out.push(st.object.value);
687
+ return [...new Set(out)];
688
+ }
689
+
690
+ /**
691
+ * Write statements about the WebID: to the profile, or — if the profile will
692
+ * not take them — to the first of its seeAlso documents that will. Every
693
+ * write checked. Returns the document written to, or null when nothing
694
+ * needed writing; throws when none would take it.
695
+ */
696
+ async writeAboutWebId(podBase, { inserts = [], deletes = [] }) {
697
+ const docs = await this.profileDocs(podBase);
698
+ const holds = ([s, p, o]) => docs.some(({ g }) => g?.holds(s, p, o));
699
+ const todo = inserts.filter(t => !holds(t));
700
+ const gone = deletes.filter(holds);
701
+ if (!todo.length && !gone.length) return null;
702
+ const refusals = [];
703
+ for (const d of docs) {
704
+ const here = gone.filter(([s, p, o]) => d.g?.holds(s, p, o));
705
+ const status = await this.writeRdfChecked(d.url, d.g, {
706
+ inserts: todo, deletes: here, mustDescribe: d.profile ? this.webId : null,
707
+ });
708
+ if (status < 300) return d.url;
709
+ refusals.push(`${d.url} → ${status}`);
710
+ if (status !== 401 && status !== 403) break; // a real answer, not "not yours to write"
711
+ }
712
+ throw new Error(`not written: neither the profile nor a document it names would take it (${refusals.join('; ')})`);
713
+ }
714
+
525
715
  }