fedipod 1.30.0 → 1.36.6

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 (43) hide show
  1. package/README.md +1 -1
  2. package/gateway.md +37 -0
  3. package/gui.md +2 -2
  4. package/lib/connections/bskyfeed.mjs +6 -0
  5. package/lib/core/deliver.mjs +52 -22
  6. package/lib/core/intake/index.mjs +17 -4
  7. package/lib/core/lease.mjs +7 -7
  8. package/lib/core/store.mjs +25 -5
  9. package/lib/gateway/caches.mjs +36 -0
  10. package/lib/gateway/front-core.mjs +59 -66
  11. package/lib/gateway/gateway-core.mjs +20 -8
  12. package/lib/gateway/headers.mjs +49 -0
  13. package/lib/gateway/notices.mjs +84 -0
  14. package/lib/gateway/quiet.mjs +189 -0
  15. package/lib/pod/containers.mjs +5 -1
  16. package/lib/pod/inbox.mjs +22 -3
  17. package/lib/pod/transport.mjs +36 -3
  18. package/lib/session/README.md +14 -0
  19. package/lib/session/fedi-account.mjs +62 -0
  20. package/lib/session/package.json +10 -2
  21. package/package.json +1 -1
  22. package/run-agent.mjs +16 -0
  23. package/scripts/stage-site.mjs +6 -3
  24. package/web/admin/bar.css +44 -10
  25. package/web/admin/client/index.html +29 -2
  26. package/web/admin/gateway.js +19 -0
  27. package/web/admin/index.html +46 -3
  28. package/web/admin/notices-bar.js +90 -0
  29. package/web/admin/record.js +1 -0
  30. package/web/admin/setup/index.html +29 -2
  31. package/web/admin/upkeep.js +5 -1
  32. package/web/app/admin-facade.mjs +24 -0
  33. package/web/app/agent.mjs +59 -6
  34. package/web/app/boot.mjs +2 -0
  35. package/web/app/deliver-relay.mjs +47 -7
  36. package/web/app/dist/boot.js +39 -4
  37. package/web/app/dist/boot.js.map +2 -2
  38. package/web/app/dist/sw.js +273 -38
  39. package/web/app/dist/sw.js.map +3 -3
  40. package/web/app/update.js +4 -0
  41. package/web/front/admin.html +3 -1
  42. package/web/front/notices.html +69 -0
  43. package/web/front/notices.js +107 -0
@@ -21,6 +21,10 @@ import { readCapped, safeFetch, isLoopbackHost } from '../shared/safefetch.mjs';
21
21
  import * as podRoot from '../pod/root.mjs';
22
22
  import * as podPolicy from '../pod/policy.mjs';
23
23
  import { podBaseOfWebId } from '../pod/urls.mjs';
24
+ import { routeQuietApi, noteOpened, noteReceived, closedState, accountState, closedAnswer } from './quiet.mjs';
25
+ import { routeNoticesApi } from './notices.mjs';
26
+ import { withSecurityHeaders } from './headers.mjs';
27
+ import { podTokenVerifier } from './caches.mjs';
24
28
 
25
29
  // The one WebFinger document, spelled out here rather than imported from
26
30
  // wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
