fedipod 1.30.0 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -72,7 +72,7 @@ client-to-server, dokieli for one, can post as you. It sends to the outbox
72
72
  address in your actor document, which your WebID profile also names, signed in
73
73
  at your pod. The post goes out the next time you open fedipod.net.
74
74
 
75
- **The manage page.** `manage account` in the bar opens it: your profile,
75
+ **The manage page.** `manage`, in the bar's account group, opens it: your profile,
76
76
  aliases, the gateway, key rotation, recovering posts, parking, moving to
77
77
  another server, retiring, and clearing a backlog. It is the same interface
78
78
  the DeviceAgent has, described in [the admin interface](gui.md).
package/gateway.md CHANGED
@@ -153,6 +153,43 @@ on the setup page). Setup reads the account on the pod, keeps its state and
153
153
  key there, attaches at the new gateway, and completes the move when the
154
154
  agent first acts. See [the DeviceAgent](device-agent.md).
155
155
 
156
+ ## Accounts that go quiet
157
+
158
+ A gateway holds no mail. Every delivery it accepts is written into your pod
159
+ inbox, and your agent reads it from there. A BrowserAgent reads only while
160
+ its page is open, so an account nobody opens grows on its pod without limit
161
+ and comes back to a drain of everything at once. fedipod.net keeps two facts
162
+ about each browser account — when its owner last signed in or posted, and
163
+ how much content has arrived since — and acts on them.
164
+
165
+ **Paused.** After about 5,000 posts, replies, likes, boosts and edits since
166
+ you were last here, or when you say so on the manage page, the door accepts
167
+ content and discards it. Follows, unfollows, account moves, deletions and
168
+ blocks still reach your pod. Signing in ends an automatic pause by itself; a
169
+ pause you set lasts until you lift it.
170
+
171
+ **Closed.** After six months without a sign-in, or when you say so on the
172
+ manage page, the address is closed for good: its handle, its actor and its
173
+ door answer 410 Gone, other servers drop the account the next time they look,
174
+ and nobody can take the name. Nothing on your pod is touched. An address that
175
+ had already moved to another gateway keeps answering as moved.
176
+
177
+ Only accounts opened from a browser are counted. A DeviceAgent behind the
178
+ gateway drains its own inbox as it runs, and is never paused or closed by
179
+ time. Accounts from before this was built are counted from their next
180
+ sign-in. A gateway operator sets the cap and the window with
181
+ `FEDIPOD_PAUSE_ITEMS` and `FEDIPOD_CLOSE_DAYS`.
182
+
183
+ ## Notices
184
+
185
+ Whoever runs the gateway can write notices to everyone with an account
186
+ there. A bell at the right end of the bar, on the record page and in the
187
+ client, shows how many this browser has not opened yet; the list gives the
188
+ titles and each one opens on its own. The operator writes, changes and
189
+ removes them at `/notices`, signed in as the admin the way the roster is.
190
+ A notice is a title and plain text: a blank line starts a paragraph, and a
191
+ web address becomes a link.
192
+
156
193
  ## What the gateway can see
157
194
 
158
195
  It reads only public data to decide what concerns you: your published
package/gui.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # The Admin interface
2
2
 
3
- In the browser version at fedipod.net, `manage account` in the bar opens this
3
+ In the browser version at fedipod.net, `manage`, in the bar's account group, opens this
4
4
  same page for your account. The rest of this page describes it as the
5
5
  DeviceAgent serves it; the controls are the same, minus the manual inbox
6
6
  drain and the local log, which a browser does not have.
7
7
 
8
- Open `https://localhost:8030/` while any agent is running — it forwards you to the agent — then choose `manage account` and select the actor you want from the local actors dropdown.
8
+ Open `https://localhost:8030/` while any agent is running — it forwards you to the agent — then choose `manage` in the bar's account group and select the actor you want from the local actors dropdown.
9
9
  Picking an actor marked "(stopped)" starts its agent, then opens its page.
