@pipeup/mailbox 0.6.0 → 0.7.0-beta.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/README.md +17 -0
- package/package.json +1 -1
- package/src/inbox.mjs +213 -0
- package/src/server.mjs +19 -5
package/README.md
CHANGED
|
@@ -147,3 +147,20 @@ logs, so keep those logs as private as you would any capability URL.
|
|
|
147
147
|
```sh
|
|
148
148
|
npm test
|
|
149
149
|
```
|
|
150
|
+
|
|
151
|
+
## A person's inbox (`pm2`)
|
|
152
|
+
|
|
153
|
+
The same server also keeps **inboxes**, for the `@pipeup/inbox` add-on: notes that say "someone mentioned you", sealed
|
|
154
|
+
to the person, so the server stores ciphertext it can't read, from senders it can't name, for people it knows only as
|
|
155
|
+
a hash. `pm1` (mailboxes) is unchanged.
|
|
156
|
+
|
|
157
|
+
| Endpoint | Purpose |
|
|
158
|
+
| ------------------------------------- | ------------------------------------------------------------------------- |
|
|
159
|
+
| `PUT <base>/i/<inbox>/key` | a person publishes their public encryption key, proved by their signature |
|
|
160
|
+
| `GET <base>/i/<inbox>/key` | a sender reads it |
|
|
161
|
+
| `POST <base>/i/<inbox>` | leave a sealed note (`{ ct, work }`: with a small proof of work) |
|
|
162
|
+
| `GET <base>/i/<inbox>?since=<cursor>` | the owner reads what is new |
|
|
163
|
+
| `DELETE <base>/i/<inbox>/<note>` | the owner takes a note away (`X-Pipeup-Key`, `X-Pipeup-Sig`) |
|
|
164
|
+
|
|
165
|
+
Options: `inboxWork` (proof-of-work bits, 14), `inboxToken` (a note then needs `Authorization: Bearer`), `inboxNotes`
|
|
166
|
+
(notes per inbox, 1000). Notes are kept as long as mailboxes are (`keepDays`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pipeup/mailbox",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0-beta.1",
|
|
4
4
|
"description": "Reference HTTP mailbox server for Pipeup shared comments (contract pm1), and a checker for any server.",
|
|
5
5
|
"homepage": "https://pipeup-ai.github.io/pipeup/",
|
|
6
6
|
"author": "Pipeup contributors",
|
package/src/inbox.mjs
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Contract pm2: a person's inbox, for notes that say "someone mentioned you" (docs/design/mentions.md, "Inbox").
|
|
2
|
+
// Notes are sealed to the person, so this server stores ciphertext it can't read, from senders it can't name, for
|
|
3
|
+
// people it knows only as a hash. Part of the mailbox server; the same limits and the same "no accounts" rule.
|
|
4
|
+
import { createHash, createPublicKey, verify } from "node:crypto";
|
|
5
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
9
|
+
const CT = /^pi1\.[A-Za-z0-9_-]+$/;
|
|
10
|
+
const ED = /^[A-Za-z0-9_-]{43}$/;
|
|
11
|
+
const MAX_CT = 8192;
|
|
12
|
+
const MAX_BODY = 16384;
|
|
13
|
+
/** What a person signs: the same framing the page's `sign(purpose, data)` uses, so no signature here can be an op's. */
|
|
14
|
+
const frame = (purpose, data) => `pipeup:${purpose}\n${data}`;
|
|
15
|
+
const sha = (s) => createHash("sha256").update(s).digest();
|
|
16
|
+
const b64u = (b) => Buffer.from(b).toString("base64url");
|
|
17
|
+
|
|
18
|
+
/** The inbox address of a person: the hash of their public key, so a sender can compute it and the server can't reverse it. */
|
|
19
|
+
export function inboxId(edPublicKey) {
|
|
20
|
+
const digest = createHash("sha256")
|
|
21
|
+
.update(Buffer.concat([Buffer.from("pipeup-inbox/1"), Buffer.from(edPublicKey, "base64url")]))
|
|
22
|
+
.digest();
|
|
23
|
+
let bits = 0;
|
|
24
|
+
let acc = 0;
|
|
25
|
+
let out = "";
|
|
26
|
+
for (const byte of digest) {
|
|
27
|
+
acc = (acc << 8) | byte;
|
|
28
|
+
bits += 8;
|
|
29
|
+
while (bits >= 5 && out.length < 26) {
|
|
30
|
+
out += ALPHABET[(acc >>> (bits - 5)) & 31];
|
|
31
|
+
bits -= 5;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Whether a hash has at least `bits` leading zero bits (the small proof of work that makes flooding someone cost something). */
|
|
38
|
+
export function work(inbox, ct, nonce, bits) {
|
|
39
|
+
const d = sha(`${inbox}|${ct}|${nonce}`);
|
|
40
|
+
let n = 0;
|
|
41
|
+
for (const byte of d) {
|
|
42
|
+
if (byte === 0) n += 8;
|
|
43
|
+
else {
|
|
44
|
+
n += Math.clz32(byte) - 24;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
if (n >= bits) break;
|
|
48
|
+
}
|
|
49
|
+
return n >= bits;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const spki = (ed) =>
|
|
53
|
+
createPublicKey({
|
|
54
|
+
key: Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), Buffer.from(ed, "base64url")]),
|
|
55
|
+
format: "der",
|
|
56
|
+
type: "spki",
|
|
57
|
+
});
|
|
58
|
+
const signedBy = (ed, text, sig) => {
|
|
59
|
+
try {
|
|
60
|
+
return ED.test(ed) && verify(null, Buffer.from(text), spki(ed), Buffer.from(sig, "base64url"));
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {object} o
|
|
68
|
+
* @param {string | null} o.dir where inboxes are kept (null: memory only)
|
|
69
|
+
* @param {() => number} o.now
|
|
70
|
+
* @param {number} o.work required proof-of-work bits for a note
|
|
71
|
+
* @param {string} [o.token] when set, a note needs `Authorization: Bearer <it>`
|
|
72
|
+
* @param {number} o.maxNotes per inbox
|
|
73
|
+
* @param {number} o.keepDays 0 keeps for ever
|
|
74
|
+
* @param {object} h shared helpers from the server
|
|
75
|
+
*/
|
|
76
|
+
export function createInbox(o, h) {
|
|
77
|
+
const boxes = new Map();
|
|
78
|
+
if (o.dir) mkdirSync(o.dir, { recursive: true });
|
|
79
|
+
const file = (id, ext) => join(o.dir, `i-${id}.${ext}`);
|
|
80
|
+
const expired = (b) => o.keepDays > 0 && o.now() > b.last + o.keepDays * 864e5;
|
|
81
|
+
function box(id, create = false) {
|
|
82
|
+
let b = boxes.get(id);
|
|
83
|
+
if (!b && o.dir && existsSync(file(id, "json"))) {
|
|
84
|
+
b = JSON.parse(readFileSync(file(id, "json"), "utf8"));
|
|
85
|
+
b.notes = existsSync(file(id, "jsonl"))
|
|
86
|
+
? readFileSync(file(id, "jsonl"), "utf8")
|
|
87
|
+
.split("\n")
|
|
88
|
+
.filter(Boolean)
|
|
89
|
+
.map((l) => JSON.parse(l))
|
|
90
|
+
: [];
|
|
91
|
+
b.ids = new Set(b.notes.map((n) => n.id));
|
|
92
|
+
boxes.set(id, b);
|
|
93
|
+
}
|
|
94
|
+
if (b && expired(b)) {
|
|
95
|
+
boxes.delete(id);
|
|
96
|
+
if (o.dir) for (const e of ["json", "jsonl"]) rmSync(file(id, e), { force: true });
|
|
97
|
+
b = undefined;
|
|
98
|
+
}
|
|
99
|
+
if (!b && create) {
|
|
100
|
+
b = { id, key: null, seq: 0, last: o.now(), notes: [], ids: new Set() };
|
|
101
|
+
boxes.set(id, b);
|
|
102
|
+
}
|
|
103
|
+
return b;
|
|
104
|
+
}
|
|
105
|
+
const meta = (b) => ({ id: b.id, key: b.key, seq: b.seq, last: b.last });
|
|
106
|
+
const save = (b) => o.dir && writeFileSync(file(b.id, "json"), JSON.stringify(meta(b)));
|
|
107
|
+
const rewrite = (b) =>
|
|
108
|
+
o.dir && writeFileSync(file(b.id, "jsonl"), b.notes.map((n) => JSON.stringify(n) + "\n").join(""));
|
|
109
|
+
|
|
110
|
+
/** Handles a request to /i/<inbox>[/key|/<note>]; returns false when the path is not an inbox's. */
|
|
111
|
+
async function handle(req, res, path, method, url) {
|
|
112
|
+
const m = /^(.*)\/i\/([0-9A-Z]{26})(?:\/(key|[A-Za-z0-9_-]{22}))?$/.exec(path);
|
|
113
|
+
if (!m) return false;
|
|
114
|
+
const [, , id, tail] = m;
|
|
115
|
+
const allowed = tail === "key" ? ["GET", "PUT"] : tail ? ["DELETE"] : ["GET", "POST"];
|
|
116
|
+
if (!allowed.includes(method)) {
|
|
117
|
+
h.fail(res, 405, "method", "Method not allowed here.", {}, { Allow: allowed.join(", ") });
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
if (method !== "DELETE") {
|
|
121
|
+
const wait = h.limited(req, method === "GET" ? "GET" : "POST");
|
|
122
|
+
if (wait) {
|
|
123
|
+
h.fail(res, 429, "rate", "Too many requests.", { retryAfter: wait }, { "Retry-After": String(wait) });
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// The person's public encryption key, which senders seal to; set by them, proved by their own signature.
|
|
128
|
+
if (tail === "key") {
|
|
129
|
+
if (method === "GET") {
|
|
130
|
+
const b = box(id);
|
|
131
|
+
if (!b?.key) h.fail(res, 404, "gone", "No key for this inbox yet.");
|
|
132
|
+
else h.send(res, 200, { v: 2, ...b.key });
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
const text = await h.readBody(req);
|
|
136
|
+
const body = text === null ? undefined : h.json(text);
|
|
137
|
+
if (!body) return (h.fail(res, 400, "bad-request", "Body is not a JSON object."), true);
|
|
138
|
+
const { ed, pub, sig } = body;
|
|
139
|
+
if (typeof ed !== "string" || inboxId(ed) !== id || !ED.test(String(pub)) || typeof sig !== "string")
|
|
140
|
+
return (h.fail(res, 400, "bad-request", "That key does not belong to this inbox."), true);
|
|
141
|
+
if (!signedBy(ed, frame("inbox/key", `${id}:${pub}`), sig))
|
|
142
|
+
return (h.fail(res, 403, "signature", "Signature wrong."), true);
|
|
143
|
+
const b = box(id, true);
|
|
144
|
+
b.key = { ed, pub, sig };
|
|
145
|
+
b.last = o.now();
|
|
146
|
+
save(b);
|
|
147
|
+
h.send(res, 200, { v: 2 });
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
if (method === "DELETE") {
|
|
151
|
+
const b = box(id);
|
|
152
|
+
const note = b?.notes.find((n) => n.id === tail);
|
|
153
|
+
if (!b || !note) return (h.fail(res, 404, "gone", "No such note."), true);
|
|
154
|
+
const ed = String(req.headers["x-pipeup-key"] ?? "");
|
|
155
|
+
const sig = String(req.headers["x-pipeup-sig"] ?? "");
|
|
156
|
+
if (inboxId(ed) !== id || !signedBy(ed, frame("inbox/delete", `${id}:${tail}`), sig))
|
|
157
|
+
return (h.fail(res, 403, "signature", "Only the inbox's owner can take a note away."), true);
|
|
158
|
+
b.notes = b.notes.filter((n) => n.id !== tail);
|
|
159
|
+
b.ids.delete(tail);
|
|
160
|
+
rewrite(b);
|
|
161
|
+
h.send(res, 204, null);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
if (method === "GET") {
|
|
165
|
+
const b = box(id);
|
|
166
|
+
if (!b) return (h.fail(res, 404, "gone", "No such inbox."), true);
|
|
167
|
+
const since = url.searchParams.get("since") ?? "";
|
|
168
|
+
if (since !== "" && !/^\d{1,15}$/.test(since))
|
|
169
|
+
return (h.fail(res, 409, "cursor", "Unknown cursor."), true);
|
|
170
|
+
const fresh = b.notes.filter((n) => n.n > Number(since || 0));
|
|
171
|
+
const page = fresh.slice(0, 200);
|
|
172
|
+
h.send(res, 200, {
|
|
173
|
+
v: 2,
|
|
174
|
+
notes: page.map(({ id: noteId, ct }) => ({ id: noteId, ct })),
|
|
175
|
+
next: String(page.length ? page[page.length - 1].n : Number(since || 0)),
|
|
176
|
+
more: fresh.length > page.length,
|
|
177
|
+
});
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
// POST: leave a note.
|
|
181
|
+
const text = await h.readBody(req, MAX_BODY);
|
|
182
|
+
if (text === null) return (h.fail(res, 413, "too-large", "Note too large."), true);
|
|
183
|
+
const body = h.json(text);
|
|
184
|
+
if (!body) return (h.fail(res, 400, "bad-request", "Body is not a JSON object."), true);
|
|
185
|
+
if (o.token) {
|
|
186
|
+
const bearer = (/^Bearer (.+)$/.exec(req.headers.authorization ?? "") ?? [])[1] ?? "";
|
|
187
|
+
if (!h.same(bearer, o.token)) return (h.fail(res, 403, "token", "Note token wrong or missing."), true);
|
|
188
|
+
}
|
|
189
|
+
const { ct, work: nonce } = body;
|
|
190
|
+
if (typeof ct !== "string" || !CT.test(ct) || ct.length > MAX_CT)
|
|
191
|
+
return (h.fail(res, 400, "bad-request", "Bad ct."), true);
|
|
192
|
+
if (typeof nonce !== "string" || nonce.length > 64 || !work(id, ct, nonce, o.work))
|
|
193
|
+
return (
|
|
194
|
+
h.fail(res, 403, "work", `A note needs a proof of work of ${o.work} bits.`, { bits: o.work }),
|
|
195
|
+
true
|
|
196
|
+
);
|
|
197
|
+
const b = box(id, true);
|
|
198
|
+
const noteId = b64u(sha(ct)).slice(0, 22);
|
|
199
|
+
if (b.ids.has(noteId)) return (h.send(res, 200, { v: 2 }), true);
|
|
200
|
+
if (b.notes.length >= o.maxNotes) return (h.fail(res, 507, "full", "This inbox is full."), true);
|
|
201
|
+
const note = { id: noteId, ct, n: ++b.seq };
|
|
202
|
+
b.notes.push(note);
|
|
203
|
+
b.ids.add(noteId);
|
|
204
|
+
b.last = o.now();
|
|
205
|
+
if (o.dir) {
|
|
206
|
+
save(b);
|
|
207
|
+
appendFileSync(file(id, "jsonl"), JSON.stringify(note) + "\n");
|
|
208
|
+
}
|
|
209
|
+
h.send(res, 201, { v: 2 });
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
return { handle };
|
|
213
|
+
}
|
package/src/server.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { createServer } from "node:http";
|
|
|
4
4
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
5
5
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
+
import { createInbox } from "./inbox.mjs";
|
|
7
8
|
|
|
8
9
|
const MAX_BODY = 524288;
|
|
9
10
|
const PAGE_BYTES = 4 * 1024 * 1024;
|
|
@@ -27,6 +28,9 @@ const same = (a, b) => timingSafeEqual(sha(a), sha(b));
|
|
|
27
28
|
* @property {() => number} [now] clock in ms (tests)
|
|
28
29
|
* @property {number} [pageBytes] page size limits (tests)
|
|
29
30
|
* @property {number} [pageBatches]
|
|
31
|
+
* @property {number} [inboxWork] proof-of-work bits a note in a person's inbox needs (default 14; contract pm2)
|
|
32
|
+
* @property {string} [inboxToken] when set, a note needs `Authorization: Bearer <it>`
|
|
33
|
+
* @property {number} [inboxNotes] notes one inbox holds (default 1000)
|
|
30
34
|
*/
|
|
31
35
|
|
|
32
36
|
/**
|
|
@@ -109,14 +113,14 @@ export function createMailboxServer(options = {}) {
|
|
|
109
113
|
}
|
|
110
114
|
|
|
111
115
|
/** Reads the body as text; null when it is over the limit (the rest is drained). */
|
|
112
|
-
function readBody(req) {
|
|
116
|
+
function readBody(req, max = MAX_BODY) {
|
|
113
117
|
return new Promise((resolve) => {
|
|
114
118
|
const parts = [];
|
|
115
119
|
let size = 0;
|
|
116
|
-
let over = Number(req.headers["content-length"] ?? 0) >
|
|
120
|
+
let over = Number(req.headers["content-length"] ?? 0) > max;
|
|
117
121
|
req.on("data", (c) => {
|
|
118
122
|
size += c.length;
|
|
119
|
-
if (size >
|
|
123
|
+
if (size > max) over = true;
|
|
120
124
|
if (!over) parts.push(c);
|
|
121
125
|
});
|
|
122
126
|
req.on("end", () => resolve(over ? null : Buffer.concat(parts).toString("utf8")));
|
|
@@ -132,18 +136,28 @@ export function createMailboxServer(options = {}) {
|
|
|
132
136
|
}
|
|
133
137
|
};
|
|
134
138
|
|
|
139
|
+
const inbox = createInbox(
|
|
140
|
+
{ dir, now, work: o.inboxWork ?? 14, token: o.inboxToken, maxNotes: o.inboxNotes ?? 1000, keepDays },
|
|
141
|
+
{ send, fail, limited, readBody, json, same },
|
|
142
|
+
);
|
|
143
|
+
|
|
135
144
|
async function handle(req, res) {
|
|
136
145
|
const url = new URL(req.url ?? "/", "http://x");
|
|
137
146
|
const path = url.pathname.replace(/\/+$/, "");
|
|
138
147
|
const method = req.method ?? "GET";
|
|
139
148
|
if (method === "OPTIONS") {
|
|
149
|
+
// pm1 answers exactly as before; an inbox (pm2) also needs PUT and its two signature headers.
|
|
150
|
+
const inboxPath = /\/i\/[0-9A-Z]{26}/.test(path);
|
|
140
151
|
return send(res, 204, null, {
|
|
141
|
-
"Access-Control-Allow-Methods": "GET, POST, DELETE",
|
|
142
|
-
"Access-Control-Allow-Headers":
|
|
152
|
+
"Access-Control-Allow-Methods": inboxPath ? "GET, POST, PUT, DELETE" : "GET, POST, DELETE",
|
|
153
|
+
"Access-Control-Allow-Headers": inboxPath
|
|
154
|
+
? "Content-Type, Authorization, X-Pipeup-Key, X-Pipeup-Sig"
|
|
155
|
+
: "Content-Type, Authorization",
|
|
143
156
|
"Access-Control-Max-Age": "86400",
|
|
144
157
|
"Access-Control-Allow-Private-Network": "true",
|
|
145
158
|
});
|
|
146
159
|
}
|
|
160
|
+
if (await inbox.handle(req, res, path, method, url)) return;
|
|
147
161
|
const mm = /^(.*)\/m\/([A-Za-z0-9_-]{22,64})(\/ops)?$/.exec(path);
|
|
148
162
|
const isCreate = /\/m$/.test(path) && !mm;
|
|
149
163
|
if (!mm && !isCreate) return fail(res, 404, "not-found", "No such endpoint.");
|