@pipeup/mailbox 0.0.1 → 0.5.1

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/Dockerfile ADDED
@@ -0,0 +1,9 @@
1
+ FROM node:22-alpine
2
+ WORKDIR /app
3
+ COPY package.json ./
4
+ COPY src ./src
5
+ ENV PORT=8787 HOST=0.0.0.0 DATA_DIR=/data
6
+ VOLUME /data
7
+ EXPOSE 8787
8
+ USER node
9
+ CMD ["node", "src/cli.mjs"]
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pipeup contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,149 @@
1
+ # @pipeup/mailbox
2
+
3
+ The reference HTTP mailbox server for Pipeup shared comments, and a checker for any server that claims to
4
+ speak the same contract (`pm1`, [add-ons design](../../docs/design/addons.md) section 7.7).
5
+
6
+ A mailbox stores opaque, encrypted batches of comments. The server can't read them: it holds ciphertext and
7
+ never sees the key, which stays in the shared file's address. Run it when your organisation doesn't want even
8
+ ciphertext to go to a public service. The Pipeup project ships this software; it runs no mailbox for anyone.
9
+
10
+ - About 200 lines of Node (`node:http`, `node:crypto`), no dependencies, Node 22 or newer.
11
+ - Storage is one append-only JSON-lines file per mailbox in `DATA_DIR`, plus a small metadata file.
12
+ - It speaks plain HTTP on localhost. Put a TLS proxy in front: browsers only accept `https:` for a shared
13
+ file (`http://localhost` and `http://127.0.0.1` work for development with the headless engine).
14
+
15
+ ## Run it
16
+
17
+ ```sh
18
+ CREATE_TOKEN="$(openssl rand -base64 24)" DATA_DIR=/var/lib/pipeup-mailbox npx @pipeup/mailbox
19
+ ```
20
+
21
+ or with Docker:
22
+
23
+ ```sh
24
+ docker build -t pipeup-mailbox .
25
+ docker run -d -p 127.0.0.1:8787:8787 -v pipeup-mailbox:/data -e CREATE_TOKEN=... -e TRUST_PROXY=on pipeup-mailbox
26
+ ```
27
+
28
+ | Variable | Default | Meaning |
29
+ | ---------------- | --------- | ------------------------------------------------------------------------------------------------ |
30
+ | `PORT` | 8787 | Port to listen on. |
31
+ | `HOST` | 127.0.0.1 | Address to bind (`0.0.0.0` inside a container). |
32
+ | `DATA_DIR` | `./data` | Where mailboxes are stored. |
33
+ | `CREATE_TOKEN` | none | When set, creating a mailbox needs `Authorization: Bearer <it>`. Set it. |
34
+ | `WRITE_TOKENS` | off | `on` gives each mailbox a write token, which the share file carries and POST bodies must repeat. |
35
+ | `MAX_MAILBOX_MB` | 64 | Quota per mailbox. A full mailbox answers 507. |
36
+ | `KEEP_DAYS` | 365 | Mailboxes are deleted this many days after their last write. `0` keeps them for ever. |
37
+ | `RATE_POST` | 60 | POSTs per minute per network address. |
38
+ | `RATE_GET` | 600 | GETs per minute per network address. |
39
+ | `TRUST_PROXY` | off | `on` takes the network address from `X-Forwarded-For`. Turn it on only behind your own proxy. |
40
+
41
+ A mailbox is also capped at 100,000 batches and every request body at 512 KiB.
42
+
43
+ Programmatic use (tests, embedding):
44
+
45
+ ```js
46
+ import { createMailboxServer } from "@pipeup/mailbox";
47
+ const mailbox = createMailboxServer({ memory: true, writeTokens: true, createToken: "secret" });
48
+ const { port } = await mailbox.listen(0);
49
+ ```
50
+
51
+ ## Behind a TLS proxy
52
+
53
+ The proxy must pass the CORS headers through (the server sets them itself) and must not add
54
+ `Access-Control-Allow-Credentials`. Pages opened from `file://` send `Origin: null`; the answer
55
+ `Access-Control-Allow-Origin: *` is what lets them read the response, and `Access-Control-Expose-Headers:
56
+ Retry-After` is what lets them read a 429.
57
+
58
+ Caddy (serving under `/pipeup`):
59
+
60
+ ```caddy
61
+ share.example.com {
62
+ handle_path /pipeup/* {
63
+ reverse_proxy 127.0.0.1:8787
64
+ }
65
+ }
66
+ ```
67
+
68
+ The server serves under any prefix, so you can instead keep the prefix with `handle /pipeup/*`. Caddy
69
+ passes `X-Forwarded-For` by default; set `TRUST_PROXY=on`.
70
+
71
+ nginx:
72
+
73
+ ```nginx
74
+ location /pipeup/ {
75
+ proxy_pass http://127.0.0.1:8787/;
76
+ proxy_set_header X-Forwarded-For $remote_addr;
77
+ client_max_body_size 1m;
78
+ # The server already sends these on every response, errors included. If you terminate CORS in nginx
79
+ # instead, repeat them on errors too, with "always":
80
+ # add_header Access-Control-Allow-Origin "*" always;
81
+ # add_header Access-Control-Expose-Headers "Retry-After" always;
82
+ # and never add Access-Control-Allow-Credentials.
83
+ # On a private address, also: add_header Access-Control-Allow-Private-Network "true" always;
84
+ }
85
+ ```
86
+
87
+ Don't add the headers in both places: duplicated `Access-Control-Allow-Origin` values make browsers refuse
88
+ the response. With `proxy_pass` the server's own headers go through untouched. Then run the checker.
89
+
90
+ ## Check a server
91
+
92
+ ```sh
93
+ PIPEUP_MAILBOX_CREATE_TOKEN=... npx @pipeup/mailbox check https://share.example.com/pipeup
94
+ ```
95
+
96
+ It prints PASS, FAIL or SKIP for each check and exits with 1 on any failure. It works against any
97
+ implementation, and an operator who writes their own should run it before pointing pages at it. It
98
+ creates two mailboxes and deletes both. It checks CORS headers on success and on errors, a simple-request
99
+ POST with a `text/plain` body, an idempotent retry, an id mismatch being refused, cursor paging and `more`,
100
+ the 512 KiB limit (413), the 429 body and exposed `Retry-After` (SKIP when the server can't be made to
101
+ limit within 80 POSTs), and 404 after `DELETE`. The create token is read from the environment, never from the
102
+ command line, so it stays out of shell history and process lists. `http:` is accepted only for `localhost`
103
+ and `127.0.0.1`.
104
+
105
+ ## The contract in short
106
+
107
+ | Endpoint | Purpose |
108
+ | -------------------------------------- | --------------------------------------------------------------------------------- |
109
+ | `GET <base>/m/<id>/ops?since=<cursor>` | New batches after the cursor, oldest first, with `next`, `more` and `expires`. |
110
+ | `POST <base>/m/<id>/ops` | Append one batch `{id, ct, token?}`; 201 appended, 200 already there. |
111
+ | `POST <base>/m` | Create a mailbox; returns its id, stop key (shown once) and optional write token. |
112
+ | `DELETE <base>/m/<id>` | Delete it with `Authorization: Bearer <stop key>`; 204. |
113
+
114
+ Every error is JSON `{v: 1, error, message}`: 400 `bad-request`, 403 `token`, 404 `gone`, 409 `cursor`, 413
115
+ `too-large`, 429 `rate` (with `Retry-After` and `retryAfter`), 507 `full`. Mailboxes are never listed.
116
+ The cursor is the number of batches before it. The rule every implementation must keep: every batch
117
+ committed before a response that returned `next` is in that response or an earlier one, and none committed
118
+ later is skipped.
119
+
120
+ ## Serverless and edge notes
121
+
122
+ The reference server is a long-running process. The contract also fits platforms with no server, as long as
123
+ each meets the cursor rule above:
124
+
125
+ - **Cloudflare Workers**: one Durable Object per mailbox. Appends are serialised for free, and the cursor is
126
+ a counter in the object's storage.
127
+ - **Deno Deploy**: Deno KV with a per-mailbox counter, incremented in an atomic transaction together with the
128
+ write of the batch.
129
+ - **AWS Lambda**: DynamoDB with the mailbox as partition key and a sequence number as sort key, written with a
130
+ conditional put (`attribute_not_exists`) and retried on conflict.
131
+
132
+ Answer `OPTIONS` and send the CORS headers on every response, errors included, whatever the platform.
133
+
134
+ ## What the operator can see
135
+
136
+ Ciphertext only: never comment text, names, anchors, the document id or the key. The operator does see
137
+ metadata: mailbox ids, when each request is made, batch sizes and counts, the network addresses and user
138
+ agents of everyone reading and writing, and so which addresses use the same document. The operator can
139
+ withhold, delete or show different readers different batches. They can't forge or alter a comment
140
+ (signatures), and a replayed batch is dropped by its id.
141
+
142
+ The write token is not in logs because it travels only in POST bodies. The mailbox id is in your access
143
+ logs, so keep those logs as private as you would any capability URL.
144
+
145
+ ## Tests
146
+
147
+ ```sh
148
+ npm test
149
+ ```
package/package.json CHANGED
@@ -1 +1,34 @@
1
- {"name":"@pipeup/mailbox","version":"0.0.1","description":"Placeholder; the real release follows.","license":"MIT"}
1
+ {
2
+ "name": "@pipeup/mailbox",
3
+ "version": "0.5.1",
4
+ "description": "Reference HTTP mailbox server for Pipeup shared comments (contract pm1), and a checker for any server.",
5
+ "homepage": "https://pipeup-ai.github.io/pipeup/",
6
+ "author": "Pipeup contributors",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/pipeup-ai/pipeup.git",
10
+ "directory": "services/mailbox"
11
+ },
12
+ "type": "module",
13
+ "license": "MIT",
14
+ "bin": {
15
+ "pipeup-mailbox": "./src/cli.mjs"
16
+ },
17
+ "main": "./src/server.mjs",
18
+ "exports": {
19
+ ".": "./src/server.mjs",
20
+ "./check": "./src/check.mjs"
21
+ },
22
+ "files": [
23
+ "src",
24
+ "README.md",
25
+ "LICENSE",
26
+ "Dockerfile"
27
+ ],
28
+ "engines": {
29
+ "node": ">=22"
30
+ },
31
+ "scripts": {
32
+ "test": "node --test \"test/*.test.mjs\""
33
+ }
34
+ }
package/src/check.mjs ADDED
@@ -0,0 +1,288 @@
1
+ // The contract's own checks (pm1), runnable against any server.
2
+ import { createHash } from "node:crypto";
3
+
4
+ /**
5
+ * @typedef {{name: string, status: "PASS" | "FAIL" | "SKIP", note?: string}} CheckResult
6
+ */
7
+
8
+ const ctOf = (s) => `pu1.${Buffer.from(s).toString("base64url")}`;
9
+ const idOf = (ct) => createHash("sha256").update(ct).digest("base64url").slice(0, 22);
10
+ const batch = (s) => {
11
+ const ct = ctOf(s);
12
+ return { id: idOf(ct), ct };
13
+ };
14
+
15
+ class Fail extends Error {}
16
+ const ok = (cond, msg) => {
17
+ if (!cond) throw new Fail(msg);
18
+ };
19
+
20
+ /**
21
+ * @param {string} base the server's base address (no trailing slash needed)
22
+ * @param {{createToken?: string}} [opts]
23
+ * @returns {Promise<CheckResult[]>}
24
+ */
25
+ export async function runChecks(base, opts = {}) {
26
+ const u = new URL(base);
27
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname);
28
+ if (u.protocol !== "https:" && !(u.protocol === "http:" && local)) {
29
+ throw new Error("The base address must be https: (http: only for localhost or 127.0.0.1).");
30
+ }
31
+ const root = base.replace(/\/+$/, "");
32
+ const tag = Date.now().toString(36);
33
+
34
+ async function call(method, url, { body, headers, raw } = {}) {
35
+ const res = await fetch(url, {
36
+ method,
37
+ body,
38
+ headers,
39
+ credentials: "omit",
40
+ redirect: "manual",
41
+ });
42
+ const text = await res.text();
43
+ let data;
44
+ try {
45
+ data = JSON.parse(text);
46
+ } catch {
47
+ data = undefined;
48
+ }
49
+ return { status: res.status, headers: res.headers, data, text: raw ? text : undefined };
50
+ }
51
+ const simple = (url, obj) =>
52
+ call("POST", url, {
53
+ body: typeof obj === "string" ? obj : JSON.stringify(obj),
54
+ headers: { "Content-Type": "text/plain;charset=UTF-8" },
55
+ });
56
+ const corsOk = (r, what) => {
57
+ ok(
58
+ r.headers.get("access-control-allow-origin") === "*",
59
+ `${what}: Access-Control-Allow-Origin must be *`,
60
+ );
61
+ ok(
62
+ (r.headers.get("access-control-expose-headers") ?? "")
63
+ .toLowerCase()
64
+ .split(/\s*,\s*/)
65
+ .includes("retry-after"),
66
+ `${what}: Access-Control-Expose-Headers must include Retry-After`,
67
+ );
68
+ ok(
69
+ !r.headers.has("access-control-allow-credentials"),
70
+ `${what}: must not send Access-Control-Allow-Credentials`,
71
+ );
72
+ };
73
+ const errOk = (r, status, code, what) => {
74
+ ok(r.status === status, `${what}: expected ${status}, got ${r.status}`);
75
+ ok(
76
+ r.data?.v === 1 && r.data.error === code && typeof r.data.message === "string",
77
+ `${what}: expected JSON {v:1,error:"${code}",message}`,
78
+ );
79
+ corsOk(r, what);
80
+ };
81
+
82
+ async function create() {
83
+ const headers = { "Content-Type": "text/plain;charset=UTF-8" };
84
+ if (opts.createToken) headers.Authorization = `Bearer ${opts.createToken}`;
85
+ const r = await call("POST", `${root}/m`, { body: '{"v":1}', headers });
86
+ ok(r.status === 201, `create: expected 201, got ${r.status} (is the create token right?)`);
87
+ ok(r.data?.v === 1 && /^[A-Za-z0-9_-]{22,64}$/.test(r.data.mailbox ?? ""), "create: bad mailbox id");
88
+ ok(typeof r.data.stop === "string" && r.data.stop.length > 0, "create: missing stop key");
89
+ ok(r.data.limits && r.data.limits.body >= 524288, "create: limits.body must be at least 524288");
90
+ corsOk(r, "create");
91
+ return {
92
+ id: r.data.mailbox,
93
+ stop: r.data.stop,
94
+ token: r.data.token ?? undefined,
95
+ url: `${root}/m/${r.data.mailbox}`,
96
+ };
97
+ }
98
+
99
+ /** @type {CheckResult[]} */
100
+ const results = [];
101
+ const run = async (name, fn) => {
102
+ try {
103
+ const note = await fn();
104
+ results.push(
105
+ typeof note === "string" && note.startsWith("skip:")
106
+ ? { name, status: "SKIP", note: note.slice(5).trim() }
107
+ : { name, status: "PASS" },
108
+ );
109
+ } catch (e) {
110
+ results.push({
111
+ name,
112
+ status: "FAIL",
113
+ note: e instanceof Fail ? e.message : `${e?.cause?.code ?? ""} ${e?.message ?? e}`.trim(),
114
+ });
115
+ }
116
+ };
117
+
118
+ let a;
119
+ let rateBox;
120
+ await run("create a mailbox (POST <base>/m)", async () => {
121
+ a = await create();
122
+ rateBox = await create();
123
+ });
124
+ if (!a) return results;
125
+ const post = (b, extra = {}) =>
126
+ simple(`${a.url}/ops`, { ...b, ...(a.token ? { token: a.token } : {}), ...extra });
127
+ const b1 = batch(`one-${tag}`);
128
+ const b2 = batch(`two-${tag}`);
129
+ const b3 = batch(`three-${tag}`);
130
+ const get = (since) =>
131
+ call("GET", `${a.url}/ops${since === undefined ? "" : `?since=${encodeURIComponent(since)}`}`, {
132
+ headers: { Accept: "application/json" },
133
+ });
134
+
135
+ await run("OPTIONS answers 204 with methods, headers and max-age", async () => {
136
+ const r = await call("OPTIONS", `${a.url}/ops`);
137
+ ok(r.status === 204, `expected 204, got ${r.status}`);
138
+ ok(
139
+ /GET/.test(r.headers.get("access-control-allow-methods") ?? "") &&
140
+ /POST/.test(r.headers.get("access-control-allow-methods") ?? ""),
141
+ "Allow-Methods must list GET and POST",
142
+ );
143
+ ok(
144
+ /content-type/i.test(r.headers.get("access-control-allow-headers") ?? ""),
145
+ "Allow-Headers must list Content-Type",
146
+ );
147
+ ok(r.headers.has("access-control-max-age"), "Max-Age missing");
148
+ corsOk(r, "OPTIONS");
149
+ });
150
+ await run("empty mailbox: GET answers the contract shape with CORS headers", async () => {
151
+ const r = await get();
152
+ ok(r.status === 200 && r.data?.v === 1, `expected 200 {v:1}, got ${r.status}`);
153
+ ok(Array.isArray(r.data.batches) && r.data.batches.length === 0, "batches must be an empty array");
154
+ ok(
155
+ typeof r.data.next === "string" && r.data.next.length <= 64 && /^[\x20-\x7e]*$/.test(r.data.next),
156
+ "next must be printable ASCII, 64 characters at most",
157
+ );
158
+ ok(typeof r.data.more === "boolean", "more must be a boolean");
159
+ corsOk(r, "GET");
160
+ });
161
+ await run("simple-request POST (text/plain body) appends: 201", async () => {
162
+ const r = await post(b1);
163
+ ok(r.status === 201 && r.data?.v === 1, `expected 201 {v:1}, got ${r.status}`);
164
+ corsOk(r, "POST");
165
+ });
166
+ await run("the same POST again is idempotent: 200", async () => {
167
+ const r = await post(b1);
168
+ ok(r.status === 200 && r.data?.v === 1, `expected 200 {v:1}, got ${r.status}`);
169
+ const g = await get();
170
+ ok(g.data.batches.filter((x) => x.id === b1.id).length === 1, "the retried batch was stored twice");
171
+ });
172
+ await run("id that does not match ct is refused: 400 bad-request", async () => {
173
+ errOk(await post({ id: b2.id, ct: b3.ct }), 400, "bad-request", "id mismatch");
174
+ });
175
+ await run("invalid JSON and a bad ct are refused: 400 bad-request", async () => {
176
+ errOk(await simple(`${a.url}/ops`, "{nope"), 400, "bad-request", "bad JSON");
177
+ errOk(await post({ id: idOf("x"), ct: "x" }), 400, "bad-request", "bad ct");
178
+ });
179
+ await run("cursors page forward without skipping or repeating", async () => {
180
+ ok((await post(b2)).status === 201, "second POST failed");
181
+ ok((await post(b3)).status === 201, "third POST failed");
182
+ const first = await get();
183
+ ok(
184
+ first.data.batches.map((x) => x.id).join() === [b1.id, b2.id, b3.id].join(),
185
+ "batches must come oldest first, each once",
186
+ );
187
+ const again = await get(first.data.next);
188
+ ok(again.status === 200 && again.data.batches.length === 0, "since=next must return nothing new");
189
+ const mid = await get("1");
190
+ const viaCursor = [];
191
+ let since = "";
192
+ for (let i = 0; i < 20; i++) {
193
+ const r = await get(since);
194
+ ok(r.status === 200, `GET since=${since} gave ${r.status}`);
195
+ viaCursor.push(...r.data.batches.map((x) => x.id));
196
+ since = r.data.next;
197
+ if (!r.data.more) break;
198
+ }
199
+ ok(
200
+ viaCursor.join() === [b1.id, b2.id, b3.id].join(),
201
+ "following next while more is true must reach every batch once",
202
+ );
203
+ ok(mid.status === 200 || mid.status === 409, "an arbitrary cursor must answer 200 or 409");
204
+ const b4 = batch(`four-${tag}`);
205
+ ok((await post(b4)).status === 201, "fourth POST failed");
206
+ const later = await get(first.data.next);
207
+ ok(
208
+ later.data.batches.map((x) => x.id).join() === b4.id,
209
+ "a batch committed after a cursor must be returned by since=cursor",
210
+ );
211
+ });
212
+ await run("unknown cursor: 409 cursor", async () => {
213
+ errOk(await get("zzzz-not-a-cursor"), 409, "cursor", "unknown cursor");
214
+ });
215
+ await run("body over 512 KiB: 413 too-large", async () => {
216
+ const ct = `pu1.${"A".repeat(524288)}`;
217
+ errOk(
218
+ await simple(`${a.url}/ops`, { id: idOf(ct), ct, ...(a.token ? { token: a.token } : {}) }),
219
+ 413,
220
+ "too-large",
221
+ "oversized body",
222
+ );
223
+ });
224
+ await run("a body just under the limit is accepted", async () => {
225
+ const ct = `pu1.${"B".repeat(524288 - 400)}`;
226
+ const r = await post({ id: idOf(ct), ct });
227
+ ok(r.status === 201, `expected 201, got ${r.status}`);
228
+ });
229
+ await run("unknown mailbox: 404 gone, with CORS headers", async () => {
230
+ errOk(
231
+ await call("GET", `${root}/m/${"x".repeat(22)}/ops`, { headers: { Accept: "application/json" } }),
232
+ 404,
233
+ "gone",
234
+ "unknown mailbox",
235
+ );
236
+ });
237
+ await run("write token: a POST without it is refused (403 token)", async () => {
238
+ if (!a.token) return "skip: this server does not issue write tokens";
239
+ errOk(await simple(`${a.url}/ops`, batch(`tok-${tag}`)), 403, "token", "missing write token");
240
+ errOk(
241
+ await simple(`${a.url}/ops`, { ...batch(`tok-${tag}`), token: "wrong" }),
242
+ 403,
243
+ "token",
244
+ "wrong write token",
245
+ );
246
+ return undefined;
247
+ });
248
+ await run("DELETE with a wrong stop key: 403 token", async () => {
249
+ errOk(
250
+ await call("DELETE", a.url, { headers: { Authorization: "Bearer wrong" } }),
251
+ 403,
252
+ "token",
253
+ "wrong stop key",
254
+ );
255
+ });
256
+ await run("DELETE with the stop key: 204, then every request answers 404", async () => {
257
+ const d = await call("DELETE", a.url, { headers: { Authorization: `Bearer ${a.stop}` } });
258
+ ok(d.status === 204, `expected 204, got ${d.status}`);
259
+ corsOk(d, "DELETE");
260
+ errOk(await get(), 404, "gone", "GET after DELETE");
261
+ errOk(await post(batch(`after-${tag}`)), 404, "gone", "POST after DELETE");
262
+ });
263
+ await run("rate limit: 429 with Retry-After header, retryAfter in the body, readable", async () => {
264
+ if (!rateBox) return "skip: no second mailbox";
265
+ const b = batch(`rate-${tag}`);
266
+ const body = JSON.stringify({ ...b, ...(rateBox.token ? { token: rateBox.token } : {}) });
267
+ for (let i = 0; i < 80; i++) {
268
+ const r = await call("POST", `${rateBox.url}/ops`, {
269
+ body,
270
+ headers: { "Content-Type": "text/plain;charset=UTF-8" },
271
+ });
272
+ if (r.status === 429) {
273
+ errOk(r, 429, "rate", "429");
274
+ const h = Number(r.headers.get("retry-after"));
275
+ ok(Number.isFinite(h) && h >= 0, "Retry-After header must be a number of seconds");
276
+ ok(typeof r.data.retryAfter === "number", "retryAfter in the body must be a number");
277
+ return undefined;
278
+ }
279
+ ok(r.status === 200 || r.status === 201, `unexpected ${r.status} while probing the rate limit`);
280
+ }
281
+ return "skip: the server did not limit 80 POSTs in a row";
282
+ });
283
+ if (rateBox)
284
+ await call("DELETE", rateBox.url, { headers: { Authorization: `Bearer ${rateBox.stop}` } }).catch(
285
+ () => {},
286
+ );
287
+ return results;
288
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ // pipeup-mailbox: run the reference server from the environment, or `check <base>` any server.
3
+ import { createMailboxServer } from "./server.mjs";
4
+ import { runChecks } from "./check.mjs";
5
+
6
+ const env = process.env;
7
+ const num = (v) => (v === undefined || v === "" ? undefined : Number(v));
8
+
9
+ async function main(argv) {
10
+ if (argv[0] === "check") {
11
+ const base = argv[1];
12
+ if (!base || base.startsWith("-")) {
13
+ console.error("usage: pipeup-mailbox check <base> (create token in PIPEUP_MAILBOX_CREATE_TOKEN)");
14
+ return 2;
15
+ }
16
+ if (argv.includes("--create-token")) {
17
+ console.error(
18
+ "Put the create token in the PIPEUP_MAILBOX_CREATE_TOKEN environment variable, not on the command line.",
19
+ );
20
+ return 2;
21
+ }
22
+ const results = await runChecks(base, { createToken: env.PIPEUP_MAILBOX_CREATE_TOKEN });
23
+ for (const r of results) console.log(`${r.status} ${r.name}${r.note ? ` (${r.note})` : ""}`);
24
+ const failed = results.filter((r) => r.status === "FAIL").length;
25
+ console.log(failed ? `${failed} check(s) failed.` : "All checks passed.");
26
+ return failed ? 1 : 0;
27
+ }
28
+ if (argv.length) {
29
+ console.error(
30
+ "usage: pipeup-mailbox start the server (see README for the environment)\n pipeup-mailbox check <base>",
31
+ );
32
+ return 2;
33
+ }
34
+ const mb = num(env.MAX_MAILBOX_MB);
35
+ const mailbox = createMailboxServer({
36
+ dataDir: env.DATA_DIR || "./data",
37
+ createToken: env.CREATE_TOKEN || undefined,
38
+ writeTokens: env.WRITE_TOKENS === "on",
39
+ maxMailboxBytes: mb === undefined ? undefined : mb * 1024 * 1024,
40
+ keepDays: num(env.KEEP_DAYS),
41
+ rate: { post: num(env.RATE_POST), get: num(env.RATE_GET) },
42
+ trustProxy: env.TRUST_PROXY === "on",
43
+ });
44
+ const { port } = await mailbox.listen(num(env.PORT) ?? 8787, env.HOST || "127.0.0.1");
45
+ console.log(`pipeup-mailbox listening on ${env.HOST || "127.0.0.1"}:${port} (put a TLS proxy in front)`);
46
+ if (!env.CREATE_TOKEN)
47
+ console.warn(
48
+ "warning: CREATE_TOKEN is not set, so anyone who can reach this server can create mailboxes.",
49
+ );
50
+ for (const s of ["SIGINT", "SIGTERM"]) process.on(s, () => mailbox.close().then(() => process.exit(0)));
51
+ return undefined;
52
+ }
53
+
54
+ main(process.argv.slice(2)).then(
55
+ (code) => {
56
+ if (code !== undefined) process.exit(code);
57
+ },
58
+ (e) => {
59
+ console.error(e?.message ?? e);
60
+ process.exit(1);
61
+ },
62
+ );
package/src/server.mjs ADDED
@@ -0,0 +1,279 @@
1
+ // @pipeup/mailbox: the reference HTTP mailbox server (contract pm1, docs/design/addons.md section 7.7).
2
+ // Stores opaque, content-addressed batches. No dependencies.
3
+ import { createServer } from "node:http";
4
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
5
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+
8
+ const MAX_BODY = 524288;
9
+ const PAGE_BYTES = 4 * 1024 * 1024;
10
+ const PAGE_BATCHES = 1000;
11
+ const CT = /^pu1\.[A-Za-z0-9_-]+$/;
12
+ const sha = (s) => createHash("sha256").update(s).digest();
13
+ const b64u = (b) => Buffer.from(b).toString("base64url");
14
+ const same = (a, b) => timingSafeEqual(sha(a), sha(b));
15
+
16
+ /**
17
+ * @typedef {object} MailboxOptions
18
+ * @property {string} [dataDir] where mailboxes are stored (one JSON-lines file each)
19
+ * @property {boolean} [memory] keep everything in memory (tests)
20
+ * @property {string} [createToken] when set, `POST <base>/m` needs `Authorization: Bearer <it>`
21
+ * @property {boolean} [writeTokens] issue a write token per mailbox, required in POST bodies
22
+ * @property {number} [maxMailboxBytes] default 64 MiB
23
+ * @property {number} [maxBatches] default 100,000
24
+ * @property {number} [keepDays] default 365, counted from the last write; 0 keeps for ever
25
+ * @property {{post?: number, get?: number}} [rate] per network address per minute (60 / 600)
26
+ * @property {boolean} [trustProxy] take the address from X-Forwarded-For (behind a TLS proxy)
27
+ * @property {() => number} [now] clock in ms (tests)
28
+ * @property {number} [pageBytes] page size limits (tests)
29
+ * @property {number} [pageBatches]
30
+ */
31
+
32
+ /**
33
+ * @param {MailboxOptions} [options]
34
+ * @returns {{listen(port: number, host?: string): Promise<{port: number}>, close(): Promise<void>, server: import("node:http").Server}}
35
+ */
36
+ export function createMailboxServer(options = {}) {
37
+ const o = options;
38
+ const now = o.now ?? Date.now;
39
+ const maxBytes = o.maxMailboxBytes ?? 64 * 1024 * 1024;
40
+ const maxBatches = o.maxBatches ?? 100000;
41
+ const keepDays = o.keepDays ?? 365;
42
+ const limits = { POST: o.rate?.post ?? 60, GET: o.rate?.get ?? 600 };
43
+ const pageBytes = o.pageBytes ?? PAGE_BYTES;
44
+ const pageBatches = o.pageBatches ?? PAGE_BATCHES;
45
+ const dir = o.memory ? null : (o.dataDir ?? "./data");
46
+ if (dir) mkdirSync(dir, { recursive: true });
47
+ /** @type {Map<string, any>} */
48
+ const boxes = new Map();
49
+ /** @type {Map<string, {start: number, n: number}>} */
50
+ const hits = new Map();
51
+
52
+ const expiry = (b) => (keepDays > 0 ? new Date(b.last + keepDays * 864e5).toISOString() : null);
53
+ const drop = (id) => {
54
+ boxes.delete(id);
55
+ if (dir) for (const e of ["json", "jsonl"]) rmSync(join(dir, `${id}.${e}`), { force: true });
56
+ };
57
+ /** The mailbox, loaded from disk on first use; undefined when gone or expired. */
58
+ function box(id) {
59
+ let b = boxes.get(id);
60
+ if (!b && dir && existsSync(join(dir, `${id}.json`))) {
61
+ b = JSON.parse(readFileSync(join(dir, `${id}.json`), "utf8"));
62
+ b.items = [];
63
+ b.ids = new Set();
64
+ b.bytes = 0;
65
+ if (existsSync(join(dir, `${id}.jsonl`))) {
66
+ for (const line of readFileSync(join(dir, `${id}.jsonl`), "utf8").split("\n")) {
67
+ if (line) add(b, JSON.parse(line));
68
+ }
69
+ }
70
+ boxes.set(id, b);
71
+ }
72
+ if (b && keepDays > 0 && now() > b.last + keepDays * 864e5) {
73
+ drop(id);
74
+ return undefined;
75
+ }
76
+ return b;
77
+ }
78
+ function add(b, item) {
79
+ b.items.push(item);
80
+ b.ids.add(item.id);
81
+ b.bytes += item.ct.length;
82
+ }
83
+
84
+ function send(res, status, body, extra = {}) {
85
+ res.writeHead(status, {
86
+ "Content-Type": "application/json; charset=utf-8",
87
+ "Cache-Control": "no-store",
88
+ "Access-Control-Allow-Origin": "*",
89
+ "Access-Control-Expose-Headers": "Retry-After",
90
+ ...extra,
91
+ });
92
+ res.end(status === 204 ? undefined : JSON.stringify(body));
93
+ }
94
+ const fail = (res, status, error, message, extra, headers) =>
95
+ send(res, status, { v: 1, error, message, ...extra }, headers);
96
+
97
+ function limited(req, kind) {
98
+ let addr = req.socket.remoteAddress ?? "?";
99
+ if (o.trustProxy)
100
+ addr = String(req.headers["x-forwarded-for"] ?? addr)
101
+ .split(",")[0]
102
+ .trim();
103
+ const key = `${kind}|${addr}`;
104
+ const t = now();
105
+ let h = hits.get(key);
106
+ if (!h || t - h.start >= 60000) hits.set(key, (h = { start: t, n: 0 }));
107
+ if (++h.n <= limits[kind]) return 0;
108
+ return Math.max(1, Math.ceil((h.start + 60000 - t) / 1000));
109
+ }
110
+
111
+ /** Reads the body as text; null when it is over the limit (the rest is drained). */
112
+ function readBody(req) {
113
+ return new Promise((resolve) => {
114
+ const parts = [];
115
+ let size = 0;
116
+ let over = Number(req.headers["content-length"] ?? 0) > MAX_BODY;
117
+ req.on("data", (c) => {
118
+ size += c.length;
119
+ if (size > MAX_BODY) over = true;
120
+ if (!over) parts.push(c);
121
+ });
122
+ req.on("end", () => resolve(over ? null : Buffer.concat(parts).toString("utf8")));
123
+ req.on("error", () => resolve(null));
124
+ });
125
+ }
126
+ const json = (text) => {
127
+ try {
128
+ const v = JSON.parse(text);
129
+ return v && typeof v === "object" && !Array.isArray(v) ? v : undefined;
130
+ } catch {
131
+ return undefined;
132
+ }
133
+ };
134
+
135
+ async function handle(req, res) {
136
+ const url = new URL(req.url ?? "/", "http://x");
137
+ const path = url.pathname.replace(/\/+$/, "");
138
+ const method = req.method ?? "GET";
139
+ if (method === "OPTIONS") {
140
+ return send(res, 204, null, {
141
+ "Access-Control-Allow-Methods": "GET, POST, DELETE",
142
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
143
+ "Access-Control-Max-Age": "86400",
144
+ "Access-Control-Allow-Private-Network": "true",
145
+ });
146
+ }
147
+ const mm = /^(.*)\/m\/([A-Za-z0-9_-]{22,64})(\/ops)?$/.exec(path);
148
+ const isCreate = /\/m$/.test(path) && !mm;
149
+ if (!mm && !isCreate) return fail(res, 404, "not-found", "No such endpoint.");
150
+ const ops = Boolean(mm?.[3]);
151
+ if (isCreate ? method !== "POST" : ops ? method !== "GET" && method !== "POST" : method !== "DELETE") {
152
+ return fail(res, 405, "method", "Method not allowed here.", {}, { Allow: "GET, POST, DELETE" });
153
+ }
154
+ if (method !== "DELETE") {
155
+ const wait = limited(req, method === "POST" ? "POST" : "GET");
156
+ if (wait) {
157
+ return fail(
158
+ res,
159
+ 429,
160
+ "rate",
161
+ "Too many requests.",
162
+ { retryAfter: wait },
163
+ { "Retry-After": String(wait) },
164
+ );
165
+ }
166
+ }
167
+ const bearer = (/^Bearer (.+)$/.exec(req.headers.authorization ?? "") ?? [])[1] ?? "";
168
+
169
+ if (isCreate) {
170
+ const body = await readBody(req);
171
+ if (body === null) return fail(res, 413, "too-large", "Body too large.");
172
+ if (o.createToken && !same(bearer, o.createToken))
173
+ return fail(res, 403, "token", "Create token wrong or missing.");
174
+ const id = b64u(randomBytes(16));
175
+ const stop = b64u(randomBytes(32));
176
+ const token = o.writeTokens ? b64u(randomBytes(18)) : null;
177
+ const b = {
178
+ id,
179
+ created: now(),
180
+ last: now(),
181
+ stop: sha(stop).toString("hex"),
182
+ token: token ? sha(token).toString("hex") : null,
183
+ items: [],
184
+ ids: new Set(),
185
+ bytes: 0,
186
+ };
187
+ boxes.set(id, b);
188
+ if (dir) {
189
+ // What is written is the mailbox's own record, not the batches (they go to the .jsonl file).
190
+ const meta = Object.fromEntries(
191
+ Object.entries(b).filter(([k]) => !["items", "ids", "bytes"].includes(k)),
192
+ );
193
+ writeFileSync(join(dir, `${id}.json`), JSON.stringify(meta));
194
+ }
195
+ return send(res, 201, {
196
+ v: 1,
197
+ mailbox: id,
198
+ stop,
199
+ token,
200
+ expires: expiry(b),
201
+ keep: keepDays > 0 ? "last-write" : null,
202
+ limits: { body: MAX_BODY, mailbox: maxBytes },
203
+ });
204
+ }
205
+
206
+ const b = box(mm[2]);
207
+ if (method === "DELETE") {
208
+ if (!b) return fail(res, 404, "gone", "No such mailbox.");
209
+ if (!bearer || sha(bearer).toString("hex") !== b.stop)
210
+ return fail(res, 403, "token", "Stop key wrong or missing.");
211
+ drop(b.id);
212
+ return send(res, 204, null);
213
+ }
214
+ if (method === "GET") {
215
+ if (!b) return fail(res, 404, "gone", "No such mailbox.");
216
+ const since = url.searchParams.get("since") ?? "";
217
+ if (since !== "" && !/^\d{1,15}$/.test(since)) return fail(res, 409, "cursor", "Unknown cursor.");
218
+ const from = since === "" ? 0 : Number(since);
219
+ if (from > b.items.length) return fail(res, 409, "cursor", "Unknown cursor.");
220
+ let to = from;
221
+ let size = 0;
222
+ while (to < b.items.length && to - from < pageBatches) {
223
+ size += b.items[to].ct.length;
224
+ if (size > pageBytes && to > from) break;
225
+ to++;
226
+ }
227
+ return send(res, 200, {
228
+ v: 1,
229
+ batches: b.items.slice(from, to),
230
+ next: String(to),
231
+ more: to < b.items.length,
232
+ expires: expiry(b),
233
+ });
234
+ }
235
+ // POST <mailbox>/ops
236
+ if (!b) {
237
+ await readBody(req);
238
+ return fail(res, 404, "gone", "No such mailbox.");
239
+ }
240
+ const text = await readBody(req);
241
+ if (text === null) return fail(res, 413, "too-large", "Body over 512 KiB.", {}, { Connection: "close" });
242
+ const m = json(text);
243
+ if (!m) return fail(res, 400, "bad-request", "Body is not a JSON object.");
244
+ if (b.token && !(typeof m.token === "string" && sha(m.token).toString("hex") === b.token)) {
245
+ return fail(res, 403, "token", "Write token wrong or missing.");
246
+ }
247
+ if (typeof m.ct !== "string" || !CT.test(m.ct)) return fail(res, 400, "bad-request", "Bad ct.");
248
+ if (m.id !== b64u(sha(m.ct)).slice(0, 22)) return fail(res, 400, "bad-request", "id does not match ct.");
249
+ if (b.ids.has(m.id)) return send(res, 200, { v: 1 });
250
+ if (b.bytes + m.ct.length > maxBytes || b.items.length >= maxBatches) {
251
+ return fail(res, 507, "full", "Mailbox is full.");
252
+ }
253
+ const item = { id: m.id, ct: m.ct };
254
+ if (dir) appendFileSync(join(dir, `${b.id}.jsonl`), JSON.stringify(item) + "\n");
255
+ add(b, item);
256
+ b.last = now();
257
+ return send(res, 201, { v: 1 });
258
+ }
259
+
260
+ const server = createServer((req, res) => {
261
+ handle(req, res).catch(() => {
262
+ if (!res.headersSent) fail(res, 500, "internal", "Server error.");
263
+ else res.end();
264
+ });
265
+ });
266
+ return {
267
+ server,
268
+ listen: (port, host) =>
269
+ new Promise((resolve, reject) => {
270
+ server.once("error", reject);
271
+ server.listen(port, host, () => resolve({ port: /** @type {any} */ (server.address()).port }));
272
+ }),
273
+ close: () =>
274
+ new Promise((resolve) => {
275
+ server.close(() => resolve());
276
+ server.closeAllConnections();
277
+ }),
278
+ };
279
+ }