fedipod-server 0.28.1 → 0.29.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 (61) hide show
  1. package/README.md +27 -28
  2. package/config/server.json +22 -0
  3. package/dist/claims.js +3 -2
  4. package/dist/directory.d.ts +2 -0
  5. package/dist/handler.d.ts +4 -1
  6. package/dist/handler.js +32 -3
  7. package/dist/handler.jsonld +4 -0
  8. package/dist/store-pod.js +37 -0
  9. package/lib/client/c2s.mjs +97 -43
  10. package/lib/client/masto/accounts.mjs +1 -0
  11. package/lib/core/deliver.mjs +17 -1
  12. package/lib/core/intake/activities.mjs +18 -2
  13. package/lib/core/intake/group.mjs +3 -1
  14. package/lib/core/intake/index.mjs +33 -2
  15. package/lib/core/intake/notes.mjs +12 -5
  16. package/lib/core/place.mjs +82 -0
  17. package/lib/core/publisher/collections.mjs +37 -2
  18. package/lib/core/publisher/index.mjs +23 -1
  19. package/lib/core/publisher/notes.mjs +73 -7
  20. package/lib/core/publisher/own.mjs +143 -0
  21. package/lib/core/publisher/questions.mjs +5 -3
  22. package/lib/core/social.mjs +62 -32
  23. package/lib/core/store.mjs +11 -1
  24. package/lib/core/wire.mjs +51 -15
  25. package/lib/device/admin/routes/lifecycle.mjs +1 -1
  26. package/lib/device/admin/routes/setup.mjs +17 -1
  27. package/lib/device/cli/commands/setup.mjs +50 -8
  28. package/lib/device/cli/context.mjs +1 -1
  29. package/lib/device/migrate.mjs +1 -1
  30. package/lib/device/setup.mjs +45 -8
  31. package/lib/gateway/front-core.mjs +54 -58
  32. package/lib/gateway/gateway-core.mjs +87 -8
  33. package/lib/gateway/relay-extras.mjs +89 -0
  34. package/lib/gateway/token-claims.mjs +16 -0
  35. package/lib/pod/location.mjs +52 -0
  36. package/lib/pod/notes.mjs +2 -4
  37. package/lib/pod/transport.mjs +171 -22
  38. package/lib/pod/type-index.mjs +101 -0
  39. package/lib/pod/urls.mjs +6 -0
  40. package/lib/server/embed.mjs +7 -6
  41. package/lib/session/fedi-account.mjs +19 -10
  42. package/lib/session/package.json +1 -1
  43. package/package.json +1 -1
  44. package/run-agent.mjs +2 -0
  45. package/web/admin/actors.js +2 -0
  46. package/web/admin/index.html +9 -0
  47. package/web/admin/record.js +4 -1
  48. package/web/admin/setup/index.html +17 -1
  49. package/web/admin/setup/setup.js +18 -5
  50. package/web/app/README.md +1 -1
  51. package/web/app/admin-facade.mjs +1 -1
  52. package/web/app/agent.mjs +11 -2
  53. package/web/app/boot.mjs +72 -32
  54. package/web/app/dist/boot.js +430 -86
  55. package/web/app/dist/boot.js.map +4 -4
  56. package/web/app/dist/sw.js +3294 -2648
  57. package/web/app/dist/sw.js.map +4 -4
  58. package/web/app/index.html +16 -0
  59. package/web/app/signup.mjs +65 -26
  60. package/web/front/run.html +7 -1
  61. package/web/front/run.js +30 -4