10
10
 
11
11
  The software row names the version the agent is running. When the copy on the machine is further ahead — after an update, or after pulling a checkout — it says so and asks for a restart, because an agent goes on serving the code it started with until it is restarted.
@@ -21,6 +21,8 @@ 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';
24
26
 
25
27
  // The one WebFinger document, spelled out here rather than imported from
26
28
  // wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
@@ -276,7 +278,7 @@ const sameOriginRequest = (request, origin) => {
276
278
 
277
279
  // Top-level paths a handle may not take, so a name never shadows a route.
278
280
  const RESERVED = new Set(['u', 'api', 'signup', 'new-account', 'run', 'admin', 'roster', 'gateway',
279
- 'gw', 'well-known', 'inbox', 'outbox', 'actor', 'install']);
281
+ 'gw', 'well-known', 'inbox', 'outbox', 'actor', 'install', 'notices']);
280
282
 
281
283
  // Why a handle is unusable, or null when it is fine. Lowercase letters, digits
282
284
  // and hyphens; 2–30 chars; not edge-hyphenated; not a reserved route.
@@ -332,6 +334,7 @@ function identFor(rec, policy = null) {
332
334
  // listDirectory() -> { handle: record } every row, for the admin roster
333
335
  // removeDirectory(handle) -> boolean drop a row; false when a seeded row remains
334
336
  // podPut(url, body, ct) -> boolean append to a user's pod (per-user cred inside)
337
+ // readReceived / writeReceived / dropReceived, pauseItems, closeDays — see quiet.mjs
335
338
  // podGet(url, { podHome }) -> Response read a user's pod (public reads; plain fetch
336
339
  // is fine). `podHome` is the row's own pod, for
337
340
  // an adapter that reads a store directly and so
@@ -409,14 +412,13 @@ async function route(request, ctx) {
409
412
  return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' }, body: ctx.runPage };
410
413
  }
411
414
 
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
- }
415
+ // The roster page (/roster, off /admin, which is the owner's own record
416
+ // page on every agent) is served with the notices page, in notices.mjs.
417
+
418
+ // Notices from the operator to every account here, and the page they are
419
+ // written on (notices.mjs).
420
+ const notices = await routeNoticesApi(request, pathname, ctx, { j, verifyPodToken, publicFor, notFound });
421
+ if (notices) return notices;
420
422
 
421
423
  // The roster: every directory row, secrets stripped, for the host's own
422
424
  // eyes. The reader proves themself the way attach proves a pod — a
@@ -432,6 +434,7 @@ async function route(request, ctx) {
432
434
  handle: r.handle, kind: r.kind || 'person', fronted: !r.inboxOnly,
433
435
  podHome: r.podHome, webId: r.webId || null, actorUrl: r.actorUrl,
434
436
  address: r.address || `@${r.handle}@${ctx.host}`,
437
+ openedAt: r.openedAt || null, pausedAt: r.pausedAt || null, closedAt: r.closedAt || null,
435
438
  }))
436
439
  .sort((a, b) => (a.address || a.handle).localeCompare(b.address || b.handle));
437
440
  return j(200, { host: ctx.host, accounts });
@@ -639,6 +642,10 @@ async function route(request, ctx) {
639
642
  return withApiCors(j(200, { ok: true, handle, movedTo, movedAt }));
640
643
  }
641
644
 
645
+ // The owner's say over an account that goes quiet (quiet.mjs).
646
+ const quiet = await routeQuietApi(request, pathname, ctx, { j, verifyPodToken, webidUnderPod });
647
+ if (quiet) return quiet;
648
+
642
649
  // The relay: the front sends requests a browser has already signed. A page
643
650
  // may not set the Date or Host header, and both are inside an HTTP
644
651
  // signature, so a browser-run agent signs and hands the request here; the
