fedipod-server 0.28.0 → 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 (71) 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/connections/bskyfeed.mjs +6 -0
  12. package/lib/core/deliver.mjs +68 -22
  13. package/lib/core/intake/activities.mjs +18 -2
  14. package/lib/core/intake/group.mjs +3 -1
  15. package/lib/core/intake/index.mjs +50 -6
  16. package/lib/core/intake/notes.mjs +12 -5
  17. package/lib/core/lease.mjs +7 -7
  18. package/lib/core/place.mjs +82 -0
  19. package/lib/core/publisher/collections.mjs +37 -2
  20. package/lib/core/publisher/index.mjs +23 -1
  21. package/lib/core/publisher/notes.mjs +73 -7
  22. package/lib/core/publisher/own.mjs +143 -0
  23. package/lib/core/publisher/questions.mjs +5 -3
  24. package/lib/core/social.mjs +62 -32
  25. package/lib/core/store.mjs +36 -6
  26. package/lib/core/wire.mjs +51 -15
  27. package/lib/device/admin/routes/lifecycle.mjs +1 -1
  28. package/lib/device/admin/routes/setup.mjs +17 -1
  29. package/lib/device/cli/commands/setup.mjs +50 -8
  30. package/lib/device/cli/context.mjs +1 -1
  31. package/lib/device/migrate.mjs +1 -1
  32. package/lib/device/setup.mjs +45 -8
  33. package/lib/gateway/caches.mjs +36 -0
  34. package/lib/gateway/front-core.mjs +80 -111
  35. package/lib/gateway/gateway-core.mjs +91 -9
  36. package/lib/gateway/headers.mjs +49 -0
  37. package/lib/gateway/quiet.mjs +7 -2
  38. package/lib/gateway/relay-extras.mjs +89 -0
  39. package/lib/gateway/token-claims.mjs +16 -0
  40. package/lib/pod/containers.mjs +5 -1
  41. package/lib/pod/inbox.mjs +22 -3
  42. package/lib/pod/location.mjs +52 -0
  43. package/lib/pod/notes.mjs +2 -4
  44. package/lib/pod/transport.mjs +207 -25
  45. package/lib/pod/type-index.mjs +101 -0
  46. package/lib/pod/urls.mjs +6 -0
  47. package/lib/server/embed.mjs +7 -6
  48. package/lib/session/README.md +14 -0
  49. package/lib/session/fedi-account.mjs +73 -2
  50. package/lib/session/package.json +10 -2
  51. package/package.json +1 -1
  52. package/run-agent.mjs +18 -0
  53. package/web/admin/actors.js +2 -0
  54. package/web/admin/index.html +9 -0
  55. package/web/admin/record.js +4 -1
  56. package/web/admin/setup/index.html +17 -1
  57. package/web/admin/setup/setup.js +18 -5
  58. package/web/app/README.md +1 -1
  59. package/web/app/admin-facade.mjs +1 -1
  60. package/web/app/agent.mjs +24 -10
  61. package/web/app/boot.mjs +72 -32
  62. package/web/app/deliver-relay.mjs +47 -7
  63. package/web/app/dist/boot.js +466 -89
  64. package/web/app/dist/boot.js.map +4 -4
  65. package/web/app/dist/sw.js +2792 -1996
  66. package/web/app/dist/sw.js.map +4 -4
  67. package/web/app/index.html +16 -0
  68. package/web/app/signup.mjs +65 -26
  69. package/web/app/update.js +4 -0
  70. package/web/front/run.html +7 -1
  71. package/web/front/run.js +30 -4
@@ -15,6 +15,7 @@
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
 
@@ -93,8 +94,10 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
93
94
  return { status: 202, reason: 'does not concern us' };
94
95
  }
95
96
 
97
+ // The sender's key, kept between deliveries: a server pushing a hundred
98
+ // items is asked for its key once (caches.mjs).
96
99
  const v = await verifyHttpSignature(request, {
97
- documentLoader: makeSafeLoader({ fetchImpl }),
100
+ documentLoader: makeSafeLoader({ fetchImpl }), keyCache: senderKeys,
98
101
  });
99
102
  // A present-but-invalid signature is a forgery — dropped here, so it never
100
103
  // reaches the pod (today it would, drain, and die unapplied). An absent or
@@ -130,14 +133,15 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
130
133
  // client-to-server dispatcher, which publishes and delivers it — so the post
131
134
  // goes out when the agent next runs, the same way inbound mail is read.
132
135
  //
133
- // `slug` is the name the client asked for its new document. It rides in the
134
- // receipt so the dispatcher can use it, and it is what lets the door answer a
135
- // Location before anything exists: the object will live at notesPrefix+slug
136
- // 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.
137
141
  export const SLUG_OK = /^[A-Za-z0-9._-]{1,64}$/u;
138
142
  export const safeSlug = (s) => (typeof s === 'string' && SLUG_OK.test(s) && !/^\.+$/u.test(s) ? s : null);
139
143
 