@@ -25,6 +25,8 @@ import { routeQuietApi, noteOpened, noteReceived, closedState, accountState, clo
25
25
  import { routeNoticesApi } from './notices.mjs';
26
26
  import { withSecurityHeaders } from './headers.mjs';
27
27
  import { podTokenVerifier } from './caches.mjs';
28
+ import { claimedWebId } from './token-claims.mjs';
29
+ import { withdrawn, overLimit, relayOne } from './relay-extras.mjs';
28
30
 
29
31
  // The one WebFinger document, spelled out here rather than imported from
30
32
  // wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
@@ -91,9 +93,13 @@ const podOwners = (podBase, fetchImpl = fetch) =>
91
93
  podRoot.readOwnerLinks(fetchImpl, podBase, { timeoutMs: OWNER_LOOKUP_MS });
92
94
 
93
95
  // Whether a token's WebID vouches for this pod, on the evidence of where it
94
- // lives. Weak on purpose, and never used alone any more — see provesPod.
96
+ // lives: the same origin, and — for a suffixed pod — inside
97
+ // the WebID's own pod, wherever in it the account was put. Weak on purpose,
98
+ // and never used alone for an attach — see provesPod.
95
99
  function webidUnderPod(webid, podHome) {
96
- try { return new URL(webid).origin === new URL(podHome).origin; } catch { return false; }
100
+ try {
101
+ return new URL(webid).origin === new URL(podHome).origin && String(podHome).startsWith(podBaseOfWebId(webid));
102
+ } catch { return false; }
97
103
  }
98
104
 
99
105
  /**
@@ -144,15 +150,8 @@ function podHomeProblem(podHome, frontOrigin) {
144
150
  }
145
151
 
146
152
  const RELAY_MAX_REQUESTS = 20;
147
- const RELAY_MAX_BODY = 1024 * 1024;
148
- const RELAY_TIMEOUT_MS = 8_000;
149
- // The only headers a relayed request may carry to the remote server. Host
150
- // comes from the URL; the user agent from safeFetch.
151
153
  const SERVER_PROBE_MS = 6000;
152
154
  const SERVER_PROBE_BYTES = 64 * 1024;
153
- const RELAY_HEADERS = new Set(['date', 'digest', 'signature', 'content-type', 'accept']);
154
-
155
- const keyIdOf = (signature) => (/keyId="([^"]+)"/.exec(signature || '') || [])[1] || null;
156
155
 
157
156
  // The owner's APIs a browser at ANOTHER origin may call: an address moving
158
157
  // to a new gateway is driven from the new gateway's page, and it has to tell
@@ -167,46 +166,6 @@ const API_CORS = {
167
166
  const withApiCors = (out) => ({ ...out, headers: { ...(out.headers || {}), ...API_CORS } });
168
167
  const apiPreflight = () => ({ status: 204, headers: { ...API_CORS, allow: 'POST, OPTIONS' }, body: null });
169
168
 
170
- async function relayOne(item, rec, fetchImpl) {
171
- const url = String(item?.url || '');
172
- const method = String(item?.method || 'POST').toUpperCase();
173
- if (method !== 'GET' && method !== 'POST') return { url, status: 0, error: 'method must be GET or POST' };
174
- let u;
175
- try { u = new URL(url); } catch { return { url, status: 0, error: 'not a URL' }; }
176
- if (u.protocol !== 'https:' && !(u.protocol === 'http:' && process.env.AP_ALLOW_PRIVATE_TARGETS === '1')) {
177
- return { url, status: 0, error: 'https only' };
178
- }
179
- const headers = {};
180
- for (const [k, v] of Object.entries(item?.headers || {})) {
181
- const name = k.toLowerCase();
182
- if (RELAY_HEADERS.has(name) && typeof v === 'string') headers[name] = v;
183
- }
184
- const body = method === 'POST' ? String(item?.body ?? '') : undefined;
185
- if (body !== undefined && Buffer.byteLength(body) > RELAY_MAX_BODY) return { url, status: 0, error: 'body too large' };
186
- const keyId = keyIdOf(headers.signature);
187
- if (method === 'POST' && !keyId) return { url, status: 0, error: 'a delivery must be signed' };
188
- if (keyId && !keyId.startsWith(rec.actorUrl + '#')) return { url, status: 0, error: "signed with a key that is not this account's" };
189
- if (headers.digest) {
190
- const want = 'SHA-256=' + crypto.createHash('sha256').update(body || '').digest('base64');
191
- if (headers.digest !== want) return { url, status: 0, error: 'digest does not match the body' };
192
- }
193
- try {
194
- const res = await safeFetch(url, { method, headers, body, signal: AbortSignal.timeout(RELAY_TIMEOUT_MS) }, fetchImpl);
195
- const out = { url, method, status: res.status };
196
- // The far server asking to be left alone has to reach the agent that will
197
- // do the asking again. Without this the browser build could not honour a
198
- // Retry-After at all — every delivery it makes goes through here — and fell
199
- // back to its own ladder against a server that had already said how long.
200
- const retryAfter = res.headers.get('retry-after');
201
- if (retryAfter) out.retryAfter = retryAfter;
202
- if (method === 'GET') {
203
- out.contentType = res.headers.get('content-type') || null;
204
- out.body = await readCapped(res, RELAY_MAX_BODY);
205
- }
206
- return out;
207
- } catch (e) { return { url, method, status: 0, error: e.message }; }
208
- }
209
-
210
169
  const j = (status, obj, ct = 'application/json') =>
211
170
  ({ status, headers: { 'content-type': ct, 'cache-control': 'no-store' }, body: JSON.stringify(obj) });
212
171
  // Held at the edge like any other missing document: a bot's scan, a dead
@@ -639,7 +598,11 @@ async function route(request, ctx) {
639
598
  // Acting through the relay is being here. Hourly at most; see noteOpened.
640
599
  await noteOpened(ctx, handle, rec).catch((e) => console.log(`relay @${handle}: stamp not written: ${e?.message || e}`));
641
600
  if (items.length > RELAY_MAX_REQUESTS) return withApiCors(j(400, { error: `at most ${RELAY_MAX_REQUESTS} requests per call` }));
601
+ const slow = overLimit(handle, items.length) && j(429, { error: 'too many relayed requests — slow down' });
602
+ if (slow) return withApiCors({ ...slow, headers: { ...slow.headers, 'retry-after': '60' } });
642
603
  const results = await Promise.all(items.map((it) => relayOne(it, rec, ctx.fetchImpl || fetch)));
604
+ // Something of the account's withdrawn: the edge's copies go.
605
+ if (withdrawn(items, rec.actorUrl)) await ctx.purge?.([`u-${handle}`]).catch((e) => console.log(`purge @${handle}: ${e?.message || e}`));
643
606
  // One line per relayed request, so a lookup that fails on the far side is
644
607
  // visible here and not only as an empty result in someone's browser.
645
608
  for (const r of results) console.log(`relay @${handle}: ${r.method || ''} ${r.url} → ${r.status}${r.error ? ` (${r.error})` : ''}`);
@@ -724,7 +687,8 @@ async function route(request, ctx) {
724
687
  return j(403, { error: 'the token proves a different pod than the one you listed' });
725
688
  }
726
689
  if (action === 'opt-in') {
727
- const { httpStatus, ...reply } = await ctx.agentControl.optIn({ podBase, webId: webid });
690
+ const { httpStatus, ...reply } = await ctx.agentControl.optIn({ podBase, webId: webid,
691
+ container: String(body.container || ''), createIndex: body.createIndex === true });
728
692
  if (reply.doorSecret) {
729
693
  // The secret appears here and nowhere else; the command is the
730
694
  // paste-and-run way to open the owner door once.
@@ -876,13 +840,28 @@ async function route(request, ctx) {
876
840
  if (!webid) return json(401, { error: 'a Solid-OIDC token proving this account\'s owner is required' });
877
841
  const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
878
842
  if (!owner) return json(403, { error: 'this outbox belongs to its owner alone' });
879
- const { status, reason, location } = await handleOwnerPost(request, identFor(rec),
880
- { podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), ownerWebId: webid });
843
+ const onPod = (u) => u.replace(rec.actorUrl.replace(/ap\/actor$/u, ''), rec.podHome);
844
+ const { status, reason, location, object } = await handleOwnerPost(request, identFor(rec),
845
+ { podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), ownerWebId: webid,
846
+ exists: async (u) => ((await (ctx.podGet || fetch)(onPod(u), { podHome: rec.podHome })).status !== 404) });
881
847
  console.log(`door @${up.handle}: owner post → ${status} (${reason})`);
882
848
  if (status !== 201) return json(status, { error: reason });
883
- return json(201, { accepted: true, ...(location ? { object: location } : {}),
849
+ return json(201, { accepted: true, ...(location ? { id: location } : {}), ...(object ? { object } : {}),
884
850
  note: 'it goes out when your FediPod agent next runs' }, location ? { location } : {});
885
851
  }
852
+ // Its owner, signed in, reads every message the account produced (§5.1:
853
+ // the outbox is filtered by who asks); that view is on the pod, under the
854
+ // owner's own rule. Anyone else reads the public one.
855
+ // The token is only read here, not verified: the redirect grants nothing,
856
+ // the pod checks the credential when the owner arrives there, and a
857
+ // verification per read would let anyone make the front fetch keys from a
858
+ // server they name.
859
+ if ((request.method === 'GET' || request.method === 'HEAD') && request.headers.get('authorization')) {
860
+ const webid = claimedWebId(request);
861
+ if (webid && (rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome))) {
862
+ return { status: 303, headers: { ...cors, location: rec.podHome + 'ap/private/outbox', 'cache-control': 'no-store' }, body: '' };
863
+ }
864
+ }
886
865
  if (rec.inboxOnly && (request.method === 'GET' || request.method === 'HEAD')) {
887
866
  return { status: 303, headers: { ...cors, location: rec.podHome + 'ap/outbox', 'cache-control': 'no-store' }, body: '' };
888
867
  }
@@ -907,6 +886,12 @@ async function route(request, ctx) {
907
886
  }
908
887
  if (closed) return gone({ ...open, ...publicFor(GONE_EDGE_SECONDS) }, CLOSED);
909
888
  const podTarget = rec.podHome + up.rest;
889
+ // The owner's own documents — the full outbox, the liked list, the pending
890
+ // lists — are read at the pod, which decides who may; never held here,
891
+ // where one reader's answer would be served to the next.
892
+ if (up.rest.startsWith('ap/private/')) {
893
+ return { status: 303, headers: { ...open, location: podTarget, 'cache-control': 'no-store' }, body: '' };
894
+ }
910
895
  // Media stays on the pod (lib/pod/urls.mjs keeps `media` off the front), but
911
896
  // the id rewrite below turns media links onto the front like every other
912
897
  // pod url in a document. Answer those by pointing at the pod: bytes are not
@@ -929,8 +914,9 @@ async function route(request, ctx) {
929
914
  ctx.podGet || ((u) => fetch(u, { headers: { accept } })),
930
915
  podTarget, { podHome: rec.podHome, accept });
931
916
  if (got.text === null) {
932
- const hold = [401, 403, 404, 410].includes(got.status) ? publicFor(MISSING_EDGE_SECONDS) : {};
933
- return { status: got.status, headers: { ...open, ...hold }, body: '' };
917
+ const hold = [401, 403, 404, 410].includes(got.status) ? { ...publicFor(MISSING_EDGE_SECONDS), 'netlify-cache-tag': `u-${up.handle}` } : {};
918
+ // A stranger is not told a document exists that they may not read (§3.2).
919
+ return { status: got.status === 401 || got.status === 403 ? 404 : got.status, headers: { ...open, ...hold }, body: '' };
934
920
  }
935
921
  let text = got.text;
936
922
  text = swap(text, rec.podHome, base);
@@ -946,6 +932,9 @@ async function route(request, ctx) {
946
932
  doc.preferredUsername = up.handle; // so @handle@front cross-checks
947
933
  doc.inbox = base + 'ap/inbox/'; // deliveries come to the front to be verified
948
934
  doc.endpoints = { ...(doc.endpoints || {}), sharedInbox: base + 'ap/inbox/' };
935
+ // The owner's own documents are read at the pod, where their credential
936
+ // works; they keep the pod's address.
937
+ for (const k of ['liked', 'ownerOutbox']) if (typeof doc[k] === 'string') doc[k] = doc[k].replace(base, rec.podHome);
949
938
  if (movedBase) {
950
939
  doc.movedTo = rec.movedTo;
951
940
  const aka = new Set([].concat(doc.alsoKnownAs || []).filter((a) => typeof a === 'string' && a !== doc.id));
@@ -961,9 +950,16 @@ async function route(request, ctx) {
961
950
  } catch { /* leave the rewritten text as-is if it will not parse */ }