@@ -659,6 +666,8 @@ async function route(request, ctx) {
659
666
  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
667
  const items = Array.isArray(body.requests) ? body.requests : [];
661
668
  if (!items.length) return withApiCors(j(400, { error: 'requests must be a non-empty list' }));
669
+ // Acting through the relay is being here. Hourly at most; see noteOpened.
670
+ await noteOpened(ctx, handle, rec).catch((e) => console.log(`relay @${handle}: stamp not written: ${e?.message || e}`));
662
671
  if (items.length > RELAY_MAX_REQUESTS) return withApiCors(j(400, { error: `at most ${RELAY_MAX_REQUESTS} requests per call` }));
663
672
  const results = await Promise.all(items.map((it) => relayOne(it, rec, ctx.fetchImpl || fetch)));
664
673
  // One line per relayed request, so a lookup that fails on the far side is
@@ -767,7 +776,7 @@ async function route(request, ctx) {
767
776
  //
768
777
  // The name is matched against a literal list, so nothing about the request
769
778
  // chooses a file.
770
- const mPageScript = /^\/(new-account|run|admin)\.js$/u.exec(pathname);
779
+ const mPageScript = /^\/(new-account|run|admin|notices)\.js$/u.exec(pathname);
771
780
  if (mPageScript) {
772
781
  const body = ctx.pageScripts?.[`${mPageScript[1]}.js`];
773
782
  if (!body) return notFound();
@@ -797,6 +806,9 @@ async function route(request, ctx) {
797
806
  if (!m || m[2] !== ctx.host) return notFound();
798
807
  const rec = await ctx.lookup(m[1]);
799
808
  if (!rec) return notFound();
809
+ // A closed address is gone, and says so rather than pretending never to
810
+ // have existed: the name stays taken.
811
+ if ((await closedState(ctx, m[1], rec)).closed) return closedAnswer({ 'access-control-allow-origin': '*' });
800
812
  // A fronted identity's documents live on its pod; the pod's own actor id
801
813
  // is the alias, so a client signing in by the fronted address can find
802
814
  // the pod (and its login) without a lookup only the host could answer.
@@ -837,19 +849,25 @@ async function route(request, ctx) {
837
849
  // An account that moved to another gateway (see /api/move): where its ids
838
850
  // live now. The actor below answers as a stub; everything else redirects.
839
851
  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}` }) });
852
+ // A closed address (see "accounts that go quiet" above): every id under it
853
+ // is gone. Asked after moved, which a close does not undo.
854
+ const closed = movedBase ? false : (await closedState(ctx, up.handle, rec)).closed;
855
+ const gone = (headers = {}, why = `this account has moved to ${rec.movedTo}`) => closedAnswer(headers, why);
856
+ const CLOSED = 'this address is closed';
842
857
 
843
858
  // Inbox: verify at the door, forward to the user's pod inbox. This is the
844
859
  // gateway, per user.
845
860
  if (up.rest === 'ap/inbox/' || up.rest === 'ap/inbox') {
846
861
  if (request.method !== 'POST') return { status: 405, headers: {}, body: '' };
847
862
  if (movedBase) { console.log(`door @${up.handle}: delivery → 410 (moved)`); return gone(); }
863
+ if (closed) { console.log(`door @${up.handle}: delivery → 410 (closed)`); return gone({}, CLOSED); }
848
864
  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 });
865
+ const standing = await accountState(ctx, up.handle, rec);
866
+ const { status, reason, content, bytes } = await handleDelivery(request, identFor(rec, policy),
867
+ { podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), fetchImpl: ctx.fetchImpl, paused: standing.paused });
851
868
  // One line per delivery, so "did it arrive at the door" has an answer.
852
869
  console.log(`door @${up.handle}: delivery → ${status} (${reason})`);
870
+ if (status === 202 && content) await noteReceived(ctx, up.handle, rec, bytes);
853
871
  return { status, headers: {}, body: '' };
854
872
  }
855
873
 
@@ -876,6 +894,7 @@ async function route(request, ctx) {
876
894
  'accept-post': 'application/ld+json, application/activity+json' }, body: null };
877
895
  }
878
896
  if (movedBase && request.method === 'POST') return gone(cors);
897
+ if (closed && request.method === 'POST') return gone(cors, CLOSED);
879
898
  if (movedBase && (request.method === 'GET' || request.method === 'HEAD')) {
880
899
  return { status: 301, headers: { ...cors, location: movedBase + up.rest, 'cache-control': 'no-store' }, body: '' };
881
900
  }
@@ -916,6 +935,7 @@ async function route(request, ctx) {
916
935
  if (movedBase && up.rest !== 'ap/actor') {
917
936
  return { status: 301, headers: { ...open, location: movedBase + up.rest, 'cache-control': 'no-store' }, body: '' };
918
937
  }
938
+ if (closed) return gone(open, CLOSED);
919
939
  const podTarget = rec.podHome + up.rest;
920
940
  // Media stays on the pod (lib/pod/urls.mjs keeps `media` off the front), but
921
941
  // the id rewrite below turns media links onto the front like every other
@@ -19,9 +19,11 @@ import * as inbox from '../pod/inbox.mjs';
19
19
  const DEFAULT_MAX_BYTES = 512 * 1024; // mirror intake.mjs MAX_ITEM_BYTES
20
20
 
21
21
  // 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',
22
+ // Follows from strangers. Only CONTENT is subject to the concerns-us drop,
23
+ // and to a pause: an account nobody is reading still takes its follows.
24
+ export const CONTROL = new Set(['Follow', 'Undo', 'Accept', 'Reject', 'Delete', 'Move',
24
25
  'Add', 'Remove', 'Block']);
26
+ export const isControl = (type) => CONTROL.has(type);
25
27
 
26
28
  const idOf = (v) => (typeof v === 'string' ? v : v?.id) || null;
27
29
  const sha256hex = (s) => crypto.createHash('sha256').update(s).digest('hex');
@@ -64,8 +66,13 @@ function concernsUsAtEdge(activity, ident) {
64
66
  // resolved identity policy: { inboxUrl, actorUrl, followersUrl, notesPrefix,
65
67
  // following, blocklist, kind, gatewayWebId, hmacSecret }. `podPut(url, body,
66
68
  // 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 } = {}) {
69
+ // `paused` says nobody is reading this inbox: content is accepted and
70
+ // discarded (the same quiet 202 a blocked sender gets, so nothing retries
71
+ // and nothing counts a failure against this host), control still lands.
72
+ // Returns { status, reason } — the adapter turns it into an HTTP response —
73
+ // and, for a delivery that reached the pod, `content` (whether it was
74
+ // content rather than control) and `bytes`, so the caller can keep count.
75
+ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch, maxBytes = DEFAULT_MAX_BYTES, paused = false } = {}) {
69
76
  // Read the body once from a clone; the original, unconsumed, goes to the
70
77
  // verifier (which needs the body for the Digest check).
71
78
  let raw;
@@ -80,7 +87,9 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
80
87
  // Edge drops — silent 202 so a rejected sender does not retry a delivery we
81
88
  // will never accept. None of these becomes a pod write.
82
89
  if (isBlocked(actor, ident.blocklist)) return { status: 202, reason: 'blocked' };
83
- if (!CONTROL.has(activity.type) && !concernsUsAtEdge(activity, ident)) {
90
+ const content = !CONTROL.has(activity.type);
91
+ if (content && paused) return { status: 202, reason: 'paused' };
92
+ if (content && !concernsUsAtEdge(activity, ident)) {
84
93
  return { status: 202, reason: 'does not concern us' };
85
94
  }
86
95
 
@@ -107,7 +116,7 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
107
116
  // holding any state.
108
117
  if (!okA) return { status: 502, reason: 'pod inbox write failed' };
109
118
  if (receipt) await inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt);
110
- return { status: 202, reason: v.verified ? 'verified' : 'buffered-unverified' };
119
+ return { status: 202, reason: v.verified ? 'verified' : 'buffered-unverified', content, bytes: Buffer.byteLength(raw) };
111
120
  }
112
121
 
113
122
  // The outbox door: the owner's own post, taken on their behalf.
@@ -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
+ }
@@ -0,0 +1,184 @@
1
+ // quiet.mjs — accounts that go quiet: what the front knows about an account
2
+ // nobody opens, and what its owner may say about it. The router in
3
+ // front-core.mjs asks closedState/accountState at the door and on every
4
+ // public read, stamps noteOpened from the relay, counts noteReceived after
5
+ // a forwarded delivery, and hands /api/open, /api/pause and /api/close here.
6
+
7
+ // ---- accounts that go quiet -------------------------------------------------
8
+ //
9
+ // The gateway holds no mail: every delivery it takes is two writes into the
10
+ // owner's pod inbox, read only when their browser is open. An account nobody
11
+ // opens grows on its pod without limit, and its owner comes back to a drain
12
+ // of everything at once. So the front keeps two facts about each browser
13
+ // account — when its owner was last here, and how much content has arrived
14
+ // since — and acts on them.
15
+ //
16
+ // `openedAt` is written when the owner signs in (POST /api/open, always) and
17
+ // while they act through the relay (at most hourly). The count of content
18
+ // deliveries since then lives in its own record, keyed by handle AND
19
+ // openedAt: a fresh stamp starts a fresh count with nothing to reset, and a
20
+ // delivery's write can never overwrite a sign-in's. Two deliveries landing
21
+ // together may each read the same total and one increment is lost — the cap
22
+ // comes out slightly soft, never too strict.
23
+ //
24
+ // PAUSED: the count has reached the cap, or the owner said so. Content is
25
+ // accepted and discarded, control (follows, unfollows, moves, deletions,
26
+ // blocks) still lands. The owner's next sign-in ends a cap pause by itself; a
27
+ // pause they set lasts until they lift it.
28
+ //
29
+ // CLOSED: the owner said so, or nothing has opened the account for the close
30
+ // window. Found closed by time, it is written down so it stays closed. From
31
+ // then on the door, the actor and the handle answer 410 — never a failure
32
+ // code, which senders retry and count against this whole host, and never a
33
+ // success, which would keep them sending. Nothing on the pod is touched. An
34
+ // address that moved (see /api/move) keeps answering as moved; closing does
35
+ // not take that away.
36
+ //
37
+ // Only an account whose owner has signed in from a browser carries
38
+ // `openedAt`. A DeviceAgent behind this door never does: it drains its own
39
+ // inbox as it runs, so it is never counted, never paused and never closed by
40
+ // time. Accounts from before this was built are counted from their next
41
+ // sign-in.
42
+ export const DEFAULT_PAUSE_ITEMS = 5000;
43
+ export const DEFAULT_CLOSE_DAYS = 183; // six months
44
+ const OPEN_STAMP_EVERY_MS = 60 * 60_000; // the relay's stamp, at most hourly
45
+ const pauseItemsOf = (ctx) => (Number(ctx.pauseItems) > 0 ? Number(ctx.pauseItems) : DEFAULT_PAUSE_ITEMS);
46
+ const closeDaysOf = (ctx) => (Number(ctx.closeDays) > 0 ? Number(ctx.closeDays) : DEFAULT_CLOSE_DAYS);
47
+ const receivedKey = (key, rec) => `${key}/${rec.openedAt}`;
48
+
49
+ export async function receivedSince(ctx, key, rec) {
50
+ if (!ctx.readReceived || !rec.openedAt) return { items: 0, bytes: 0 };
51
+ const got = await ctx.readReceived(receivedKey(key, rec)).catch(() => null);
52
+ return { items: Number(got?.items) || 0, bytes: Number(got?.bytes) || 0 };
53
+ }
54
+
55
+ // One more content delivery reached the pod. Nothing to count against until
56
+ // the owner has been here once.
57
+ export async function noteReceived(ctx, key, rec, bytes) {
58
+ if (!ctx.readReceived || !ctx.writeReceived || !rec.openedAt) return;
59
+ const so = await receivedSince(ctx, key, rec);
60
+ await ctx.writeReceived(receivedKey(key, rec), { items: so.items + 1, bytes: so.bytes + (Number(bytes) || 0) })
61
+ .catch((e) => console.log(`front @${key}: count not written: ${e?.message || e}`));
62
+ }
63
+
64
+ // The owner is here. `always` is a sign-in; the relay stamps at most hourly,
65
+ // so a busy hour of posting is one write. The count that went with the old
66
+ // stamp is dropped: it is over, and a store should not keep one per hour.
67
+ export async function noteOpened(ctx, key, rec, { always = false } = {}) {
68
+ if (!ctx.putDirectory) return rec;
69
+ if (!always && rec.openedAt && Date.now() - Date.parse(rec.openedAt) < OPEN_STAMP_EVERY_MS) return rec;
70
+ const next = { ...rec, openedAt: new Date().toISOString() };
71
+ await ctx.putDirectory(key, next);
72
+ if (rec.openedAt && ctx.dropReceived) await ctx.dropReceived(receivedKey(key, rec)).catch(() => {});
73
+ return next;
74
+ }
75
+
76
+ // Closed, and why. Reads no count: this is asked on every public read.
77
+ export async function closedState(ctx, key, rec) {
78
+ if (rec.movedTo) return { closed: false };
79
+ if (rec.closedAt) return { closed: true, closedAt: rec.closedAt, closedBy: rec.closedBy || 'owner' };
80
+ if (rec.openedAt && Date.now() - Date.parse(rec.openedAt) > closeDaysOf(ctx) * 86400_000) {
81
+ const closedAt = new Date().toISOString();
82
+ if (ctx.putDirectory) await ctx.putDirectory(key, { ...rec, closedAt, closedBy: 'quiet' });
83
+ console.log(`front @${key}: closed — nothing opened it since ${rec.openedAt}`);
84
+ return { closed: true, closedAt, closedBy: 'quiet' };
85
+ }
86
+ return { closed: false };
87
+ }
88
+
89
+ // The whole standing of an account, for the door and for its owner's page.
90
+ export async function accountState(ctx, key, rec) {
91
+ const c = await closedState(ctx, key, rec);
92
+ const received = c.closed ? { items: 0, bytes: 0 } : await receivedSince(ctx, key, rec);
93
+ const cap = pauseItemsOf(ctx);
94
+ const pausedBy = c.closed ? null : rec.pausedAt ? 'owner' : received.items >= cap ? 'quiet' : null;
95
+ return {
96
+ closed: c.closed, closedAt: c.closedAt || null, closedBy: c.closedBy || null,
97
+ paused: !!pausedBy, pausedBy, pausedAt: rec.pausedAt || null,
98
+ openedAt: rec.openedAt || null, received, pauseItems: cap, closeDays: closeDaysOf(ctx),
99
+ };
100
+ }
101
+
102
+ // A 410 with a reason, the shape every closed or moved id answers with.
103
+ export const closedAnswer = (headers = {}, why = 'this address is closed') => ({
104
+ status: 410, headers: { ...headers, 'content-type': 'application/json', 'cache-control': 'no-store' },
105
+ body: JSON.stringify({ error: why }) });
106
+
107
+ // The owner of a row, proved the way the relay proves them: a pod token whose
108
+ // WebID is the row's, or lives on the row's pod for a row from before WebIDs
109
+ // were recorded.
110
+ async function provedOwner(request, pathname, ctx, rec, { j, verifyPodToken, webidUnderPod }) {
111
+ const webid = await verifyPodToken(request, pathname, ctx.verifier);
112
+ if (!webid) return { error: j(401, { error: 'a Solid-OIDC token proving the pod is required' }) };
113
+ const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
114
+ if (!owner) return { error: j(403, { error: "the token proves a different pod than this account's" }) };
115
+ return { webid };
116
+ }
117
+
118
+ // The owner's three routes. `deps` are front-core's own JSON reply and
119
+ // token check, handed in so this module stays free of the router's privates.
120
+ // Returns a response, or null when the path is not one of these.
121
+ export async function routeQuietApi(request, pathname, ctx, deps) {
122
+ const { j } = deps;
123
+ if (request.method !== 'POST' || !['/api/open', '/api/pause', '/api/close'].includes(pathname)) return null;
124
+ // The owner is here: their browser says so as it opens the account. The
125
+ // answer is the account's standing at this gateway, which the manage page
126
+ // shows. A closed address is told so and nothing is written.
127
+ if (pathname === '/api/open' && request.method === 'POST') {
128
+ if (!ctx.putDirectory) return j(501, { error: 'this front keeps no directory' });
129
+ let body;
130
+ try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
131
+ const handle = String(body.handle || '').toLowerCase();
132
+ let rec = await ctx.lookup(handle);
133
+ if (!rec) return j(404, { error: 'no such account' });
134
+ const who = await provedOwner(request, pathname, ctx, rec, deps);
135
+ if (who.error) return who.error;
136
+ const before = await closedState(ctx, handle, rec);
137
+ if (before.closed) return j(410, { error: 'this address is closed', closedAt: before.closedAt, closedBy: before.closedBy });
138
+ rec = await noteOpened(ctx, handle, rec, { always: true });
139
+ return j(200, { ok: true, handle, ...await accountState(ctx, handle, rec) });
140
+ }
141
+
142
+ // The owner pausing their own account, or lifting the pause they set.
143
+ if (pathname === '/api/pause' && request.method === 'POST') {
144
+ if (!ctx.putDirectory) return j(501, { error: 'this front keeps no directory' });
145
+ let body;
146
+ try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
147
+ const handle = String(body.handle || '').toLowerCase();
148
+ let rec = await ctx.lookup(handle);
149
+ if (!rec) return j(404, { error: 'no such account' });
150
+ const who = await provedOwner(request, pathname, ctx, rec, deps);
151
+ if (who.error) return who.error;
152
+ const before = await closedState(ctx, handle, rec);
153
+ if (before.closed) return j(410, { error: 'this address is closed', closedAt: before.closedAt });
154
+ if (typeof body.paused !== 'boolean') return j(400, { error: 'paused must be true or false' });
155
+ if (body.paused && !rec.pausedAt) rec = { ...rec, pausedAt: new Date().toISOString() };
156
+ if (!body.paused && rec.pausedAt) { rec = { ...rec }; delete rec.pausedAt; }
157
+ // Lifting a pause is a sign-in's worth of "I am here": the count starts over.
158
+ if (!body.paused) rec = { ...rec, openedAt: new Date().toISOString() };
159
+ await ctx.putDirectory(handle, rec);
160
+ console.log(`front @${handle}: ${body.paused ? 'paused' : 'resumed'} by its owner`);
161
+ return j(200, { ok: true, handle, ...await accountState(ctx, handle, rec) });
162
+ }
163
+
164
+ // The owner closing their address for good. Repeating it changes nothing.
165
+ if (pathname === '/api/close' && request.method === 'POST') {
166
+ if (!ctx.putDirectory) return j(501, { error: 'this front keeps no directory' });
167
+ let body;
168
+ try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
169
+ const handle = String(body.handle || '').toLowerCase();
170
+ let rec = await ctx.lookup(handle);
171
+ if (!rec) return j(404, { error: 'no such account' });
172
+ const who = await provedOwner(request, pathname, ctx, rec, deps);
173
+ if (who.error) return who.error;
174
+ if (body.confirm !== true) return j(400, { error: 'closing is for good — send confirm: true' });
175
+ if (!rec.closedAt) {
176
+ rec = { ...rec, closedAt: new Date().toISOString(), closedBy: 'owner' };
177
+ await ctx.putDirectory(handle, rec);
178
+ console.log(`front @${handle}: closed by its owner`);
179
+ }
180
+ return j(200, { ok: true, handle, ...await accountState(ctx, handle, rec) });
181
+ }
182
+
183
+ return null;
184
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod",
3
- "version": "1.30.0",
3
+ "version": "1.32.0",
4
4
  "description": "Standalone single-actor ActivityPub agent whose wire face, RDF truth and state all live on a Solid pod (CSS). Bundles a Phanpy UI and a Mastodon client-API facade.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -86,7 +86,7 @@ const shell = (text, c) => text
86
86
  .replace('src="../../"', `src="${c.path}"`)
87
87
  .replace('<script src="client.js"></script>',
88
88
  c.login ? '<script src="/admin/client/client.js"></script>' : '')
89
- .replace('>sign out</a>', `>sign out</a>${clientBar()}`)
89
+ .replace('<span id="account-pick">', `${clientBar()}\n <span id="account-pick">`)
90
90
  .replace('</head>', '<script src="/admin/client-pick.js"></script>\n</head>')
91
91
  .replace('</body>', `${clientNews()}</body>`);
92
92
 
@@ -136,7 +136,7 @@ const copyAdmin = (from, to) => { fs.mkdirSync(to, { recursive: true });
136
136
  // A sign-out beside "manage account", on every bar. It clears the FediPod
137
137
  // session, the client's stored account, browser-stored connected keys, and
138
138
  // the worker — the /?signout page (boot.js) does the teardown.
139
- .replace('>manage account</a>', '>manage account</a>\n <a id="bar-signout" href="/?signout" title=" Sign out of FediPod and clear this browser">sign out</a>')
139
+ .replace(/(<a id="bar-manage"[^>]*>manage<\/a>)/u, '$1\n <a id="bar-signout" href="/?signout" title=" Sign out of FediPod and clear this browser">sign out</a>')
140
140
  .replace('</head>', '<style>#actor-pick{display:none!important}</style>\n</head>');
141
141
  text = withUpdate(text);
142
142
  if (/[/\\]client$/.test(from)) {
@@ -147,7 +147,7 @@ const copyAdmin = (from, to) => { fs.mkdirSync(to, { recursive: true });
147
147
  } else if (/[/\\]admin$/.test(from)) {
148
148
  // The record page carries the same client control as the shells: it is
149
149
  // the page the owner is on when they want to change which client opens.
150
- text = text.replace('>sign out</a>', `>sign out</a>${clientBar()}`)
150
+ text = text.replace('<span id="account-pick">', `${clientBar()}\n <span id="account-pick">`)
151
151
  .replace('</head>', '<script src="/admin/client-pick.js"></script>\n</head>')
152
152
  .replace('</body>', `${clientNews()}</body>`);
153
153
  // Hide controls the browser build does not carry: the manual drain (it
@@ -197,6 +197,9 @@ fs.writeFileSync(path.join(site, '_redirects'), [
197
197
  '/api/roster /.netlify/functions/front 200',
198
198
  '/api/revoke /.netlify/functions/front 200',
199
199
  '/api/agent /.netlify/functions/front 200',
200
+ '/api/notices /.netlify/functions/front 200',
201
+ '/notices /.netlify/functions/front 200',
202
+ '/notices.js /.netlify/functions/front 200',
200
203
  '/u/* /.netlify/functions/front 200',
201
204
  '/.well-known/* /.netlify/functions/front 200',
202
205
  '/@* /.netlify/functions/front 200',