@snagset/server 0.0.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.
Files changed (105) hide show
  1. package/README.md +83 -0
  2. package/dist/app.js +453 -0
  3. package/dist/app.js.map +1 -0
  4. package/dist/bin.js +951 -0
  5. package/dist/bin.js.map +1 -0
  6. package/dist/boot.js +321 -0
  7. package/dist/boot.js.map +1 -0
  8. package/dist/config.generated.js +116 -0
  9. package/dist/config.generated.js.map +1 -0
  10. package/dist/config.js +180 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/crypto.js +26 -0
  13. package/dist/crypto.js.map +1 -0
  14. package/dist/db/connect.js +49 -0
  15. package/dist/db/connect.js.map +1 -0
  16. package/dist/db/migrate.js +210 -0
  17. package/dist/db/migrate.js.map +1 -0
  18. package/dist/db/migrations/0001_init.sql +1060 -0
  19. package/dist/db/migrations/0002_maintenance_claim.sql +109 -0
  20. package/dist/db/migrations/0003_email_provider.sql +35 -0
  21. package/dist/db/migrations/0004_unsubscribe.sql +24 -0
  22. package/dist/db/rls.js +58 -0
  23. package/dist/db/rls.js.map +1 -0
  24. package/dist/jobs/index.js +171 -0
  25. package/dist/jobs/index.js.map +1 -0
  26. package/dist/jobs/reaper.js +182 -0
  27. package/dist/jobs/reaper.js.map +1 -0
  28. package/dist/keys.js +77 -0
  29. package/dist/keys.js.map +1 -0
  30. package/dist/mail/index.js +75 -0
  31. package/dist/mail/index.js.map +1 -0
  32. package/dist/mail/mailer.js +27 -0
  33. package/dist/mail/mailer.js.map +1 -0
  34. package/dist/mail/notify.js +405 -0
  35. package/dist/mail/notify.js.map +1 -0
  36. package/dist/mail/resend.js +95 -0
  37. package/dist/mail/resend.js.map +1 -0
  38. package/dist/mail/smtp.js +315 -0
  39. package/dist/mail/smtp.js.map +1 -0
  40. package/dist/mail/unsubscribe.js +134 -0
  41. package/dist/mail/unsubscribe.js.map +1 -0
  42. package/dist/projectCache.js +50 -0
  43. package/dist/projectCache.js.map +1 -0
  44. package/dist/projects.js +101 -0
  45. package/dist/projects.js.map +1 -0
  46. package/dist/routes/attachments.js +159 -0
  47. package/dist/routes/attachments.js.map +1 -0
  48. package/dist/routes/auth.js +55 -0
  49. package/dist/routes/auth.js.map +1 -0
  50. package/dist/routes/commentEdit.js +201 -0
  51. package/dist/routes/commentEdit.js.map +1 -0
  52. package/dist/routes/comments.js +313 -0
  53. package/dist/routes/comments.js.map +1 -0
  54. package/dist/routes/cors.js +174 -0
  55. package/dist/routes/cors.js.map +1 -0
  56. package/dist/routes/jobs.js +56 -0
  57. package/dist/routes/jobs.js.map +1 -0
  58. package/dist/routes/mcpAuth.js +187 -0
  59. package/dist/routes/mcpAuth.js.map +1 -0
  60. package/dist/routes/mcpToken.js +94 -0
  61. package/dist/routes/mcpToken.js.map +1 -0
  62. package/dist/routes/owner.js +288 -0
  63. package/dist/routes/owner.js.map +1 -0
  64. package/dist/routes/reviewLinks.js +169 -0
  65. package/dist/routes/reviewLinks.js.map +1 -0
  66. package/dist/routes/rounds.js +137 -0
  67. package/dist/routes/rounds.js.map +1 -0
  68. package/dist/routes/sessions.js +349 -0
  69. package/dist/routes/sessions.js.map +1 -0
  70. package/dist/routes/sites.js +192 -0
  71. package/dist/routes/sites.js.map +1 -0
  72. package/dist/routes/threads.js +1080 -0
  73. package/dist/routes/threads.js.map +1 -0
  74. package/dist/routes/widget.js +132 -0
  75. package/dist/routes/widget.js.map +1 -0
  76. package/dist/serverless.js +97 -0
  77. package/dist/serverless.js.map +1 -0
  78. package/dist/session.js +152 -0
  79. package/dist/session.js.map +1 -0
  80. package/dist/storage/bucket.js +116 -0
  81. package/dist/storage/bucket.js.map +1 -0
  82. package/dist/storage/settings.js +132 -0
  83. package/dist/storage/settings.js.map +1 -0
  84. package/dist/vendor/adapters/index.js +1 -0
  85. package/dist/vendor/adapters/outbound/fence.js +44 -0
  86. package/dist/vendor/adapters/outbound/sanitise.js +233 -0
  87. package/dist/vendor/types/index.js +4 -0
  88. package/dist/vendor/types/names.js +254 -0
  89. package/dist/vendor/widget/object.js +38 -0
  90. package/dist/vendor/widget/predicates.js +377 -0
  91. package/package.json +53 -0
  92. package/static/dashboard/assets/index-CaPfKhFP.css +1 -0
  93. package/static/dashboard/assets/index-DfTNjHCK.js +1 -0
  94. package/static/dashboard/index.html +14 -0
  95. package/static/widget/snag.anchor-ext.js +1 -0
  96. package/static/widget/snag.anchor-fuzzy.js +1 -0
  97. package/static/widget/snag.capture.js +4 -0
  98. package/static/widget/snag.crumbs.js +4 -0
  99. package/static/widget/snag.dock.js +5 -0
  100. package/static/widget/snag.js +5 -0
  101. package/static/widget/snag.panels.js +2 -0
  102. package/static/widget/snag.runtime.js +2 -0
  103. package/static/widget/snag.screenshot.js +1 -0
  104. package/static/widget/snag.text.js +2 -0
  105. package/vercel.json +6 -0
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The `http` adapter — Resend (D57).
3
+ *
4
+ * ONE of the two modules in this codebase allowed to touch a mail transport;
5
+ * `mail.test.ts` asserts there is no third. Everything else depends on
6
+ * `Mailer`.
7
+ *
8
+ * `fetch` and nothing else: Resend's API is one POST with a JSON body and a
9
+ * bearer token, and its own SDK is a wrapper around exactly that. A dependency
10
+ * whose job is to build one request object is a dependency that has to be
11
+ * audited, updated and shipped inside a package whose pitch is `npx snagset`.
12
+ * The same argument settled S3 in this repo (aws4fetch over the AWS SDK) and it
13
+ * settles this.
14
+ */
15
+ const ENDPOINT = "https://api.resend.com/emails";
16
+ /**
17
+ * How long to wait before giving up on the API.
18
+ *
19
+ * Bounded because the caller is a JOB with a budget (`runDueJobs`), and an
20
+ * un-timed fetch inside a budgeted tick means the budget is a suggestion. Ten
21
+ * seconds is far beyond Resend's normal response and far below the 20s tick
22
+ * budget, so a hung request loses one job's turn rather than the whole tick.
23
+ */
24
+ const TIMEOUT_MS = 10_000;
25
+ export function createResendMailer(apiKey, from) {
26
+ return {
27
+ kind: "http",
28
+ from,
29
+ async send(mail) {
30
+ const res = await fetch(ENDPOINT, {
31
+ method: "POST",
32
+ headers: {
33
+ authorization: `Bearer ${apiKey}`,
34
+ "content-type": "application/json",
35
+ },
36
+ body: JSON.stringify({
37
+ from,
38
+ to: [mail.to],
39
+ subject: mail.subject,
40
+ text: mail.text,
41
+ ...(mail.html === undefined ? {} : { html: mail.html }),
42
+ // Resend takes custom headers as an object on the send payload.
43
+ ...(mail.headers === undefined ? {} : { headers: mail.headers }),
44
+ }),
45
+ signal: AbortSignal.timeout(TIMEOUT_MS),
46
+ });
47
+ if (!res.ok)
48
+ throw new Error(await describe(res));
49
+ // Resend answers `{ id }`. A body that does not parse is not a failure to
50
+ // send — the mail is already accepted — so the id is dropped rather than
51
+ // the send being reported as failed and retried into a duplicate.
52
+ const id = await res
53
+ .json()
54
+ .then((b) => b.id ?? null)
55
+ .catch(() => null);
56
+ return { id };
57
+ },
58
+ };
59
+ }
60
+ /**
61
+ * Turn a Resend error into a sentence that names the fix.
62
+ *
63
+ * The same reasoning as the S3 error mapper next door: the raw body is JSON
64
+ * around one useful word, and the operator reading this at 9pm needs the next
65
+ * step rather than the status code. The three below are the ones a first
66
+ * configuration actually hits — an unverified sending domain in particular,
67
+ * which is the single most common Resend setup failure and whose message
68
+ * ("domain is not verified") does not say where to go.
69
+ */
70
+ async function describe(res) {
71
+ const body = await res.text().catch(() => "");
72
+ const message = (() => {
73
+ try {
74
+ return JSON.parse(body).message ?? body.slice(0, 200);
75
+ }
76
+ catch {
77
+ return body.slice(0, 200);
78
+ }
79
+ })();
80
+ if (res.status === 401 || res.status === 403) {
81
+ return (`resend rejected the API key (${res.status}). Check the key in SNAGSET_MAIL_URL — ` +
82
+ `it starts "re_" and is created at resend.com/api-keys. A key with only ` +
83
+ `"sending access" to a different domain fails here too.`);
84
+ }
85
+ if (res.status === 422 && /domain/i.test(message)) {
86
+ return (`resend refused the From address "${message}". The sending DOMAIN must be ` +
87
+ `verified at resend.com/domains before it will accept mail from it — ` +
88
+ `check SNAGSET_MAIL_FROM uses a domain you have added there.`);
89
+ }
90
+ if (res.status === 429) {
91
+ return `resend rate-limited us (429). The outbox will retry with backoff; no mail was lost.`;
92
+ }
93
+ return `resend returned ${res.status}: ${message}`;
94
+ }
95
+ //# sourceMappingURL=resend.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resend.js","sourceRoot":"","sources":["../../src/mail/resend.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,315 @@
1
+ import { createConnection } from "node:net";
2
+ import { connect as tlsConnect } from "node:tls";
3
+ import { randomUUID } from "node:crypto";
4
+ /**
5
+ * The `smtp` adapter — the second and last module allowed to touch a mail
6
+ * transport (LOCK L7). `mail.test.ts` asserts there is no third.
7
+ *
8
+ * ── WHY THIS IS HAND-ROLLED ──
9
+ *
10
+ * The same argument as `resend.ts` and as this repo's S3 client: the subset of
11
+ * ESMTP a transactional sender needs is EHLO, optional STARTTLS, optional AUTH,
12
+ * MAIL/RCPT/DATA, QUIT. That is a small, rigidly specified protocol. Against it
13
+ * sits a dependency tree that ships inside a package whose entire pitch is
14
+ * `npx snagset`, and which has to be audited and updated for the life of the
15
+ * product.
16
+ *
17
+ * The risk is real and worth naming: a hand-rolled SMTP client that gets AUTH
18
+ * or line endings wrong sends NOTHING, and mail is exactly where a subtle
19
+ * protocol bug hides. That is why `smtp.test.ts` drives this against a real TCP
20
+ * server speaking real SMTP rather than against a mocked socket — the bugs this
21
+ * class of code has are wire bugs, and a mock cannot have them.
22
+ *
23
+ * ── TWO DECISIONS THAT REMOVE WHOLE CATEGORIES OF BUG ──
24
+ *
25
+ * **Everything is base64.** Bodies are base64 with charset=utf-8 and subjects
26
+ * are RFC 2047 base64 encoded-words. This is not about size — base64 is 33%
27
+ * bigger than quoted-printable for ASCII text. It is that the base64 alphabet
28
+ * contains no `.` and no CR/LF, which makes DOT-STUFFING (RFC 5321 §4.5.2)
29
+ * and the 998-character line limit unreachable by construction. A line of a
30
+ * reviewer's comment that happens to begin with a full stop is a real message
31
+ * that a naive sender truncates, and this is the version of the fix that cannot
32
+ * be forgotten in one branch.
33
+ *
34
+ * **CRLF, always, everywhere.** `\n` alone is accepted by some servers and
35
+ * silently mangles the message on others. One constant, used everywhere.
36
+ */
37
+ const CRLF = "\r\n";
38
+ const CONNECT_TIMEOUT_MS = 10_000;
39
+ /** Per command. A server that accepts the connection and then stops answering
40
+ * is the failure mode a socket-level timeout does not catch. */
41
+ const REPLY_TIMEOUT_MS = 15_000;
42
+ export function createSmtpMailer(opts) {
43
+ const url = new URL(opts.url);
44
+ const implicitTls = url.protocol === "smtps:";
45
+ const port = url.port ? Number(url.port) : implicitTls ? 465 : 587;
46
+ const host = url.hostname;
47
+ const user = url.username ? decodeURIComponent(url.username) : null;
48
+ const pass = url.password ? decodeURIComponent(url.password) : null;
49
+ return {
50
+ kind: "smtp",
51
+ from: opts.from,
52
+ async send(mail) {
53
+ const messageId = `<${randomUUID()}@${senderDomain(opts.from)}>`;
54
+ const session = await open(host, port, implicitTls);
55
+ try {
56
+ await session.expect(220);
57
+ let caps = await ehlo(session, host);
58
+ if (!implicitTls && caps.has("STARTTLS")) {
59
+ await session.command("STARTTLS", 220);
60
+ await session.upgrade(host);
61
+ // RFC 3207 §4.2: everything the server said before STARTTLS is
62
+ // discarded and EHLO is re-issued. Servers routinely advertise AUTH
63
+ // only after the upgrade, so skipping this makes authentication
64
+ // silently unavailable on a correctly configured server.
65
+ caps = await ehlo(session, host);
66
+ }
67
+ if (user && pass)
68
+ await authenticate(session, caps, user, pass);
69
+ await session.command(`MAIL FROM:<${opts.from}>`, 250);
70
+ await session.command(`RCPT TO:<${mail.to}>`, 250);
71
+ await session.command("DATA", 354);
72
+ await session.raw(build(mail, opts.from, messageId) + CRLF + "." + CRLF, 250);
73
+ // QUIT is best-effort: the message is accepted at the 250 above, and a
74
+ // server that drops the connection instead of answering QUIT has still
75
+ // taken it. Treating that as a send failure would retry a delivered
76
+ // message — the one outcome the outbox exists to prevent.
77
+ await session.command("QUIT", 221).catch(() => { });
78
+ return { id: messageId };
79
+ }
80
+ finally {
81
+ session.close();
82
+ }
83
+ },
84
+ };
85
+ }
86
+ /** EHLO, and the capability set it returns, upper-cased for comparison. */
87
+ async function ehlo(session, host) {
88
+ const reply = await session.command(`EHLO ${clientName(host)}`, 250);
89
+ const caps = new Set();
90
+ for (const line of reply.split(CRLF).slice(1)) {
91
+ const verb = line.slice(4).trim().toUpperCase();
92
+ if (verb)
93
+ caps.add(verb.split(" ")[0]);
94
+ // AUTH's mechanisms are on the same line as the verb.
95
+ if (verb.startsWith("AUTH "))
96
+ for (const m of verb.slice(5).split(/\s+/))
97
+ caps.add(`AUTH=${m}`);
98
+ }
99
+ return caps;
100
+ }
101
+ /**
102
+ * AUTH, preferring PLAIN.
103
+ *
104
+ * PLAIN is one round trip and LOGIN is three; both send the password base64'd,
105
+ * which is encoding and not encryption — so this refuses to run at all on a
106
+ * connection that is not TLS. An adapter that would rather authenticate in
107
+ * cleartext than fail is an adapter that leaks the operator's mail password to
108
+ * anyone on the path, once, silently, and then works fine.
109
+ */
110
+ async function authenticate(session, caps, user, pass) {
111
+ if (!session.secure) {
112
+ throw new Error("refusing to send SMTP credentials over an unencrypted connection. " +
113
+ "The server did not offer STARTTLS — use smtps:// (implicit TLS, usually " +
114
+ "port 465), or a server that supports STARTTLS on 587.");
115
+ }
116
+ if (caps.has("AUTH=PLAIN") || (!caps.has("AUTH=LOGIN") && caps.has("AUTH"))) {
117
+ const token = Buffer.from(`\0${user}\0${pass}`, "utf8").toString("base64");
118
+ await session.command(`AUTH PLAIN ${token}`, 235);
119
+ return;
120
+ }
121
+ if (caps.has("AUTH=LOGIN")) {
122
+ await session.command("AUTH LOGIN", 334);
123
+ await session.command(Buffer.from(user, "utf8").toString("base64"), 334);
124
+ await session.command(Buffer.from(pass, "utf8").toString("base64"), 235);
125
+ return;
126
+ }
127
+ throw new Error("the SMTP server advertised no authentication mechanism this client supports " +
128
+ "(PLAIN or LOGIN). Credentials were supplied in SNAGSET_MAIL_URL, so it was " +
129
+ "expected to need them.");
130
+ }
131
+ /** The message itself: headers, then either one part or a multipart pair. */
132
+ function build(mail, from, messageId) {
133
+ const headers = [
134
+ `From: ${from}`,
135
+ `To: ${mail.to}`,
136
+ `Subject: ${encodeHeader(mail.subject)}`,
137
+ `Message-ID: ${messageId}`,
138
+ `Date: ${new Date().toUTCString()}`,
139
+ "MIME-Version: 1.0",
140
+ ];
141
+ // Extra headers before the Content-Type block, because everything after that
142
+ // point describes the BODY and a header appended there would land inside the
143
+ // part rather than on the message. CRLF and newline are stripped from the
144
+ // value: a header injection here would let anything that reaches this
145
+ // function add arbitrary headers, and one of them is `Bcc`.
146
+ for (const [name, value] of Object.entries(mail.headers ?? {})) {
147
+ headers.push(`${name}: ${encodeHeader(String(value).replace(/[\r\n]+/g, " "))}`);
148
+ }
149
+ if (mail.html === undefined) {
150
+ headers.push('Content-Type: text/plain; charset="utf-8"', "Content-Transfer-Encoding: base64");
151
+ return headers.join(CRLF) + CRLF + CRLF + base64Lines(mail.text);
152
+ }
153
+ // The boundary is random rather than fixed: a fixed boundary that appears in
154
+ // a reviewer's comment ends the part early and truncates the mail. Random
155
+ // does not make that impossible, but 32 hex characters makes it a
156
+ // non-event, where a memorable constant makes it a matter of time.
157
+ const boundary = `=_${randomUUID().replace(/-/g, "")}`;
158
+ headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
159
+ // Plain part FIRST: RFC 2046 §5.1.4 orders alternatives worst-to-best, and a
160
+ // client shows the LAST one it understands. Reversed, every graphical client
161
+ // shows the plain text.
162
+ const parts = [
163
+ `--${boundary}`,
164
+ 'Content-Type: text/plain; charset="utf-8"',
165
+ "Content-Transfer-Encoding: base64",
166
+ "",
167
+ base64Lines(mail.text),
168
+ `--${boundary}`,
169
+ 'Content-Type: text/html; charset="utf-8"',
170
+ "Content-Transfer-Encoding: base64",
171
+ "",
172
+ base64Lines(mail.html),
173
+ `--${boundary}--`,
174
+ ];
175
+ return headers.join(CRLF) + CRLF + CRLF + parts.join(CRLF);
176
+ }
177
+ /** RFC 2047 encoded-word, but only when the subject needs one: an ASCII
178
+ * subject stays readable in a raw message, which matters when the thing you
179
+ * are debugging is why the mail did not arrive. */
180
+ function encodeHeader(s) {
181
+ // eslint-disable-next-line no-control-regex
182
+ if (/^[\x20-\x7E]*$/.test(s))
183
+ return s;
184
+ return `=?utf-8?B?${Buffer.from(s, "utf8").toString("base64")}?=`;
185
+ }
186
+ /** Base64, wrapped at 76 characters per RFC 2045 §6.8. Servers are entitled to
187
+ * reject longer lines and some do. */
188
+ function base64Lines(s) {
189
+ const b64 = Buffer.from(s, "utf8").toString("base64");
190
+ const out = [];
191
+ for (let i = 0; i < b64.length; i += 76)
192
+ out.push(b64.slice(i, i + 76));
193
+ return out.join(CRLF);
194
+ }
195
+ /** The domain half of the From address, for the Message-ID. */
196
+ function senderDomain(from) {
197
+ const at = from.lastIndexOf("@");
198
+ const domain = at === -1 ? "" : from.slice(at + 1).replace(/[>\s]/g, "");
199
+ return domain || "localhost";
200
+ }
201
+ /** What we call ourselves in EHLO. Not the server's name — ours — and a bare
202
+ * hostname is what a server expects from a client without a public FQDN. */
203
+ function clientName(host) {
204
+ return /^[\d.]+$/.test(host) || host.includes(":") ? "[127.0.0.1]" : "snagset";
205
+ }
206
+ async function open(host, port, tls) {
207
+ let socket = await new Promise((resolve, reject) => {
208
+ const s = tls
209
+ ? tlsConnect({ host, port, servername: host }, () => resolve(s))
210
+ : createConnection({ host, port }, () => resolve(s));
211
+ s.setTimeout(CONNECT_TIMEOUT_MS, () => {
212
+ s.destroy();
213
+ reject(new Error(`timed out connecting to ${host}:${port} after ${CONNECT_TIMEOUT_MS}ms`));
214
+ });
215
+ s.once("error", reject);
216
+ });
217
+ let secure = tls;
218
+ // One buffer for the whole session. SMTP replies arrive in whatever chunks
219
+ // the network chose, and a reply is not complete until a line matches
220
+ // `NNN<space>` — the continuation form is `NNN-`. Reading per-chunk instead
221
+ // is the classic bug: it works locally and fails against a server that
222
+ // sends its multi-line EHLO in two packets.
223
+ let buffer = "";
224
+ let waiter = null;
225
+ let closed = null;
226
+ const settle = () => {
227
+ if (!waiter)
228
+ return;
229
+ const match = /^\d{3} [^\r\n]*\r\n/m.exec(buffer);
230
+ if (!match)
231
+ return;
232
+ const end = buffer.indexOf(match[0]) + match[0].length;
233
+ const reply = buffer.slice(0, end);
234
+ buffer = buffer.slice(end);
235
+ const w = waiter;
236
+ waiter = null;
237
+ w.resolve(reply);
238
+ };
239
+ const attach = (s) => {
240
+ s.setEncoding("utf8");
241
+ s.on("data", (chunk) => {
242
+ buffer += chunk;
243
+ settle();
244
+ });
245
+ s.on("error", (e) => {
246
+ closed = e;
247
+ waiter?.reject(e);
248
+ waiter = null;
249
+ });
250
+ s.on("close", () => {
251
+ closed ??= new Error("the SMTP server closed the connection");
252
+ waiter?.reject(closed);
253
+ waiter = null;
254
+ });
255
+ };
256
+ attach(socket);
257
+ const read = () => new Promise((resolve, reject) => {
258
+ if (closed)
259
+ return reject(closed);
260
+ waiter = { resolve, reject };
261
+ settle();
262
+ const timer = setTimeout(() => {
263
+ if (waiter) {
264
+ waiter = null;
265
+ reject(new Error(`the SMTP server did not reply within ${REPLY_TIMEOUT_MS}ms`));
266
+ }
267
+ }, REPLY_TIMEOUT_MS);
268
+ timer.unref();
269
+ });
270
+ const check = (reply, want, sent) => {
271
+ const code = Number(reply.slice(0, 3));
272
+ if (code !== want) {
273
+ // The command is echoed because "550 5.7.1 rejected" without it is
274
+ // unattributable — the same code means a bad sender on MAIL FROM and a
275
+ // bad recipient on RCPT TO.
276
+ throw new Error(`SMTP ${sent} → ${reply.trim()}`);
277
+ }
278
+ return reply;
279
+ };
280
+ return {
281
+ get secure() {
282
+ return secure;
283
+ },
284
+ async expect(code) {
285
+ return check(await read(), code, "<greeting>");
286
+ },
287
+ async command(line, code) {
288
+ socket.write(line + CRLF);
289
+ // The password is never echoed into an error message.
290
+ const shown = /^AUTH /.test(line) ? line.split(" ").slice(0, 2).join(" ") : line;
291
+ return check(await read(), code, shown);
292
+ },
293
+ async raw(payload, code) {
294
+ socket.write(payload);
295
+ return check(await read(), code, "DATA");
296
+ },
297
+ async upgrade(hostname) {
298
+ const plain = socket;
299
+ plain.removeAllListeners("data");
300
+ plain.removeAllListeners("error");
301
+ plain.removeAllListeners("close");
302
+ socket = await new Promise((resolve, reject) => {
303
+ const s = tlsConnect({ socket: plain, servername: hostname }, () => resolve(s));
304
+ s.once("error", reject);
305
+ });
306
+ secure = true;
307
+ buffer = "";
308
+ attach(socket);
309
+ },
310
+ close() {
311
+ socket.destroy();
312
+ },
313
+ };
314
+ }
315
+ //# sourceMappingURL=smtp.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"smtp.js","sourceRoot":"","sources":["../../src/mail/smtp.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,134 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { Hono } from "hono";
3
+ /**
4
+ * One-click unsubscribe — B2, and the thing that has to exist before a single
5
+ * notification is sent to a reviewer.
6
+ *
7
+ * ── WHY THIS IS NOT OPTIONAL ──
8
+ *
9
+ * Until now the only notification went to the project OWNER, whose address was
10
+ * verified at claim time. Reviewer addresses are self-declared, and a system
11
+ * that mails a self-declared address on a stranger's trigger with no way out
12
+ * is an open relay with better manners. RFC 8058 and the bulk-sender rules at
13
+ * Gmail and Yahoo both say the same thing in different words: carry a
14
+ * one-click opt-out or expect the spam folder.
15
+ *
16
+ * ── WHY IT CANNOT USE A SESSION ──
17
+ *
18
+ * The person clicking may have lost their review link, closed the tab, or
19
+ * never held a session at all — and none of that is a reason to keep mailing
20
+ * them. So the link is not authenticated by *who is asking*; it is
21
+ * authenticated by being unforgeable. An HMAC over the reviewer's id, under
22
+ * its own key purpose (08 §4.10), is exactly that, and the separate purpose is
23
+ * what stops an unsubscribe link from ever being accepted as a session or a
24
+ * share signature.
25
+ *
26
+ * ── WHY GET DOES NOT UNSUBSCRIBE ──
27
+ *
28
+ * Mail clients and security scanners fetch every link in a message. A GET that
29
+ * mutates would unsubscribe people who never clicked — silently, and in a way
30
+ * they could not detect. So GET renders a confirmation and POST performs it,
31
+ * which is also precisely what RFC 8058's `List-Unsubscribe-Post` specifies:
32
+ * the client posts `List-Unsubscribe=One-Click`, and only a POST changes
33
+ * anything.
34
+ */
35
+ /** `<reviewerId>.<mac>` — the whole token, opaque to the reader. */
36
+ export function unsubscribeToken(keys, reviewerId) {
37
+ const mac = createHmac("sha256", keys.derive("unsubscribe")).update(reviewerId).digest("base64url");
38
+ return `${reviewerId}.${mac}`;
39
+ }
40
+ /**
41
+ * The reviewer id this token vouches for, or null.
42
+ *
43
+ * No `kid` and therefore no rotation overlap, deliberately: an unsubscribe
44
+ * link lives in somebody's mailbox for years, and the failure mode of a
45
+ * rotated key here is "the opt-out link in a two-year-old email stops
46
+ * working", which is the one outcome this whole file exists to prevent. The
47
+ * token authorises exactly one irreversible-in-the-safe-direction act, so a
48
+ * long-lived signature is the right trade.
49
+ */
50
+ export function readUnsubscribeToken(keys, token) {
51
+ const cut = token.lastIndexOf(".");
52
+ if (cut <= 0)
53
+ return null;
54
+ const id = token.slice(0, cut);
55
+ const given = Buffer.from(token.slice(cut + 1), "base64url");
56
+ const want = createHmac("sha256", keys.derive("unsubscribe")).update(id).digest();
57
+ // Length check first: timingSafeEqual THROWS on a length mismatch rather
58
+ // than returning false, so a short token would be a 500 instead of a 404.
59
+ if (given.length !== want.length || !timingSafeEqual(given, want))
60
+ return null;
61
+ return id;
62
+ }
63
+ /** The header pair a notification carries. RFC 8058. */
64
+ export function unsubscribeHeaders(baseUrl, token) {
65
+ return {
66
+ "List-Unsubscribe": `<${baseUrl.replace(/\/+$/, "")}/unsubscribe?t=${encodeURIComponent(token)}>`,
67
+ // Without this line the URL is treated as an ordinary link and the
68
+ // provider renders no unsubscribe button — the header pair is the feature,
69
+ // not either half of it.
70
+ "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
71
+ };
72
+ }
73
+ const page = (title, body) => `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">` +
74
+ `<title>${title}</title>` +
75
+ `<style>body{font:16px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif;color:#16181d;background:#f7f7f5;` +
76
+ `margin:0;display:grid;place-items:center;min-height:100vh;padding:24px}` +
77
+ `main{background:#fff;border:1px solid #e3e3df;border-radius:6px;padding:28px 32px;max-width:34rem}` +
78
+ `h1{font-size:20px;margin:0 0 12px}p{margin:0 0 16px;color:#5b6270}` +
79
+ `button{font:inherit;padding:9px 16px;border-radius:6px;border:1px solid #2b53d8;background:#2b53d8;color:#fff;cursor:pointer}` +
80
+ `@media(prefers-color-scheme:dark){body{background:#101216;color:#e9eaee}main{background:#171a20;border-color:#262a33}p{color:#a2a9b8}}` +
81
+ `</style><main>${body}</main>`;
82
+ export function unsubscribeRoutes(deps) {
83
+ const app = new Hono();
84
+ const { sql, keys } = deps;
85
+ /**
86
+ * Look the reviewer up WITHOUT revealing whether the id exists.
87
+ *
88
+ * A valid signature already proves the id came from us, so there is nothing
89
+ * to enumerate — but a "no such person" page would still tell a forwarded
90
+ * recipient something about somebody else's account, and the useful answer
91
+ * in every case is the same: you will not be mailed.
92
+ */
93
+ const nameFor = async (id) => {
94
+ const [row] = await sql `
95
+ SELECT name FROM reviewers WHERE id = ${id}::uuid AND erased_at IS NULL`;
96
+ return row?.name ?? null;
97
+ };
98
+ app.get("/unsubscribe", async (c) => {
99
+ const token = c.req.query("t") ?? "";
100
+ const id = readUnsubscribeToken(keys, token);
101
+ if (!id) {
102
+ return c.html(page("Link not recognised", `<h1>That link is not one of ours</h1><p>It may have been altered in transit. If you are still receiving mail you did not ask for, reply to it and a person will sort it out.</p>`), 404);
103
+ }
104
+ const name = await nameFor(id).catch(() => null);
105
+ return c.html(page("Stop these emails?", `<h1>Stop emailing ${name ? escapeHtml(name) : "you"} about this review?</h1>` +
106
+ `<p>You will stop receiving notifications about comments you wrote or replied to. ` +
107
+ `Your comments stay exactly as they are, and you can still open your review link at any time.</p>` +
108
+ `<form method="post" action="/unsubscribe"><input type="hidden" name="t" value="${escapeHtml(token)}">` +
109
+ `<button type="submit">Stop the emails</button></form>`));
110
+ });
111
+ app.post("/unsubscribe", async (c) => {
112
+ // The token arrives as a form field from our own confirmation page, or as
113
+ // a query parameter from a provider's one-click POST, which sends
114
+ // `List-Unsubscribe=One-Click` as the BODY and keeps the URL intact.
115
+ const form = await c.req.parseBody().catch(() => ({}));
116
+ const token = String(form["t"] ?? c.req.query("t") ?? "");
117
+ const id = readUnsubscribeToken(keys, token);
118
+ if (!id)
119
+ return c.text("not found", 404);
120
+ // Idempotent: clicking twice, or a provider retrying, is the same outcome
121
+ // as clicking once. `COALESCE` keeps the FIRST opt-out time, which is the
122
+ // one a deliverability complaint asks about.
123
+ await sql `
124
+ UPDATE reviewers
125
+ SET notifications_off_at = COALESCE(notifications_off_at, now())
126
+ WHERE id = ${id}::uuid`;
127
+ return c.html(page("Unsubscribed", `<h1>Done — no more emails</h1><p>You will not be notified about this review again. Nothing else changed.</p>`));
128
+ });
129
+ return app;
130
+ }
131
+ function escapeHtml(s) {
132
+ return s.replace(/[&<>"']/g, (ch) => ch === "&" ? "&amp;" : ch === "<" ? "&lt;" : ch === ">" ? "&gt;" : ch === '"' ? "&quot;" : "&#39;");
133
+ }
134
+ //# sourceMappingURL=unsubscribe.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"unsubscribe.js","sourceRoot":"","sources":["../../src/mail/unsubscribe.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,50 @@
1
+ export class ProjectUnavailable extends Error {
2
+ constructor(cause) {
3
+ super(`the project cache could not refresh: ${cause}`);
4
+ this.name = "ProjectUnavailable";
5
+ }
6
+ }
7
+ export function createProjectCache(sql, opts = {}) {
8
+ const ttl = opts.ttlMs ?? 10_000;
9
+ const now = opts.now ?? (() => Date.now());
10
+ const entries = new Map();
11
+ // One in-flight query per uid: a cold cache under load would otherwise send
12
+ // one identical query per concurrent request, which is the stampede the
13
+ // cache exists to prevent.
14
+ const inflight = new Map();
15
+ async function load(uid) {
16
+ const rows = await sql `
17
+ SELECT id, org_id, uid, status, write_mode, write_mode_downgrade_at, read_mode,
18
+ session_epoch, config_version, config_etag, key_prefix, ip_salt
19
+ FROM projects WHERE uid = ${uid} AND deleted_at IS NULL
20
+ `;
21
+ return rows[0] ?? null;
22
+ }
23
+ return {
24
+ async get(uid) {
25
+ const hit = entries.get(uid);
26
+ if (hit && now() - hit.at < ttl)
27
+ return hit.row;
28
+ let pending = inflight.get(uid);
29
+ if (!pending) {
30
+ pending = load(uid)
31
+ .then((row) => {
32
+ entries.set(uid, { at: now(), row });
33
+ return row;
34
+ })
35
+ .catch((e) => {
36
+ // Do NOT fall back to `hit.row`. See the header.
37
+ entries.delete(uid);
38
+ throw new ProjectUnavailable(e instanceof Error ? e.message : String(e));
39
+ })
40
+ .finally(() => inflight.delete(uid));
41
+ inflight.set(uid, pending);
42
+ }
43
+ return pending;
44
+ },
45
+ invalidate(uid) {
46
+ entries.delete(uid);
47
+ },
48
+ };
49
+ }
50
+ //# sourceMappingURL=projectCache.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"projectCache.js","sourceRoot":"","sources":["../src/projectCache.ts"],"names":[],"mappings":"AAyCA,MAAM,OAAO,kBAAmB,SAAQ,KAAK;IAC3C,YAAY,KAAa;QACvB,KAAK,CAAC,wCAAwC,KAAK,EAAE,CAAC,CAAC;QACvD,IAAI,CAAC,IAAI,GAAG,oBAAoB,CAAC;IACnC,CAAC;CACF;AAgBD,MAAM,UAAU,kBAAkB,CAChC,GAAiB,EACjB,OAA+C,EAAE;IAEjD,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,IAAI,MAAM,CAAC;IACjC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;IAC3C,MAAM,OAAO,GAAG,IAAI,GAAG,EAAiB,CAAC;IACzC,4EAA4E;IAC5E,wEAAwE;IACxE,2BAA2B;IAC3B,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAsC,CAAC;IAE/D,KAAK,UAAU,IAAI,CAAC,GAAW;QAC7B,MAAM,IAAI,GAAG,MAAM,GAAG,CAAc;;;kCAGN,GAAG;KAChC,CAAC;QACF,OAAO,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC;IACzB,CAAC;IAED,OAAO;QACL,KAAK,CAAC,GAAG,CAAC,GAAG;YACX,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;YAC7B,IAAI,GAAG,IAAI,GAAG,EAAE,GAAG,GAAG,CAAC,EAAE,GAAG,GAAG;gBAAE,OAAO,GAAG,CAAC,GAAG,CAAC;YAEhD,IAAI,OAAO,GAAG,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;YAChC,IAAI,CAAC,OAAO,EAAE,CAAC;gBACb,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC;qBAChB,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE;oBACZ,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,EAAE,EAAE,GAAG,EAAE,EAAE,GAAG,EAAE,CAAC,CAAC;oBACrC,OAAO,GAAG,CAAC;gBACb,CAAC,CAAC;qBACD,KAAK,CAAC,CAAC,CAAU,EAAE,EAAE;oBACpB,iDAAiD;oBACjD,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;oBACpB,MAAM,IAAI,kBAAkB,CAAC,CAAC,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;gBAC3E,CAAC,CAAC;qBACD,OAAO,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;gBACvC,QAAQ,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;YAC7B,CAAC;YACD,OAAO,OAAO,CAAC;QACjB,CAAC;QACD,UAAU,CAAC,GAAG;YACZ,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QACtB,CAAC;KACF,CAAC;AACJ,CAAC"}