962
951
  }
963
952
  // `readPublicDocument` asked for it with no credential, so a document that
964
- // came back is one anybody can read — which is what makes it cacheable.
965
- return { status: 200,
966
- headers: { ...open, 'content-type': got.type || (container ? 'text/turtle' : page ? 'text/html' : AP_CT), ...publicFor(PUBLIC_EDGE_SECONDS) },
953
+ // came back is one anybody can read — which is what makes it cacheable. A
954
+ // deleted post's Tombstone answers 410 Gone, and still says what it was.
955
+ let kind = null;
956
+ try { kind = JSON.parse(text)?.type; } catch { /* not JSON: a page or a container */ }
957
+ return { status: kind === 'Tombstone' ? 410 : 200,
958
+ headers: { ...open, 'content-type': got.type || (container ? 'text/turtle' : page ? 'text/html' : AP_CT), ...publicFor(PUBLIC_EDGE_SECONDS),
959
+ // The outbox answers its signed-in owner differently, so the edge keeps
960
+ // the public copy for those who show no credential.
961
+ ...(up.rest === 'ap/outbox' ? { 'netlify-vary': 'header=Authorization', vary: 'Authorization' } : {}),
962
+ 'netlify-cache-tag': `u-${up.handle}` },
967
963
  body: text };
968
964
  }