@@ -59,7 +63,7 @@ async function verifyPodToken(request, pathname, verifier) {
59
63
  // Solid-OIDC binds the token to a key the client proves on every request;
60
64
  // a token shown without the proof is one anyone who saw it could show.
61
65
  if (!dpop) { console.log(`front: pod token without a DPoP proof refused on ${pathname}`); return null; }
62
- const v = verifier || (await import('@solid/access-token-verifier')).createSolidTokenVerifier();
66
+ const v = verifier || await podTokenVerifier();
63
67
  const url = request.url;
64
68
  const { webid } = await v(authz, { header: dpop, method: request.method, url });
65
69
  return webid || null;
@@ -205,7 +209,9 @@ async function relayOne(item, rec, fetchImpl) {
205
209
 
206
210
  const j = (status, obj, ct = 'application/json') =>
207
211
  ({ status, headers: { 'content-type': ct, 'cache-control': 'no-store' }, body: JSON.stringify(obj) });
208
- const notFound = () => ({ status: 404, headers: { 'content-type': 'text/plain' }, body: 'not found\n' });
212
+ // Held at the edge like any other missing document: a bot's scan, a dead
213
+ // handle, a typo in a WebFinger query each cost one call, not one per asker.
214
+ const notFound = () => ({ status: 404, headers: { 'content-type': 'text/plain', ...publicFor(MISSING_EDGE_SECONDS) }, body: 'not found\n' });
209
215
 
210
216
  // A user's public base on the front, a 1:1 mirror of their pod home.
211
217
  // The directory key is the FULL fediverse address — handle@host — never the
@@ -241,7 +247,9 @@ function parseUserPath(pathname) {
241
247
  const BROWSER_MAX_AGE = 60;
242
248
  const publicFor = (seconds, stale = seconds * 4) => ({
243
249
  'cache-control': `public, max-age=${Math.min(seconds, BROWSER_MAX_AGE)}`,
244
- 'netlify-cdn-cache-control': `public, s-maxage=${seconds}, stale-while-revalidate=${stale}`,
250
+ // `durable`: one copy for every region, and one that a deploy does not
251
+ // empty. Without it each region held its own and every deploy started cold.
252
+ 'netlify-cdn-cache-control': `public, durable, s-maxage=${seconds}, stale-while-revalidate=${stale}`,
245
253
  });
246
254
  // How long the edge holds a public document, and a handle's WebFinger
247
255
  // answer. Every server that has heard of an account asks for its actor and
@@ -250,6 +258,15 @@ const publicFor = (seconds, stale = seconds * 4) => ({
250
258
  // can afford.
251
259
  const PUBLIC_EDGE_SECONDS = 600;
252
260
  const WEBFINGER_EDGE_SECONDS = 3600;
261
+ // A closed or moved address stays gone; a picture's address stays where it is.
262
+ const GONE_EDGE_SECONDS = 3600;
263
+ const MEDIA_EDGE_SECONDS = 86400;
264
+ // A public document the pod would not give (missing, or not public) is asked
265
+ // for again and again by every server that shows the account — a pinned-posts
266
+ // collection an old account never wrote, a forum count nobody published. Held
267
+ // briefly, so the edge absorbs the asking; briefly, so a document published a
268
+ // moment later is not "missing" for long.
269
+ const MISSING_EDGE_SECONDS = 120;
253
270
 
254
271
  // Where a pod owner opts their identity in. An operator may name it, because
255
272
  // the path it takes is one their pod server can no longer serve; the dot says
@@ -276,7 +293,7 @@ const sameOriginRequest = (request, origin) => {
276
293
 
277
294
  // Top-level paths a handle may not take, so a name never shadows a route.
278
295
  const RESERVED = new Set(['u', 'api', 'signup', 'new-account', 'run', 'admin', 'roster', 'gateway',
279
- 'gw', 'well-known', 'inbox', 'outbox', 'actor', 'install']);
296
+ 'gw', 'well-known', 'inbox', 'outbox', 'actor', 'install', 'notices']);
280
297
 
281
298
  // Why a handle is unusable, or null when it is fine. Lowercase letters, digits
282
299
  // and hyphens; 2–30 chars; not edge-hyphenated; not a reserved route.
@@ -332,6 +349,7 @@ function identFor(rec, policy = null) {
332
349
  // listDirectory() -> { handle: record } every row, for the admin roster
333
350
  // removeDirectory(handle) -> boolean drop a row; false when a seeded row remains
334
351
  // podPut(url, body, ct) -> boolean append to a user's pod (per-user cred inside)
352
+ // readReceived / writeReceived / dropReceived, pauseItems, closeDays — see quiet.mjs
335
353
  // podGet(url, { podHome }) -> Response read a user's pod (public reads; plain fetch
336
354
  // is fine). `podHome` is the row's own pod, for
337
355
  // an adapter that reads a store directly and so
@@ -341,51 +359,6 @@ export async function routeFront(request, ctx) {
341
359
  return { ...out, headers: withSecurityHeaders(out.headers, out.body) };
342
360
  }
343
361
 
344
- // Every response this file makes, hardened in one place rather than in each of
345
- // the dozen shapes below.
346
- //
347
- // `nosniff` matters most: the front serves user-supplied JSON straight from
348
- // somebody's pod (the proxied actor and object documents), and without it a
349
- // browser is free to decide for itself that a document is HTML and run what is
350
- // inside it. The rest is the same posture the app already has — nothing may be
351
- // framed, no base tag may be rewritten, no plugin content.
352
- //
353
- // A content-security-policy goes on the HTML only: it would mean nothing on a
354
- // JSON document, and `frame-ancestors` has to be a header rather than a meta
355
- // tag anyway.
356
- //
357
- // `script-src 'self'` is the one that matters, and it is only possible because
358
- // none of these pages carries inline script any more — each has its own file and
359
- // its own route above. A policy cannot tell an inline block the author wrote
360
- // from one an attacker injected, so as long as any inline script has to run,
361
- // every inline script may.
362
- //
363
- // `connect-src` allows https: because the pages sign in against the user's own
364
- // pod, which is a different origin by definition and not one we can name here.
365
- function withSecurityHeaders(headers = {}, body = null) {
366
- const ct = String(headers['content-type'] || '');
367
- const isHtml = ct.startsWith('text/html');
368
- return {
369
- ...headers,
370
- 'x-content-type-options': 'nosniff',
371
- 'referrer-policy': 'same-origin',
372
- 'x-frame-options': 'SAMEORIGIN',
373
- ...(isHtml && body ? {
374
- 'content-security-policy': [
375
- "default-src 'self'",
376
- "script-src 'self'", // no inline script: see above
377
- "style-src 'self' 'unsafe-inline'",
378
- "img-src 'self' https: data:",
379
- "connect-src 'self' https:", // sign-in goes to the user's own pod
380
- "object-src 'none'", // no plugin content, ever
381
- "base-uri 'none'", // no rewriting where relative URLs resolve
382
- "frame-ancestors 'self'", // nobody else may frame these pages
383
- "form-action 'self'", // a form here submits here
384
- ].join('; '),
385
- } : {}),
386
- };
387
- }
388
-
389
362
  async function route(request, ctx) {
390
363
  const url = new URL(request.url);
391
364
  const { pathname } = url;
@@ -409,14 +382,13 @@ async function route(request, ctx) {
409
382
  return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' }, body: ctx.runPage };
410
383
  }
411
384
 
412
- // The roster page: the host reading who has accounts here. The page signs in
413
- // and calls the roster API below; a deploy with no admin supplies no page.
414
- // Off /admin, which is the owner's own record page on every agent.
415
- if (pathname === '/roster') {
416
- if (request.method !== 'GET' && request.method !== 'HEAD') return { status: 405, headers: {}, body: '' };
417
- if (!ctx.adminPage) return notFound();
418
- return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: ctx.adminPage };
419
- }
385
+ // The roster page (/roster, off /admin, which is the owner's own record
386
+ // page on every agent) is served with the notices page, in notices.mjs.
387
+
388
+ // Notices from the operator to every account here, and the page they are
389
+ // written on (notices.mjs).
390
+ const notices = await routeNoticesApi(request, pathname, ctx, { j, verifyPodToken, publicFor, notFound });
391
+ if (notices) return notices;
420
392
 
421
393
  // The roster: every directory row, secrets stripped, for the host's own
422
394
  // eyes. The reader proves themself the way attach proves a pod — a
@@ -432,6 +404,7 @@ async function route(request, ctx) {
432
404
  handle: r.handle, kind: r.kind || 'person', fronted: !r.inboxOnly,
433
405
  podHome: r.podHome, webId: r.webId || null, actorUrl: r.actorUrl,
434
406
  address: r.address || `@${r.handle}@${ctx.host}`,
407
+ openedAt: r.openedAt || null, pausedAt: r.pausedAt || null, closedAt: r.closedAt || null,
435
408
  }))
436
409
  .sort((a, b) => (a.address || a.handle).localeCompare(b.address || b.handle));
437
410
  return j(200, { host: ctx.host, accounts });
@@ -639,6 +612,10 @@ async function route(request, ctx) {
639
612
  return withApiCors(j(200, { ok: true, handle, movedTo, movedAt }));
640
613
  }
641
614
 
615
+ // The owner's say over an account that goes quiet (quiet.mjs).
616
+ const quiet = await routeQuietApi(request, pathname, ctx, { j, verifyPodToken, webidUnderPod, apiPreflight });
617
+ if (quiet) return withApiCors(quiet);
618
+
642
619
  // The relay: the front sends requests a browser has already signed. A page
643
620
  // may not set the Date or Host header, and both are inside an HTTP
644
621
  // signature, so a browser-run agent signs and hands the request here; the
@@ -659,6 +636,8 @@ async function route(request, ctx) {
659
636
  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" })); }
660
637
  const items = Array.isArray(body.requests) ? body.requests : [];
661
638
  if (!items.length) return withApiCors(j(400, { error: 'requests must be a non-empty list' }));
639
+ // Acting through the relay is being here. Hourly at most; see noteOpened.
640
+ await noteOpened(ctx, handle, rec).catch((e) => console.log(`relay @${handle}: stamp not written: ${e?.message || e}`));
662
641
  if (items.length > RELAY_MAX_REQUESTS) return withApiCors(j(400, { error: `at most ${RELAY_MAX_REQUESTS} requests per call` }));
663
642
  const results = await Promise.all(items.map((it) => relayOne(it, rec, ctx.fetchImpl || fetch)));
664
643
  // One line per relayed request, so a lookup that fails on the far side is
@@ -767,7 +746,7 @@ async function route(request, ctx) {
767
746
  //
768
747
  // The name is matched against a literal list, so nothing about the request
769
748
  // chooses a file.
770
- const mPageScript = /^\/(new-account|run|admin)\.js$/u.exec(pathname);
749
+ const mPageScript = /^\/(new-account|run|admin|notices)\.js$/u.exec(pathname);
771
750
  if (mPageScript) {
772
751
  const body = ctx.pageScripts?.[`${mPageScript[1]}.js`];
773
752
  if (!body) return notFound();
@@ -797,6 +776,9 @@ async function route(request, ctx) {
797
776
  if (!m || m[2] !== ctx.host) return notFound();
798
777
  const rec = await ctx.lookup(m[1]);
799
778
  if (!rec) return notFound();
779
+ // A closed address is gone, and says so rather than pretending never to
780
+ // have existed: the name stays taken.
781
+ if ((await closedState(ctx, m[1], rec)).closed) return closedAnswer({ 'access-control-allow-origin': '*', ...publicFor(GONE_EDGE_SECONDS) });
800
782
  // A fronted identity's documents live on its pod; the pod's own actor id
801
783
  // is the alias, so a client signing in by the fronted address can find
802
784
  // the pod (and its login) without a lookup only the host could answer.
@@ -837,19 +819,25 @@ async function route(request, ctx) {
837
819
  // An account that moved to another gateway (see /api/move): where its ids
838
820
  // live now. The actor below answers as a stub; everything else redirects.
839
821
  const movedBase = rec.movedTo ? rec.movedTo.replace(/ap\/actor$/u, '') : null;
840
- const gone = (headers = {}) => ({ status: 410, headers: { ...headers, 'content-type': 'application/json', 'cache-control': 'no-store' },
841
- body: JSON.stringify({ error: `this account has moved to ${rec.movedTo}` }) });
822
+ // A closed address (see "accounts that go quiet" above): every id under it
823
+ // is gone. Asked after moved, which a close does not undo.
824
+ const closed = movedBase ? false : (await closedState(ctx, up.handle, rec)).closed;
825
+ const gone = (headers = {}, why = `this account has moved to ${rec.movedTo}`) => closedAnswer(headers, why);
826
+ const CLOSED = 'this address is closed';
842
827
 
843
828
  // Inbox: verify at the door, forward to the user's pod inbox. This is the
844
829
  // gateway, per user.
845
830
  if (up.rest === 'ap/inbox/' || up.rest === 'ap/inbox') {
846
831
  if (request.method !== 'POST') return { status: 405, headers: {}, body: '' };
847
832
  if (movedBase) { console.log(`door @${up.handle}: delivery → 410 (moved)`); return gone(); }
833
+ if (closed) { console.log(`door @${up.handle}: delivery → 410 (closed)`); return gone({}, CLOSED); }
848
834
  const policy = await policyFor(rec, ctx.fetchImpl || fetch);
849
- const { status, reason } = await handleDelivery(request, identFor(rec, policy),
850
- { podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), fetchImpl: ctx.fetchImpl });
835
+ const standing = await accountState(ctx, up.handle, rec);
836
+ const { status, reason, content, bytes } = await handleDelivery(request, identFor(rec, policy),
837
+ { podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), fetchImpl: ctx.fetchImpl, paused: standing.paused });
851
838
  // One line per delivery, so "did it arrive at the door" has an answer.
852
839
  console.log(`door @${up.handle}: delivery → ${status} (${reason})`);
840
+ if (status === 202 && content) await noteReceived(ctx, up.handle, rec, bytes);
853
841
  return { status, headers: {}, body: '' };
854
842
  }
855
843
 
@@ -876,6 +864,7 @@ async function route(request, ctx) {
876
864
  'accept-post': 'application/ld+json, application/activity+json' }, body: null };
877
865
  }
878
866
  if (movedBase && request.method === 'POST') return gone(cors);
867
+ if (closed && request.method === 'POST') return gone(cors, CLOSED);
879
868
  if (movedBase && (request.method === 'GET' || request.method === 'HEAD')) {
880
869
  return { status: 301, headers: { ...cors, location: movedBase + up.rest, 'cache-control': 'no-store' }, body: '' };
881
870
  }
@@ -890,8 +879,8 @@ async function route(request, ctx) {
890
879
  const { status, reason, location } = await handleOwnerPost(request, identFor(rec),
891
880
  { podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), ownerWebId: webid });
892
881
  console.log(`door @${up.handle}: owner post → ${status} (${reason})`);
893
- if (status !== 202) return json(status, { error: reason });
894
- return json(202, { accepted: true, ...(location ? { object: location } : {}),
882
+ if (status !== 201) return json(status, { error: reason });
883
+ return json(201, { accepted: true, ...(location ? { object: location } : {}),
895
884
  note: 'it goes out when your FediPod agent next runs' }, location ? { location } : {});
896
885
  }
897
886
  if (rec.inboxOnly && (request.method === 'GET' || request.method === 'HEAD')) {
@@ -916,13 +905,14 @@ async function route(request, ctx) {
916
905
  if (movedBase && up.rest !== 'ap/actor') {
917
906
  return { status: 301, headers: { ...open, location: movedBase + up.rest, 'cache-control': 'no-store' }, body: '' };
918
907
  }
908
+ if (closed) return gone({ ...open, ...publicFor(GONE_EDGE_SECONDS) }, CLOSED);
919
909
  const podTarget = rec.podHome + up.rest;
920
910
  // Media stays on the pod (lib/pod/urls.mjs keeps `media` off the front), but
921
911
  // the id rewrite below turns media links onto the front like every other
922
912
  // pod url in a document. Answer those by pointing at the pod: bytes are not
923
913
  // a document to cap and relabel, and remotes follow a redirect for a picture.
924
914
  if (up.rest.startsWith('ap/media/')) {
925
- return { status: 302, headers: { location: podTarget, 'cache-control': 'no-store' }, body: '' };
915
+ return { status: 302, headers: { location: podTarget, ...publicFor(MEDIA_EDGE_SECONDS) }, body: '' };
926
916
  }
927
917
  // The pod this read belongs to travels with it: an adapter reading a store
928
918
  // directly (the CSS server component) has no access control of its own and
@@ -938,7 +928,10 @@ async function route(request, ctx) {
938
928
  const got = await podRoot.readPublicDocument(
939
929
  ctx.podGet || ((u) => fetch(u, { headers: { accept } })),
940
930
  podTarget, { podHome: rec.podHome, accept });
941
- if (got.text === null) return { status: got.status, headers: open, body: '' };
931
+ 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: '' };
934
+ }
942
935
  let text = got.text;
943
936
  text = swap(text, rec.podHome, base);
944
937
  // The moved stub: the pod's actor now carries the NEW gateway's ids, and a
@@ -15,13 +15,16 @@
15
15
  import crypto from 'node:crypto';
16
16
  import { verifyHttpSignature, makeSafeLoader, makeReceipt, signReceipt } from './httpsig.mjs';
17
17
  import * as inbox from '../pod/inbox.mjs';
18
+ import { senderKeys } from './caches.mjs';
18
19
 
19
20
  const DEFAULT_MAX_BYTES = 512 * 1024; // mirror intake.mjs MAX_ITEM_BYTES
20
21
 
21
22
  // Control activities are the message itself and must always pass — you want
22
- // Follows from strangers. Only CONTENT is subject to the concerns-us drop.
23
- const CONTROL = new Set(['Follow', 'Undo', 'Accept', 'Reject', 'Delete', 'Move',
23
+ // Follows from strangers. Only CONTENT is subject to the concerns-us drop,
24
+ // and to a pause: an account nobody is reading still takes its follows.
25
+ export const CONTROL = new Set(['Follow', 'Undo', 'Accept', 'Reject', 'Delete', 'Move',
24
26
  'Add', 'Remove', 'Block']);
27
+ export const isControl = (type) => CONTROL.has(type);
25
28
 
26
29
  const idOf = (v) => (typeof v === 'string' ? v : v?.id) || null;
27
30
  const sha256hex = (s) => crypto.createHash('sha256').update(s).digest('hex');
@@ -64,8 +67,13 @@ function concernsUsAtEdge(activity, ident) {
64
67
  // resolved identity policy: { inboxUrl, actorUrl, followersUrl, notesPrefix,
65
68
  // following, blocklist, kind, gatewayWebId, hmacSecret }. `podPut(url, body,
66
69
  // contentType) → boolean` appends to the pod with the gateway's credential.
67
- // Returns { status, reason } — the adapter turns it into an HTTP response.
68
- export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch, maxBytes = DEFAULT_MAX_BYTES } = {}) {
70
+ // `paused` says nobody is reading this inbox: content is accepted and
71
+ // discarded (the same quiet 202 a blocked sender gets, so nothing retries
72
+ // and nothing counts a failure against this host), control still lands.
73
+ // Returns { status, reason } — the adapter turns it into an HTTP response —
74
+ // and, for a delivery that reached the pod, `content` (whether it was
75
+ // content rather than control) and `bytes`, so the caller can keep count.
76
+ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch, maxBytes = DEFAULT_MAX_BYTES, paused = false } = {}) {
69
77
  // Read the body once from a clone; the original, unconsumed, goes to the
70
78
  // verifier (which needs the body for the Digest check).
71
79
  let raw;
@@ -80,12 +88,16 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
80
88
  // Edge drops — silent 202 so a rejected sender does not retry a delivery we
81
89
  // will never accept. None of these becomes a pod write.
82
90
  if (isBlocked(actor, ident.blocklist)) return { status: 202, reason: 'blocked' };
83
- if (!CONTROL.has(activity.type) && !concernsUsAtEdge(activity, ident)) {
91
+ const content = !CONTROL.has(activity.type);
92
+ if (content && paused) return { status: 202, reason: 'paused' };
93
+ if (content && !concernsUsAtEdge(activity, ident)) {
84
94
  return { status: 202, reason: 'does not concern us' };
85
95
  }
86
96
 
97
+ // The sender's key, kept between deliveries: a server pushing a hundred
98
+ // items is asked for its key once (caches.mjs).
87
99
  const v = await verifyHttpSignature(request, {
88
- documentLoader: makeSafeLoader({ fetchImpl }),
100
+ documentLoader: makeSafeLoader({ fetchImpl }), keyCache: senderKeys,
89
101
  });
90
102
  // A present-but-invalid signature is a forgery — dropped here, so it never
91
103
  // reaches the pod (today it would, drain, and die unapplied). An absent or
@@ -107,7 +119,7 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
107
119
  // holding any state.
108
120
  if (!okA) return { status: 502, reason: 'pod inbox write failed' };
109
121
  if (receipt) await inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt);
110
- return { status: 202, reason: v.verified ? 'verified' : 'buffered-unverified' };
122
+ return { status: 202, reason: v.verified ? 'verified' : 'buffered-unverified', content, bytes: Buffer.byteLength(raw) };
111
123
  }
112
124
 
113
125
  // The outbox door: the owner's own post, taken on their behalf.
@@ -147,7 +159,7 @@ export async function handleOwnerPost(request, ident, { podPut, ownerWebId, maxB
147
159
  const okA = await inbox.appendVerifiedDelivery(podPut, ident.inboxUrl, hash, raw);
148
160
  if (!okA) return { status: 502, reason: 'pod inbox write failed' };
149
161
  await inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt);
150
- return { status: 202, reason: 'accepted', location: slug && ident.notesPrefix ? ident.notesPrefix + slug : null };
162
+ return { status: 201, reason: 'accepted', location: slug && ident.notesPrefix ? ident.notesPrefix + slug : null };
151
163
  }
152
164
 
153
165
  export const _internal = { isBlocked, concernsUsAtEdge, httpUrl, sha256hex };
@@ -0,0 +1,49 @@
1
+ // headers.mjs — the hardening every front response carries, in one place.
2
+ // Split out of front-core.mjs, which is at its size gate; nothing here knows
3
+ // about routes.
4
+
5
+ // Every response this file makes, hardened in one place rather than in each of
6
+ // the dozen shapes below.
7
+ //
8
+ // `nosniff` matters most: the front serves user-supplied JSON straight from
9
+ // somebody's pod (the proxied actor and object documents), and without it a
10
+ // browser is free to decide for itself that a document is HTML and run what is
11
+ // inside it. The rest is the same posture the app already has — nothing may be
12
+ // framed, no base tag may be rewritten, no plugin content.
13
+ //
14
+ // A content-security-policy goes on the HTML only: it would mean nothing on a
15
+ // JSON document, and `frame-ancestors` has to be a header rather than a meta
16
+ // tag anyway.
17
+ //
18
+ // `script-src 'self'` is the one that matters, and it is only possible because
19
+ // none of these pages carries inline script any more — each has its own file and
20
+ // its own route above. A policy cannot tell an inline block the author wrote
21
+ // from one an attacker injected, so as long as any inline script has to run,
22
+ // every inline script may.
23
+ //
24
+ // `connect-src` allows https: because the pages sign in against the user's own
25
+ // pod, which is a different origin by definition and not one we can name here.
26
+ export function withSecurityHeaders(headers = {}, body = null) {
27
+ const ct = String(headers['content-type'] || '');
28
+ const isHtml = ct.startsWith('text/html');
29
+ return {
30
+ ...headers,
31
+ 'x-content-type-options': 'nosniff',
32
+ 'referrer-policy': 'same-origin',
33
+ 'x-frame-options': 'SAMEORIGIN',
34
+ ...(isHtml && body ? {
35
+ 'content-security-policy': [
36
+ "default-src 'self'",
37
+ "script-src 'self'", // no inline script: see above
38
+ "style-src 'self' 'unsafe-inline'",
39
+ "img-src 'self' https: data:",
40
+ "connect-src 'self' https:", // sign-in goes to the user's own pod
41
+ "object-src 'none'", // no plugin content, ever
42
+ "base-uri 'none'", // no rewriting where relative URLs resolve
43
+ "frame-ancestors 'self'", // nobody else may frame these pages
44
+ "form-action 'self'", // a form here submits here
45
+ ].join('; '),
46
+ } : {}),
47
+ };
48
+ }
49
+
@@ -0,0 +1,84 @@
1
+ // notices.mjs — notices from whoever runs the front, to every account here.
2
+ //
3
+ // The operator writes them on the /notices page, proved as the admin the way
4
+ // the roster is; every page of ours asks GET /api/notices once per load and
5
+ // shows a bell when there are any. A notice is a title, a plain-text body and
6
+ // a time. The body is never markup: the pages render it as text.
7
+ //
8
+ // Kept beside the directory, in a store the adapter supplies:
9
+ // listNotices() -> { id: notice } putNotice(id, notice) deleteNotice(id)
10
+ // A front that supplies none has no notices to offer, and says so.
11
+
12
+ import crypto from 'node:crypto';
13
+
14
+ const TITLE_MAX = 120;
15
+ const BODY_MAX = 4000;
16
+ const NOTICES_EDGE_SECONDS = 60;
17
+
18
+ const clean = (v, max) => String(v ?? '').replace(/\r\n?/gu, '\n').trim().slice(0, max);
19
+ const newest = (a, b) => String(b.at || '').localeCompare(String(a.at || ''));
20
+
21
+ export async function listNotices(ctx) {
22
+ const all = ctx.listNotices ? await ctx.listNotices() : {};
23
+ return Object.values(all || {}).filter(Boolean).sort(newest);
24
+ }
25
+
26
+ // GET is public: what every account is shown. POST is the admin's alone. The
27
+ // /notices page is where the admin writes them; it signs in like the roster.
28
+ // Returns a response, or null when the path is neither.
29
+ export async function routeNoticesApi(request, pathname, ctx, { j, verifyPodToken, publicFor, notFound }) {
30
+ // The two admin pages: the roster (who has accounts here) and the notices.
31
+ // Each signs in and calls its API; a deploy with no admin supplies no page.
32
+ const page = pathname === '/notices' ? ctx.noticesPage : pathname === '/roster' ? ctx.adminPage : undefined;
33
+ if (page !== undefined) {
34
+ if (request.method !== 'GET' && request.method !== 'HEAD') return { status: 405, headers: {}, body: '' };
35
+ if (!page) return notFound();
36
+ return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: page };
37
+ }
38
+ if (pathname !== '/api/notices') return null;
39
+ const open = { 'access-control-allow-origin': '*' };
40
+ if (request.method === 'OPTIONS') {
41
+ return { status: 204, headers: { ...open, 'access-control-allow-methods': 'GET, OPTIONS',
42
+ 'access-control-allow-headers': 'Accept', allow: 'GET, POST, OPTIONS' }, body: null };
43
+ }
44
+ if (request.method === 'GET') {
45
+ if (!ctx.listNotices) return j(501, { error: 'this front keeps no notices' });
46
+ const out = j(200, { notices: await listNotices(ctx) });
47
+ Object.assign(out.headers, open, publicFor(NOTICES_EDGE_SECONDS));
48
+ return out;
49
+ }
50
+ if (request.method !== 'POST') return { status: 405, headers: {}, body: '' };
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);
53
+ if (!webid) return j(401, { error: 'a Solid-OIDC token is required' });
54
+ if (webid !== ctx.adminWebId) return j(403, { error: 'that WebID is not the admin of this front' });
55
+ let body;
56
+ try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
57
+ const action = String(body.action || '');
58
+ const all = (ctx.listNotices ? await ctx.listNotices() : {}) || {};
59
+ if (action === 'delete') {
60
+ const id = String(body.id || '');
61
+ if (!all[id]) return j(404, { error: 'no such notice' });
62
+ await ctx.deleteNotice(id);
63
+ console.log(`front: notice ${id} deleted by the admin`);
64
+ return j(200, { ok: true, id, deleted: true });
65
+ }
66
+ if (action !== 'create' && action !== 'update') return j(400, { error: 'action must be create, update or delete' });
67
+ const title = clean(body.title, TITLE_MAX);
68
+ const text = clean(body.body, BODY_MAX);
69
+ if (!title) return j(400, { error: 'a title is required' });
70
+ if (!text) return j(400, { error: 'a body is required' });
71
+ const now = new Date().toISOString();
72
+ let notice;
73
+ if (action === 'create') {
74
+ const id = `${Date.now().toString(36)}-${crypto.randomBytes(4).toString('hex')}`;
75
+ notice = { id, title, body: text, at: now, updatedAt: now };
76
+ } else {
77
+ const prior = all[String(body.id || '')];
78
+ if (!prior) return j(404, { error: 'no such notice' });
79
+ notice = { ...prior, title, body: text, updatedAt: now };
80
+ }
81
+ await ctx.putNotice(notice.id, notice);
82
+ console.log(`front: notice ${notice.id} ${action}d by the admin`);
83
+ return j(action === 'create' ? 201 : 200, { ok: true, notice });
84
+ }