140
- 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 } = {}) {
141
145
  if (!ident.hmacSecret) return { status: 409, reason: 'this account has no door secret — attach it again' };
142
146
  let raw;
143
147
  try { raw = await request.text(); } catch { return { status: 400, reason: 'unreadable body' }; }
@@ -147,16 +151,94 @@ export async function handleOwnerPost(request, ident, { podPut, ownerWebId, maxB
147
151
  if (!doc || typeof doc !== 'object' || Array.isArray(doc) || !doc.type) {
148
152
  return { status: 400, reason: 'a typed ActivityStreams object is required' };
149
153
  }
150
- 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.
151
176
  const receipt = signReceipt({
152
177
  v: 1, verified: true, method: 'c2s', keyId: ownerWebId || null, actor: ident.actorUrl,
153
178
  checks: ['owner-token'], reason: 'owner', gateway: ident.gatewayWebId, ...(slug ? { slug } : {}),
179
+ serial, at, hash,
154
180
  }, ident.hmacSecret);
155
- const hash = sha256hex(raw);
156
181
  const okA = await inbox.appendVerifiedDelivery(podPut, ident.inboxUrl, hash, raw);
157
182
  if (!okA) return { status: 502, reason: 'pod inbox write failed' };
158
183
  await inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt);
159
- return { status: 202, 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/');
160
242
  }
161
243
 
162
244
  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
+
@@ -101,7 +101,7 @@ export async function accountState(ctx, key, rec) {
101
101
 
102
102
  // A 410 with a reason, the shape every closed or moved id answers with.
103
103
  export const closedAnswer = (headers = {}, why = 'this address is closed') => ({
104
- status: 410, headers: { ...headers, 'content-type': 'application/json', 'cache-control': 'no-store' },
104
+ status: 410, headers: { 'cache-control': 'no-store', ...headers, 'content-type': 'application/json' },
105
105
  body: JSON.stringify({ error: why }) });
106
106
 
107
107
  // The owner of a row, proved the way the relay proves them: a pod token whose
@@ -120,7 +120,12 @@ async function provedOwner(request, pathname, ctx, rec, { j, verifyPodToken, web
120
120
  // Returns a response, or null when the path is not one of these.
121
121
  export async function routeQuietApi(request, pathname, ctx, deps) {
122
122
  const { j } = deps;
123
- if (request.method !== 'POST' || !['/api/open', '/api/pause', '/api/close'].includes(pathname)) return null;
123
+ if (!['/api/open', '/api/pause', '/api/close'].includes(pathname)) return null;
124
+ // A page at another origin asks first whether it may call, and is answered
125
+ // as the relay answers it. Unanswered, the browser never sends the call and
126
+ // some pages asked again every minute.
127
+ if (request.method === 'OPTIONS') return deps.apiPreflight();
128
+ if (request.method !== 'POST') return null;
124
129
  // The owner is here: their browser says so as it opens the account. The
125
130
  // answer is the account's standing at this gateway, which the manage page
126
131
  // shows. A closed address is told so and nothing is written.
@@ -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
+ }
@@ -51,12 +51,16 @@ export async function provisionPublic(pod, base) {
51
51
  * The private half: the home itself and the state container.
52
52
  *
53
53
  * Idempotent, so two devices doing it at once is harmless — which is what
54
- * makes it safe to run on every boot rather than only at setup.
54
+ * makes it safe to run on every boot rather than only at setup. Asked before
55
+ * written: a browser's worker boots whenever the browser likes, and each boot
56
+ * used to write the canary and both rules again. Returns whether it wrote.
55
57
  */
56
58
  export async function provisionPrivate(pod, urls) {
59
+ if (await exists(pod, urls.state)) return false;
57
60
  await pod.putJson(keepUrl(urls.state), KEEP, KEEP_CT);
58
61
  await pod.setAcl(urls.state, []);
59
62
  await pod.setAcl(urls.home, []);
63
+ return true;
60
64
  }
61
65
 
62
66
  /**
package/lib/pod/inbox.mjs CHANGED
@@ -85,13 +85,15 @@ export async function writeKeep(pod, urls) {
85
85
  * those and not the others is an inbox that is open when it should be shut.
86
86
  */
87
87
  export async function setPosture(pod, urls, posture) {
88
- if (posture === 'open') return pod.setAcl(urls.inbox, ['Append']);
89
- if (posture === 'closed') return pod.setAcl(urls.inbox, []);
88
+ // Restated at every start, and most starts find it already so: the rule is
89
+ // read first and written only when it differs (transport.setAcl ifChanged).
90
+ if (posture === 'open') return pod.setAcl(urls.inbox, ['Append'], { ifChanged: true });
91
+ if (posture === 'closed') return pod.setAcl(urls.inbox, [], { ifChanged: true });
90
92
  const webId = posture?.gatewayWebId;
91
93
  if (!webId) throw new Error(`inbox.setPosture: unknown posture ${JSON.stringify(posture)}`);
92
94
  // Public loses Append; the door keeps it. Both halves in one write, because
93
95
  // between two writes the inbox is either open to everyone or shut to the door.
94
- return pod.setAcl(urls.inbox, [], { appendAgents: [webId] });
96
+ return pod.setAcl(urls.inbox, [], { appendAgents: [webId], ifChanged: true });
95
97
  }
96
98
 
97
99
  /**
@@ -145,3 +147,20 @@ export async function readDeliveryReceipt(pod, itemUrl, { maxBytes, readCapped }
145
147
  * @returns {Promise<boolean>} whether the pod removed it
146
148
  */
147
149
  export const dropHandledItem = (pod, url) => pod.delete(url);
150
+
151
+ /**
152
+ * Remove the receipt a gateway wrote beside an item, once the item is gone.
153
+ * Best-effort: a receipt that stays is a stray document, never a lost
154
+ * delivery. Left behind, they were one stray per delivery, forever.
155
+ */
156
+ export const dropReceiptBeside = (pod, url) => pod.delete(url + '.receipt.json').catch(() => false);
157
+
158
+ /**
159
+ * Receipts whose item is no longer in the inbox, from the last listing: what
160
+ * earlier drains left behind. The drain removes a few of these each sweep
161
+ * until none are left.
162
+ */
163
+ export const orphanReceipts = (pod, urls) => pod.orphanReceipts?.(urls.inbox) ?? [];
164
+
165
+ /** Remove one such stray. @returns {Promise<boolean>} whether the pod removed it */
166
+ export const dropStrayReceipt = (pod, url) => pod.delete(url).catch(() => false);
@@ -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 }));