969
965
 
@@ -133,14 +133,15 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
133
133
  // client-to-server dispatcher, which publishes and delivers it — so the post
134
134
  // goes out when the agent next runs, the same way inbound mail is read.
135
135
  //
136
- // `slug` is the name the client asked for its new document. It rides in the
137
- // receipt so the dispatcher can use it, and it is what lets the door answer a
138
- // Location before anything exists: the object will live at notesPrefix+slug
139
- // unless that name is taken, in which case the agent mints another.
136
+ // `slug` is the name of the new document — the client's, or one the door
137
+ // chose — and `serial` and `at` name any other activity. They ride in the
138
+ // receipt so the dispatcher uses them, and they are what let the door answer a
139
+ // Location before anything exists. A name the client chose that is already
140
+ // taken is the one case where the agent picks another.
140
141
  export const SLUG_OK = /^[A-Za-z0-9._-]{1,64}$/u;
141
142
  export const safeSlug = (s) => (typeof s === 'string' && SLUG_OK.test(s) && !/^\.+$/u.test(s) ? s : null);
142
143
 
143
- export async function handleOwnerPost(request, ident, { podPut, ownerWebId, maxBytes = DEFAULT_MAX_BYTES } = {}) {
144
+ export async function handleOwnerPost(request, ident, { podPut, ownerWebId, maxBytes = DEFAULT_MAX_BYTES, exists = null } = {}) {
144
145
  if (!ident.hmacSecret) return { status: 409, reason: 'this account has no door secret — attach it again' };
145
146
  let raw;
146
147
  try { raw = await request.text(); } catch { return { status: 400, reason: 'unreadable body' }; }
@@ -150,16 +151,94 @@ export async function handleOwnerPost(request, ident, { podPut, ownerWebId, maxB
150
151
  if (!doc || typeof doc !== 'object' || Array.isArray(doc) || !doc.type) {
151
152
  return { status: 400, reason: 'a typed ActivityStreams object is required' };
152
153
  }
153
- const slug = safeSlug(request.headers.get('slug'));
154
+ // A post that makes something new is named HERE, when the client did not
155
+ // name it, so the answer can give the address of what it made (§6: 201 with
156
+ // the new activity's id in Location) though nothing has been made yet.
157
+ // What the account would refuse is refused here, before the app is told
158
+ // "created" for something that will never exist.
159
+ const refusal = refusedAtTheDoor(doc, ident);
160
+ if (refusal) return { status: 422, reason: refusal };
161
+ const makes = doc.type === 'Create' || !ACTIVITY_TYPES.has(doc.type);
162
+ // A name the client asks for that is already taken is replaced here, so the
163
+ // address answered is the one the post gets.
164
+ let slug = safeSlug(request.headers.get('slug'));
165
+ if (slug && makes && exists && await exists(containerFor(doc, ident) + slug).catch(() => false)) slug = null;
166
+ if (!slug && makes) slug = mintSlug();
167
+ // Anything else is named the way the agent names it, from a serial and a
168
+ // time chosen here and handed over in the receipt.
169
+ // Two posts in the same millisecond still get different names.
170
+ const now = Date.now();
171
+ const serial = now * 1000 + crypto.randomInt(1000);
172
+ const at = new Date(now).toISOString();
173
+ const hash = sha256hex(raw);
174
+ // The receipt names the body it vouches for, so it vouches for nothing else,
175
+ // and the agent can tell the same body sent twice.
154
176
  const receipt = signReceipt({
155
177
  v: 1, verified: true, method: 'c2s', keyId: ownerWebId || null, actor: ident.actorUrl,
156
178
  checks: ['owner-token'], reason: 'owner', gateway: ident.gatewayWebId, ...(slug ? { slug } : {}),
179
+ serial, at, hash,
157
180
  }, ident.hmacSecret);
158
- const hash = sha256hex(raw);
159
181
  const okA = await inbox.appendVerifiedDelivery(podPut, ident.inboxUrl, hash, raw);
160
182
  if (!okA) return { status: 502, reason: 'pod inbox write failed' };
161
183
  await inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt);
162
- return { status: 201, reason: 'accepted', location: slug && ident.notesPrefix ? ident.notesPrefix + slug : null };
184
+ if (makes) {
185
+ const object = slug && ident.notesPrefix ? containerFor(doc, ident) + slug : null;
186
+ return { status: 201, reason: 'accepted', object, location: object ? object + '-create' : null };
187
+ }
188
+ const object = typeof doc.object === 'string' ? doc.object : doc.object?.id || null;
189
+ return { status: 201, reason: 'accepted', object, location: activityIdFor(doc.type, object, ident, serial, at) };
190
+ }
191
+
192
+ // The id the agent gives each kind of activity (lib/core/wire.mjs): an edit by
193
+ // its note and time, a deletion by its note, a boost and an undo as documents
194
+ // in the posts folder, the rest by the actor and serial.
195
+ const SERIAL_NAMED = new Set(['Like', 'Follow', 'Block', 'Add', 'Remove', 'Accept', 'Reject']);
196
+ function activityIdFor(type, object, ident, serial, at) {
197
+ const actor = ident.actorUrl;
198
+ if ((type === 'Update' || type === 'Delete') && object && object !== actor) {
199
+ return type === 'Delete' ? object + '-delete' : object + '-update-' + at.replace(/[^0-9TZ]/g, '');
200
+ }
201
+ if ((type === 'Announce' || type === 'Undo') && ident.notesPrefix) return `${ident.notesPrefix}${type.toLowerCase()}-${serial}`;
202
+ return SERIAL_NAMED.has(type) ? `${actor}#${type.toLowerCase()}-${serial}` : null;
203
+ }
204
+
205
+ // The account's own changes are made on its manage page, and the one
206
+ // collection a client may add to or take from is the pinned posts.
207
+ function refusedAtTheDoor(doc, ident) {
208
+ const object = typeof doc.object === 'string' ? doc.object : doc.object?.id || null;
209
+ const target = typeof doc.target === 'string' ? doc.target : doc.target?.id || null;
210
+ if (doc.type === 'Move') return 'moving the account is done on its manage page';
211
+ if ((doc.type === 'Update' || doc.type === 'Delete') && object === ident.actorUrl) {
212
+ return 'the account itself is changed on its manage page';
213
+ }
214
+ if ((doc.type === 'Add' || doc.type === 'Remove') && target !== ident.actorUrl.replace(/actor$/u, 'featured')) {
215
+ return 'the pinned posts are the one collection an app may add to or take from';
216
+ }
217
+ return null;
218
+ }
219
+
220
+ // The activity types §6 names; anything else with a type is an object the
221
+ // agent wraps in a Create. The same list the dispatcher keeps (lib/client/c2s.mjs).
222
+ const ACTIVITY_TYPES = new Set([
223
+ 'Create', 'Update', 'Delete', 'Follow', 'Like', 'Announce', 'Undo',
224
+ 'Block', 'Add', 'Remove', 'Accept', 'Reject', 'Move',
225
+ ]);
226
+
227
+ // The name the agent would have chosen itself: the day and eight hex characters.
228
+ const mintSlug = () => new Date().toISOString().slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
229
+
230
+ // Where the agent will keep it: public and unlisted posts under notes/, posts
231
+ // for followers or named people under private/ — decided by the addressing,
232
+ // read the way the dispatcher reads it (nothing stated is public).
233
+ const PUBLIC_NAMES = new Set(['https://www.w3.org/ns/activitystreams#Public', 'as:Public', 'Public']);
234
+ function containerFor(doc, ident) {
235
+ const object = doc.type === 'Create' && doc.object && typeof doc.object === 'object' ? doc.object : doc;
236
+ const list = (v) => (v == null ? [] : Array.isArray(v) ? v : [v]).map(x => (typeof x === 'string' ? x : x?.id));
237
+ const to = list(doc.to ?? object.to);
238
+ const cc = list(doc.cc ?? object.cc);
239
+ const blind = list(doc.bto ?? object.bto).length + list(doc.bcc ?? object.bcc).length;
240
+ const open = (!to.length && !cc.length && !blind) || [...to, ...cc].some(a => PUBLIC_NAMES.has(a));
241
+ return open ? ident.notesPrefix : ident.notesPrefix.replace(/ap\/notes\/$/u, 'ap/private/');
163
242
  }
164
243
 
165
244
  export const _internal = { isBlocked, concernsUsAtEdge, httpUrl, sha256hex };
@@ -0,0 +1,89 @@
1
+ // relay-extras.mjs — the relay's single request, and what the relay notices
2
+ // about the requests it carries.
3
+ //
4
+ // relayOne(): sends one request a browser-run agent has already signed, with
5
+ // exactly the headers that were signed. It forwards a signature and cannot
6
+ // make one: a POST must carry one, by this account's own key, over the body
7
+ // its Digest names.
8
+ //
9
+ // withdrawn(): a relayed delivery that withdraws something of the account's —
10
+ // a Delete, or an Undo — means the copies the edge holds of that account's
11
+ // documents are out of date; the front purges them rather than serving a
12
+ // deleted post for the rest of its hold.
13
+ //
14
+ // overLimit(): how many requests one account may have the relay carry. Counted
15
+ // per running instance, which bounds a runaway agent or a stolen token without
16
+ // any shared state; a real account sends nowhere near it.
17
+
18
+ import crypto from 'node:crypto';
19
+ import { readCapped, safeFetch } from '../shared/safefetch.mjs';
20
+
21
+ const RELAY_MAX_BODY = 1024 * 1024;
22
+ const RELAY_TIMEOUT_MS = 8_000;
23
+ // The only headers a relayed request may carry to the remote server. Host
24
+ // comes from the URL; the user agent from safeFetch.
25
+ const RELAY_HEADERS = new Set(['date', 'digest', 'signature', 'content-type', 'accept']);
26
+ const keyIdOf = (signature) => (/keyId="([^"]+)"/.exec(signature || '') || [])[1] || null;
27
+
28
+ export async function relayOne(item, rec, fetchImpl) {
29
+ const url = String(item?.url || '');
30
+ const method = String(item?.method || 'POST').toUpperCase();
31
+ if (method !== 'GET' && method !== 'POST') return { url, status: 0, error: 'method must be GET or POST' };
32
+ let u;
33
+ try { u = new URL(url); } catch { return { url, status: 0, error: 'not a URL' }; }
34
+ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && process.env.AP_ALLOW_PRIVATE_TARGETS === '1')) {
35
+ return { url, status: 0, error: 'https only' };
36
+ }
37
+ const headers = {};
38
+ for (const [k, v] of Object.entries(item?.headers || {})) {
39
+ const name = k.toLowerCase();
40
+ if (RELAY_HEADERS.has(name) && typeof v === 'string') headers[name] = v;
41
+ }
42
+ const body = method === 'POST' ? String(item?.body ?? '') : undefined;
43
+ if (body !== undefined && Buffer.byteLength(body) > RELAY_MAX_BODY) return { url, status: 0, error: 'body too large' };
44
+ const keyId = keyIdOf(headers.signature);
45
+ if (method === 'POST' && !keyId) return { url, status: 0, error: 'a delivery must be signed' };
46
+ if (keyId && !keyId.startsWith(rec.actorUrl + '#')) return { url, status: 0, error: "signed with a key that is not this account's" };
47
+ if (headers.digest) {
48
+ const want = 'SHA-256=' + crypto.createHash('sha256').update(body || '').digest('base64');
49
+ if (headers.digest !== want) return { url, status: 0, error: 'digest does not match the body' };
50
+ }
51
+ try {
52
+ const res = await safeFetch(url, { method, headers, body, signal: AbortSignal.timeout(RELAY_TIMEOUT_MS) }, fetchImpl);
53
+ const out = { url, method, status: res.status };
54
+ // The far server asking to be left alone has to reach the agent that will
55
+ // do the asking again. Without this the browser build could not honour a
56
+ // Retry-After at all — every delivery it makes goes through here — and fell
57
+ // back to its own ladder against a server that had already said how long.
58
+ const retryAfter = res.headers.get('retry-after');
59
+ if (retryAfter) out.retryAfter = retryAfter;
60
+ if (method === 'GET') {
61
+ out.contentType = res.headers.get('content-type') || null;
62
+ out.body = await readCapped(res, RELAY_MAX_BODY);
63
+ }
64
+ return out;
65
+ } catch (e) { return { url, method, status: 0, error: e.message }; }
66
+ }
67
+
68
+ const WINDOW_MS = 10 * 60 * 1000;
69
+ const PER_WINDOW = 600;
70
+ const counts = new Map(); // handle → { since, n }
71
+
72
+ export function withdrawn(items, actorUrl) {
73
+ return items.some((it) => {
74
+ if (String(it?.method || 'POST').toUpperCase() !== 'POST') return false;
75
+ try {
76
+ const a = JSON.parse(String(it?.body ?? ''));
77
+ return a?.actor === actorUrl && (a.type === 'Delete' || a.type === 'Undo');
78
+ } catch { return false; }
79
+ });
80
+ }
81
+
82
+ export function overLimit(handle, n, now = Date.now()) {
83
+ const c = counts.get(handle);
84
+ const fresh = !c || now - c.since > WINDOW_MS ? { since: now, n: 0 } : c;
85
+ fresh.n += n;
86
+ counts.set(handle, fresh);
87
+ if (counts.size > 10_000) counts.clear();
88
+ return fresh.n > PER_WINDOW;
89
+ }
@@ -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
+ }
@@ -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 }));