@lampdotdev/lamp 0.35.0 → 0.36.2-nightly.17

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.
@@ -1,4 +1,4 @@
1
- import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
1
+ import { createHash, createHmac, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
2
2
  import { isIP } from "node:net";
3
3
  import { inflateRawSync } from "node:zlib";
4
4
  import { redis, reserve } from "./quota.js";
@@ -71,7 +71,7 @@ export const isID = (id) => typeof id === "string" && id.length === ID_LENGTH &&
71
71
 
72
72
  /** The secret that lets the sharer's Lamp change or remove the link. Only its hash is kept here. */
73
73
  export const newSecret = () => randomBytes(32).toString("base64url");
74
- const hashSecret = (secret) => createHash("sha256").update(secret).digest("hex");
74
+ export const hashSecret = (secret) => createHash("sha256").update(secret).digest("hex");
75
75
  function secretMatches(secret, hash) {
76
76
  if (typeof secret !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(secret) || typeof hash !== "string") return false;
77
77
  const a = Buffer.from(hashSecret(secret), "hex"), b = Buffer.from(hash, "hex");
@@ -138,9 +138,10 @@ function release(value) {
138
138
 
139
139
  /** What a share sends, checked. Null when anything is off. `releases` is undefined when the share didn't say (a Lamp
140
140
  * from before them), so a change keeps the ones the link has. */
141
+ const GITHUB_REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/(?!\.\.?$)[A-Za-z0-9._-]{1,100}$/;
141
142
  export function readShare(body) {
142
143
  if (!body || typeof body !== "object" || Array.isArray(body)) return null;
143
- const known = ["name", "by", "line", "words", "ideas", "asks", "shape", "releases", "picture", "icon"];
144
+ const known = ["name", "by", "line", "words", "ideas", "asks", "shape", "releases", "picture", "icon", "github"];
144
145
  if (Object.keys(body).some((key) => !known.includes(key))) return null;
145
146
  const name = tidy(body.name, LIMITS.name);
146
147
  const words = tidy(body.words, LIMITS.words, { lines: true });
@@ -156,16 +157,19 @@ export function readShare(body) {
156
157
  ? body.releases.map(release) : [null];
157
158
  const shot = picture(body.picture, LIMITS.picture);
158
159
  const icon = picture(body.icon, LIMITS.icon, { square: true });
159
- if (!name || !words || line == null || by == null || ideas.includes(null) || asks.includes(null) || shape === undefined || releases?.includes(null) || !shot || !icon) return null;
160
+ // The app's public repository on GitHub, as "owner/name", which the page links to: nothing else is taken.
161
+ const github = body.github == null || body.github === "" ? null : GITHUB_REPO.test(body.github) ? body.github : undefined;
162
+ if (!name || !words || line == null || by == null || ideas.includes(null) || asks.includes(null) || shape === undefined || releases?.includes(null) || !shot || !icon || github === undefined) return null;
160
163
  return { name, by, line, words, ideas, asks, shape, releases: releases?.sort((a, b) => b.date.localeCompare(a.date)),
161
- picture: shot.bytes ? shot : null, icon: icon.bytes ? icon : null };
164
+ picture: shot.bytes ? shot : null, icon: icon.bytes ? icon : null, github };
162
165
  }
163
166
 
164
167
  /** What anyone may see of a shared wish: never its secret. */
165
168
  export const shown = (record) => record && ({
166
169
  id: record.id, name: record.name, by: record.by ?? "", line: record.line, words: record.words, ideas: record.ideas ?? [], asks: record.asks ?? [], shape: record.shape ?? null,
167
- releases: record.releases ?? [], picture: record.picture, icon: record.icon, file: record.file ?? null, updated: record.updated,
170
+ releases: record.releases ?? [], picture: record.picture, icon: record.icon, file: record.file ?? null, updated: record.updated, github: record.github ?? null,
168
171
  page: record.page ? { version: record.page.version } : null,
172
+ ...(record.owner && record.app ? { owner: record.owner, app: record.app } : {}),
169
173
  });
170
174
 
171
175
  /** Every file kept for a record: its pictures, the wish's file and any parts of one still being sent. */
@@ -186,6 +190,8 @@ export const FOLLOW_FORMAT = 1;
186
190
  export const latest = (record) => ({
187
191
  format: FOLLOW_FORMAT, id: record.id, name: record.name, by: record.by ?? "",
188
192
  version: record.file ? (record.file.version ?? 1) : 0, file: record.file?.url ?? null,
193
+ // The version of its page that plays now, which lamp waits on after sending one (it's stored after the answer).
194
+ page: record.page?.version ?? null,
189
195
  });
190
196
 
191
197
  /**
@@ -202,6 +208,50 @@ export const linkFor = (origin, id, name) => {
202
208
  return `${origin}/w/${id}${slug ? `-${slug}` : ""}`;
203
209
  };
204
210
 
211
+ /**
212
+ * The link a wish is known by: its maker's username and the app's name (`/matt-rubens/bricks`) once its maker has an
213
+ * account, else the old kind (`linkFor`), which still opens it and sends anyone to the new one.
214
+ */
215
+ export const linkOf = (origin, record) => (record?.owner && record?.app ? `${origin}/${record.owner}/${record.app}` : linkFor(origin, record.id, record.name));
216
+
217
+ /**
218
+ * A browser signed in to an account: the email's link, opened in it, leaves a key of its own in this cookie, which only
219
+ * lamp.dev reads (HttpOnly, and Lax, so another site's form never sends it). Ideas sent from it are by that username.
220
+ */
221
+ export const ACCOUNT_COOKIE = "lamp_account";
222
+ export const cookieKey = (request) => new RegExp(`(?:^|;\\s*)${ACCOUNT_COOKIE}=([A-Za-z0-9_-]{43})(?:;|$)`).exec(request.headers.get("cookie") || "")?.[1] ?? null;
223
+ export const signInCookie = (key, url) => `${ACCOUNT_COOKIE}=${key}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${new URL(url).protocol === "https:" ? "; Secure" : ""}`;
224
+ export const signOutCookie = (url) => `${ACCOUNT_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${new URL(url).protocol === "https:" ? "; Secure" : ""}`;
225
+
226
+ /**
227
+ * Asks someone who sent an idea with their address to confirm it, by email: its link signs their browser in to the
228
+ * address's account, or makes one with a username they choose. Emails about their ideas go only to a confirmed
229
+ * address (`writeToSender`), so nobody hears about an idea that someone else typed their address into.
230
+ */
231
+ export async function askToConfirm(kept, { email, name, app, origin, env, fetcher }) {
232
+ const token = randomBytes(16).toString("hex");
233
+ await kept.waitForAccount(token, { email, browser: true, ...(name ? { suggested: name } : {}), at: new Date().toISOString() });
234
+ const link = `${origin}/api/accounts?confirm=${token}`;
235
+ return sendMail(env, fetcher, { to: [email], subject: `Confirm your email to hear back about ${app}`,
236
+ text: `You sent an idea for ${app} on Lamp. To hear back about it, confirm your email here:\n\n${link}\n\nIf you didn't send it, you can ignore this email.` });
237
+ }
238
+
239
+ /** The account a share's key (`X-Lamp-Account`) belongs to, by its username, or null. */
240
+ export async function accountOf(kept, key) {
241
+ if (typeof key !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(key) || !kept.accountForKey) return null;
242
+ return kept.accountForKey(hashSecret(key));
243
+ }
244
+
245
+ /** A name for the app under its maker's username, free or already this link's: its own, then -2, -3… */
246
+ async function claimAppName(kept, owner, name, id) {
247
+ const base = slugFor(name) || "app";
248
+ for (let n = 1; n < 100; n++) {
249
+ const app = n === 1 ? base : `${base}-${n}`;
250
+ if (await kept.claimApp(owner, app, id)) return app;
251
+ }
252
+ throw new Error("No name free");
253
+ }
254
+
205
255
  /** The wish's id in its link's last part, whatever name follows it after a dash. Null when it doesn't start with one. */
206
256
  export const wishIDFrom = (part) => {
207
257
  const id = typeof part === "string" ? part.slice(0, ID_LENGTH) : null;
@@ -214,11 +264,40 @@ export const wishIDFrom = (part) => {
214
264
  * The record of each shared wish in Upstash (the store the site's quotas already use), its pictures in Vercel
215
265
  * Blob, public at addresses with a random part. Preview deployments keep theirs apart from production's.
216
266
  */
267
+ /**
268
+ * A write to the blob store that answers in time: one that hasn't after `seconds` is cancelled and tried again, a few
269
+ * times, since a write that never answers held a whole page up until its function's time ran out.
270
+ */
271
+ async function inTime(write, { seconds = 30, tries = 3, what = "a file" } = {}) {
272
+ for (let attempt = 1; ; attempt++) {
273
+ // Given up on at its time whatever the blob library is doing: its own retries of a failed request wait between
274
+ // tries and don't hear a cancel then, so a write that keeps failing could otherwise hold on for minutes.
275
+ const signal = AbortSignal.timeout(seconds * 1000);
276
+ let timer;
277
+ const late = new Promise((_, failed) => { timer = setTimeout(() => failed(new Error(`no answer in ${seconds}s`)), seconds * 1000 + 1000); });
278
+ try { return await Promise.race([write(signal), late]); } catch (error) {
279
+ if (attempt >= tries) throw error;
280
+ console.warn(`Storing ${what} again (try ${attempt + 1} of ${tries}): ${error?.message ?? error}`);
281
+ } finally { clearTimeout(timer); }
282
+ }
283
+ }
284
+
217
285
  export function cloudStore(env, fetcher = fetch) {
286
+ // The blob library tries a failed request again 10 times by default, waiting longer each time; `inTime` tries again
287
+ // itself, and says why, so the library's own are few.
288
+ process.env.VERCEL_BLOB_RETRIES ??= "2";
218
289
  const db = redis(env);
219
290
  const scope = env.VERCEL_ENV === "production" ? "production" : "preview";
220
291
  const key = (id) => `lamp:shared-wish:{${scope}}:${id}`;
221
292
  const ideasKey = (id) => `lamp:shared-wish:{${scope}}:ideas:${id}`;
293
+ const mailKey = (box) => `lamp:shared-wish:{${scope}}:idea-mail:${box}`;
294
+ const makerKey = (id) => `lamp:shared-wish:{${scope}}:maker:${id}`;
295
+ // Makers' accounts: a username, its confirmed email, the keys their Lamps hold, and the apps under it by name.
296
+ const accountKey = (name) => `lamp:shared-wish:{${scope}}:account:${name}`;
297
+ const accountEmailKey = (email) => `lamp:shared-wish:{${scope}}:account-email:${email.toLowerCase()}`;
298
+ const accountByKeyKey = (hash) => `lamp:shared-wish:{${scope}}:account-key:${hash}`;
299
+ const accountWaitingKey = (token) => `lamp:shared-wish:{${scope}}:account-waiting:${token}`;
300
+ const appKey = (name, app) => `lamp:shared-wish:{${scope}}:app:${name}:${app}`;
222
301
  const command = async (args) => {
223
302
  const response = await fetcher(db.url, {
224
303
  method: "POST", redirect: "error", signal: AbortSignal.timeout(3000),
@@ -250,7 +329,44 @@ export function cloudStore(env, fetcher = fetch) {
250
329
  return (await command(["EVAL", "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('SET', KEYS[1], ARGV[2]) end return false",
251
330
  "1", key(after.id), JSON.stringify(before), JSON.stringify(after)])) === "OK";
252
331
  },
253
- async remove(id) { await command(["DEL", key(id), ideasKey(id)]); },
332
+ async remove(id) { await command(["DEL", key(id), ideasKey(id), makerKey(id)]); },
333
+ /** A maker's account by its username: `{ name, email, created }`, or null. */
334
+ async account(name) { const text = await command(["GET", accountKey(name)]); return typeof text === "string" ? JSON.parse(text) : null; },
335
+ async accountForEmail(email) { const name = await command(["GET", accountEmailKey(email)]); return typeof name === "string" ? name : null; },
336
+ async accountForKey(hash) { const name = await command(["GET", accountByKeyKey(hash)]); return typeof name === "string" ? name : null; },
337
+ /** Makes the account, and its address its own, together: false when either is someone's already. */
338
+ async createAccount(account) {
339
+ return (await command(["EVAL", `if redis.call('EXISTS', KEYS[1]) == 1 or redis.call('EXISTS', KEYS[2]) == 1 then return 0 end
340
+ redis.call('SET', KEYS[1], ARGV[1]) redis.call('SET', KEYS[2], ARGV[2]) return 1`,
341
+ "2", accountKey(account.name), accountEmailKey(account.email), JSON.stringify(account), account.name])) === 1;
342
+ },
343
+ async addAccountKey(hash, name) { await command(["SET", accountByKeyKey(hash), name]); },
344
+ /** What waits on an email's confirmation, a day at most; let go once it's done, so a try that fails can be tried again. */
345
+ async waitForAccount(token, waiting) { await command(["SET", accountWaitingKey(token), JSON.stringify(waiting), "EX", "86400"]); },
346
+ async accountWaiting(token) { const text = await command(["GET", accountWaitingKey(token)]); return typeof text === "string" ? JSON.parse(text) : null; },
347
+ async doneWaiting(token) { await command(["DEL", accountWaitingKey(token)]); },
348
+ /** Claims an app's name under a username for a link: false when another link has it. */
349
+ async claimApp(name, app, id) {
350
+ return (await command(["EVAL", `local had = redis.call('GET', KEYS[1]) if had and had ~= ARGV[1] then return 0 end
351
+ redis.call('SET', KEYS[1], ARGV[1]) return 1`, "1", appKey(name, app), id])) === 1;
352
+ },
353
+ async appID(name, app) { const id = await command(["GET", appKey(name, app)]); return typeof id === "string" ? id : null; },
354
+ async releaseApp(name, app) { await command(["DEL", appKey(name, app)]); },
355
+ /** The maker's own address for a link's ideas, as `{ email, confirmed, key }`, or null. */
356
+ async maker(id) { const text = await command(["GET", makerKey(id)]); return typeof text === "string" ? JSON.parse(text) : null; },
357
+ async setMaker(id, value) { await command(value ? ["SET", makerKey(id), JSON.stringify(value)] : ["DEL", makerKey(id)]); },
358
+ /** Confirms ("confirm") or lets go ("stop") the maker's address in one step, only while `key` is still its key. */
359
+ async makerStep(id, key, step) {
360
+ await command(["EVAL", `local text = redis.call('GET', KEYS[1])
361
+ if not text then return 0 end
362
+ local maker = cjson.decode(text)
363
+ if maker.key ~= ARGV[1] then return 0 end
364
+ if ARGV[2] == "confirm" then
365
+ maker.confirmed = true
366
+ redis.call('SET', KEYS[1], cjson.encode(maker))
367
+ else redis.call('DEL', KEYS[1]) end
368
+ return 1`, "1", makerKey(id), key, step]);
369
+ },
254
370
  /** Keeps an idea for the link, numbered in the order they came: null when the link already holds `IDEA_LIMITS.kept`. */
255
371
  async addIdea(id, idea) {
256
372
  // Its number is its field, given as it's kept, so two at once never share one. The cap counts those still open
@@ -272,22 +388,91 @@ export function cloudStore(env, fetcher = fetch) {
272
388
  /** True when this call took it away: two at once never both do. */
273
389
  async removeIdea(id, n) { return (await command(["HDEL", ideasKey(id), String(n)])) === 1; },
274
390
  /**
275
- * Gives idea `n` its outcome ("in" or "dismissed"), in one step, and answers it as it was, its address included, to
276
- * whichever call gave it: null when it's gone or already has one, so it's told once. The address goes with this step.
391
+ * Gives idea `n` its outcome ("in" or "dismissed"), with the maker's note to its sender as the first of its thread
392
+ * if they wrote one, in one step, and answers it as it was, its address included, to whichever call gave it: null
393
+ * when it's gone or already has one, so it's told once. The picture goes with this step, and the address too when
394
+ * it's dismissed with nothing said, since nothing more will be written to it.
277
395
  */
278
- async finishIdea(id, n, status, at) {
396
+ async finishIdea(id, n, status, at, message) {
279
397
  const text = await command(["EVAL", `local text = redis.call('HGET', KEYS[1], ARGV[1])
280
398
  if not text then return false end
281
399
  local idea = cjson.decode(text)
282
400
  if idea.status then return false end
283
401
  idea.status = ARGV[2]
284
402
  idea.done = ARGV[3]
285
- idea.email = nil
403
+ idea.last = ARGV[3]
404
+ idea.picture = nil
405
+ idea.waiting = nil
406
+ if ARGV[4] ~= "" then
407
+ local thread = idea.thread or {}
408
+ table.insert(thread, cjson.decode(ARGV[4]))
409
+ idea.thread = thread
410
+ end
411
+ if ARGV[2] == "dismissed" and not idea.thread then idea.email = nil end
286
412
  redis.call('HSET', KEYS[1], ARGV[1], cjson.encode(idea))
287
413
  redis.call('HINCRBY', KEYS[1], 'open', -1)
288
- return text`, "1", ideasKey(id), String(n), status, at]);
414
+ return text`, "1", ideasKey(id), String(n), status, at, message ? JSON.stringify(message) : ""]);
415
+ return typeof text === "string" ? { ...JSON.parse(text), n } : null;
416
+ },
417
+ /**
418
+ * One step in an idea's conversation, answering the idea as it was before it, or null when it's gone or the step
419
+ * doesn't apply: "say" adds a message to its thread (one of a message id already there, or past
420
+ * `IDEA_LIMITS.thread`, doesn't), and an answer from its sender leaves it waiting on the maker; "settle" says the
421
+ * maker has seen to it; "box" gives it its mailbox and the subject of the first email, unless another call gave it
422
+ * one first (whose is in what's answered), and "makerbox" the same for its maker's mailbox; "stop", with its mailbox,
423
+ * lets its address go.
424
+ */
425
+ async talk(id, n, op, value = "", extra = "") {
426
+ const text = await command(["EVAL", `local text = redis.call('HGET', KEYS[1], ARGV[1])
427
+ if not text then return false end
428
+ local idea = cjson.decode(text)
429
+ local op = ARGV[2]
430
+ if op == "say" then
431
+ local said = cjson.decode(ARGV[3])
432
+ -- A note given before ideas had threads starts its thread.
433
+ if idea.note and not idea.thread then
434
+ idea.thread = {{by = "maker", text = idea.note, at = idea.done or idea.at}}
435
+ idea.note = nil
436
+ end
437
+ local thread = idea.thread or {}
438
+ if #thread >= tonumber(ARGV[4]) then return false end
439
+ for _, one in ipairs(thread) do
440
+ if (said.mid and one.mid == said.mid) or (said.via and one.via == said.via) then return false end
441
+ end
442
+ table.insert(thread, said)
443
+ idea.thread = thread
444
+ idea.last = said.at
445
+ if said.by == "sender" then idea.waiting = true else idea.waiting = nil end
446
+ elseif op == "settle" then idea.waiting = nil
447
+ elseif op == "box" then
448
+ if idea.mailbox then return text end
449
+ idea.mailbox = ARGV[3]; idea.subject = ARGV[4]
450
+ elseif op == "makerbox" then
451
+ if idea.makerBox then return text end
452
+ idea.makerBox = ARGV[3]
453
+ elseif op == "stop" then
454
+ if idea.mailbox ~= ARGV[3] then return false end
455
+ idea.email = nil
456
+ else return false end
457
+ redis.call('HSET', KEYS[1], ARGV[1], cjson.encode(idea))
458
+ return text`, "1", ideasKey(id), String(n), op, typeof value === "string" ? value : JSON.stringify(value), String(extra || IDEA_LIMITS.thread)]);
289
459
  return typeof text === "string" ? { ...JSON.parse(text), n } : null;
290
460
  },
461
+ /**
462
+ * An idea's mailbox: the address replies to its emails come to, kept a while past the last of them. Each idea has
463
+ * one for its sender's replies and, once its maker has emails of their own, one for the maker's (`side` "maker").
464
+ */
465
+ async openMailbox(id, n, side = "sender") {
466
+ const box = randomBytes(10).toString("hex");
467
+ await command(["SET", mailKey(box), `${id}:${n}${side === "maker" ? ":maker" : ""}`, "EX", String(MAILBOX_SECONDS)]);
468
+ return box;
469
+ },
470
+ async mailbox(box) {
471
+ const found = await command(["GETEX", mailKey(box), "EX", String(MAILBOX_SECONDS)]);
472
+ const [id, n, side] = typeof found === "string" ? found.split(":") : [];
473
+ return isID(id) && /^\d+$/.test(n ?? "") ? { id, n: Number(n), side: side === "maker" ? "maker" : "sender" } : null;
474
+ },
475
+ async closeMailbox(box) { await command(["DEL", mailKey(box)]); },
291
476
  async putImage(id, name, image) {
292
477
  const { put } = await import("@vercel/blob");
293
478
  const blob = await put(`${scope}/w/${id}/${name}.${image.extension}`, image.bytes, {
@@ -304,40 +489,67 @@ export function cloudStore(env, fetcher = fetch) {
304
489
  // the pictures are, and only ever read back from the store's own host.
305
490
  async putFile(id, name, bytes) {
306
491
  const { put } = await import("@vercel/blob");
307
- const blob = await put(`${scope}/w/${id}/${name}`, bytes, {
308
- access: "public", contentType: "application/octet-stream", addRandomSuffix: true, cacheControlMaxAge: 300, ...auth,
309
- });
492
+ const blob = await inTime((abortSignal) => put(`${scope}/w/${id}/${name}`, bytes, {
493
+ access: "public", contentType: "application/octet-stream", addRandomSuffix: true, cacheControlMaxAge: 300, abortSignal, ...auth,
494
+ }), { seconds: 60, what: name });
310
495
  return blob.url;
311
496
  },
312
497
  // One file of a playable page, under the version's own random folder: read back by the play handler, never served
313
498
  // from Blob itself (which hands HTML out as a download).
314
499
  async putPageFile(id, token, path, bytes) {
315
500
  const { put } = await import("@vercel/blob");
316
- const blob = await put(`${scope}/w/${id}/page/${token}/${path}`, bytes, {
317
- // Kept as what it is, so a browser sent straight to a big one (`DIRECT_FILE`) runs or reads it.
318
- access: "public", contentType: pageType(path), addRandomSuffix: false, cacheControlMaxAge: 300, ...auth,
319
- });
501
+ const blob = await inTime((abortSignal) => put(`${scope}/w/${id}/page/${token}/${path}`, bytes, {
502
+ // Kept as what it is, so a browser sent straight to a big one (`DIRECT_FILE`) runs or reads it. Written again
503
+ // in place when a try was cancelled after it got there.
504
+ access: "public", contentType: pageType(path), addRandomSuffix: false, allowOverwrite: true, cacheControlMaxAge: 300, abortSignal, ...auth,
505
+ }), { seconds: 30, what: path });
320
506
  return blob.url;
321
507
  },
322
508
  async readFile(url) {
323
- const address = new URL(url);
324
- if (address.protocol !== "https:" || !address.hostname.endsWith(".public.blob.vercel-storage.com")) throw new Error("Not the store's");
325
- const response = await fetcher(url, { redirect: "error", signal: AbortSignal.timeout(10000) });
326
- if (!response.ok) throw new Error("Part unavailable");
327
- return Buffer.from(await response.arrayBuffer());
509
+ const { body } = await this.openFile(url);
510
+ return Buffer.from(await new Response(body).arrayBuffer());
328
511
  },
329
512
  // A page's file as it comes, passed on without being held whole: a big one (a game's map data, tens of MB) is more
330
513
  // than a function's answer may hold at once, and more than it can read in the time a whole read is given.
514
+ // A small one is kept in the instance after its first read (`remembered`): a page's files never change, and a game
515
+ // asks for some many times over (a worker's script, under an address of its own each time), which the CDN can't
516
+ // keep. The store turns this function's reads away for a while when they come in bursts (403), and asking again
517
+ // soon only keeps that going: a refused read is asked once more, a second later, and said with what the store
518
+ // gave as its reason.
331
519
  async openFile(url) {
332
520
  const address = new URL(url);
333
521
  if (address.protocol !== "https:" || !address.hostname.endsWith(".public.blob.vercel-storage.com")) throw new Error("Not the store's");
334
- const waiting = new AbortController();
335
- const timer = setTimeout(() => waiting.abort(), 10000);
336
- try {
337
- const response = await fetcher(url, { redirect: "error", signal: waiting.signal });
338
- if (!response.ok || !response.body) throw new Error(`Part unavailable (${response.status})`);
339
- return { body: response.body, length: response.headers.get("content-length") };
340
- } finally { clearTimeout(timer); }
522
+ const kept = remembered.get(url);
523
+ if (kept) { remembered.delete(url); remembered.set(url, kept); return { body: kept, length: String(kept.length) }; }
524
+ let said = null;
525
+ for (let attempt = 1; attempt <= 2; attempt++) {
526
+ if (attempt > 1) await new Promise((done) => setTimeout(done, 1000 + Math.random() * 500));
527
+ // The time limit is for the store to start answering: a big file's body may take longer to pass on.
528
+ const waiting = new AbortController();
529
+ const timer = setTimeout(() => waiting.abort(), 10000);
530
+ let response;
531
+ try { response = await fetcher(url, { redirect: "error", signal: waiting.signal }); }
532
+ catch (error) { said = error?.message ?? String(error); continue; }
533
+ finally { clearTimeout(timer); }
534
+ const length = response.headers.get("content-length");
535
+ if (response.ok && response.body) {
536
+ if (length === null || Number(length) > REMEMBERED_FILE) return { body: response.body, length };
537
+ const bytes = Buffer.from(await response.arrayBuffer());
538
+ remember(url, bytes);
539
+ return { body: bytes, length: String(bytes.length) };
540
+ }
541
+ // The start of the store's words, read for a moment at most: a body that stalls never holds up the next try.
542
+ let words = "";
543
+ const reader = response.body?.getReader();
544
+ if (reader) {
545
+ const first = await Promise.race([reader.read().catch(() => null), new Promise((done) => setTimeout(done, 1000, null))]);
546
+ if (first?.value) words = Buffer.from(first.value).subarray(0, 100).toString("utf8").replace(/\s+/g, " ");
547
+ reader.cancel().catch(() => {});
548
+ }
549
+ said = [response.status, response.headers.get("x-vercel-error"), response.headers.get("x-vercel-mitigated") && `mitigated ${response.headers.get("x-vercel-mitigated")}`, words && `"${words}"`].filter(Boolean).join(" ");
550
+ if (!(response.status === 403 || response.status === 429 || response.status >= 500)) break;
551
+ }
552
+ throw new Error(`Part unavailable (${said})`);
341
553
  },
342
554
  // The page and its card are cached by Vercel's CDN, tagged with the wish (see `cacheTags`): a change or a removal
343
555
  // takes them out at once rather than when they'd have run out.
@@ -350,12 +562,46 @@ export function cloudStore(env, fetcher = fetch) {
350
562
  };
351
563
  }
352
564
 
565
+ // A page's small files, as read from the store by this instance, the most recently served last: at most REMEMBERED in
566
+ // all, the least recently served going first.
567
+ const REMEMBERED = 96 * 1024 * 1024, REMEMBERED_FILE = 2 * 1024 * 1024;
568
+ const remembered = new Map();
569
+ let rememberedBytes = 0;
570
+ function remember(url, bytes) {
571
+ // Reads of one file at once each arrive here: the one already kept is counted out before its copy is counted in.
572
+ rememberedBytes -= remembered.get(url)?.length ?? 0;
573
+ remembered.delete(url);
574
+ remembered.set(url, bytes);
575
+ rememberedBytes += bytes.length;
576
+ for (const [oldest, kept] of remembered) {
577
+ if (rememberedBytes <= REMEMBERED) break;
578
+ remembered.delete(oldest);
579
+ rememberedBytes -= kept.length;
580
+ }
581
+ }
582
+
353
583
  /** Kept in memory, for `npm run dev` and the tests: pictures as data: URLs, so nothing else needs serving. */
354
584
  export function memoryStore() {
355
- const records = new Map(), pages = new Map(), ideas = new Map();
585
+ const records = new Map(), pages = new Map(), ideas = new Map(), mailboxes = new Map(), makers = new Map();
586
+ const accounts = new Map(), emails = new Map(), keys = new Map(), waiting = new Map(), apps = new Map();
356
587
  return {
357
588
  limiter: null,
358
589
  records,
590
+ accounts,
591
+ async account(name) { return accounts.has(name) ? structuredClone(accounts.get(name)) : null; },
592
+ async accountForEmail(email) { return emails.get(email.toLowerCase()) ?? null; },
593
+ async accountForKey(hash) { return keys.get(hash) ?? null; },
594
+ async createAccount(account) {
595
+ if (accounts.has(account.name) || emails.has(account.email.toLowerCase())) return false;
596
+ accounts.set(account.name, structuredClone(account)); emails.set(account.email.toLowerCase(), account.name); return true;
597
+ },
598
+ async addAccountKey(hash, name) { keys.set(hash, name); },
599
+ async waitForAccount(token, value) { waiting.set(token, structuredClone(value)); },
600
+ async accountWaiting(token) { return waiting.has(token) ? structuredClone(waiting.get(token)) : null; },
601
+ async doneWaiting(token) { waiting.delete(token); },
602
+ async claimApp(name, app, id) { const had = apps.get(`${name}/${app}`); if (had && had !== id) return false; apps.set(`${name}/${app}`, id); return true; },
603
+ async appID(name, app) { return apps.get(`${name}/${app}`) ?? null; },
604
+ async releaseApp(name, app) { apps.delete(`${name}/${app}`); },
359
605
  async get(id) { return records.has(id) ? structuredClone(records.get(id)) : null; },
360
606
  async create(record) { if (records.has(record.id)) return false; records.set(record.id, structuredClone(record)); return true; },
361
607
  async replace(record) { if (!records.has(record.id)) return false; records.set(record.id, structuredClone(record)); return true; },
@@ -364,7 +610,14 @@ export function memoryStore() {
364
610
  records.set(after.id, structuredClone(after));
365
611
  return true;
366
612
  },
367
- async remove(id) { records.delete(id); ideas.delete(id); },
613
+ async remove(id) { records.delete(id); ideas.delete(id); makers.delete(id); },
614
+ async maker(id) { return makers.has(id) ? structuredClone(makers.get(id)) : null; },
615
+ async setMaker(id, value) { if (value) makers.set(id, structuredClone(value)); else makers.delete(id); },
616
+ async makerStep(id, key, step) {
617
+ const maker = makers.get(id);
618
+ if (maker?.key !== key) return;
619
+ if (step === "confirm") maker.confirmed = true; else makers.delete(id);
620
+ },
368
621
  async addIdea(id, idea) {
369
622
  const kept = ideas.get(id) ?? { next: 0, all: new Map() };
370
623
  if ([...kept.all.values()].filter((one) => !one.status).length >= IDEA_LIMITS.kept) return null;
@@ -375,14 +628,38 @@ export function memoryStore() {
375
628
  },
376
629
  async ideas(id) { return [...(ideas.get(id)?.all.values() ?? [])].map((idea) => structuredClone(idea)).sort((a, b) => a.n - b.n); },
377
630
  async removeIdea(id, n) { return ideas.get(id)?.all.delete(n) ?? false; },
378
- async finishIdea(id, n, status, at) {
631
+ async finishIdea(id, n, status, at, message) {
379
632
  const idea = ideas.get(id)?.all.get(n);
380
633
  if (!idea || idea.status) return null;
381
634
  const before = structuredClone(idea);
382
- Object.assign(idea, { status, done: at });
383
- delete idea.email;
635
+ Object.assign(idea, { status, done: at, last: at });
636
+ delete idea.waiting;
637
+ if (message) idea.thread = [...(idea.thread ?? []), message];
638
+ if (status === "dismissed" && !idea.thread) delete idea.email;
639
+ delete idea.picture;
384
640
  return before;
385
641
  },
642
+ async talk(id, n, op, value = "", extra = "") {
643
+ const idea = ideas.get(id)?.all.get(n);
644
+ if (!idea) return null;
645
+ const before = structuredClone(idea);
646
+ if (op === "say") {
647
+ if (idea.note && !idea.thread) { idea.thread = [{ by: "maker", text: idea.note, at: idea.done ?? idea.at }]; delete idea.note; }
648
+ const thread = idea.thread ?? [];
649
+ if (thread.length >= IDEA_LIMITS.thread || thread.some((one) => (value.mid && one.mid === value.mid) || (value.via && one.via === value.via))) return null;
650
+ idea.thread = [...thread, value];
651
+ idea.last = value.at;
652
+ if (value.by === "sender") idea.waiting = true; else delete idea.waiting;
653
+ } else if (op === "settle") delete idea.waiting;
654
+ else if (op === "box") { if (!idea.mailbox) Object.assign(idea, { mailbox: value, subject: extra }); }
655
+ else if (op === "makerbox") { if (!idea.makerBox) idea.makerBox = value; }
656
+ else if (op === "stop" && idea.mailbox === value) delete idea.email;
657
+ else return null;
658
+ return before;
659
+ },
660
+ async openMailbox(id, n, side = "sender") { const box = randomBytes(10).toString("hex"); mailboxes.set(box, { id, n, side }); return box; },
661
+ async mailbox(box) { return mailboxes.get(box) ?? null; },
662
+ async closeMailbox(box) { mailboxes.delete(box); },
386
663
  async putImage(_id, _name, image) { return `data:${image.type};base64,${image.bytes.toString("base64")}`; },
387
664
  async removeImages() {},
388
665
  async putFile(_id, _name, bytes) { return `data:application/octet-stream;base64,${bytes.toString("base64")}`; },
@@ -435,13 +712,17 @@ export function createShareHandler({ env = process.env, fetcher = fetch, store,
435
712
  const origin = request.headers.get("origin");
436
713
  if (origin && origin !== url.origin) return reply(403, { error: "origin_not_allowed" });
437
714
  const id = url.searchParams.get("id");
438
- if (method !== "POST" && !isID(id)) return reply(404, { error: "not_found" });
439
- // Asked by the Lamps that follow this link: public, as the page is, and cheap (one read, no quota).
715
+ const user = url.searchParams.get("user"), app = url.searchParams.get("app");
716
+ const byName = method === "GET" && !id && typeof user === "string" && typeof app === "string" && /^[a-z0-9-]{1,60}$/.test(user) && /^[a-z0-9-]{1,60}$/.test(app);
717
+ if (method !== "POST" && !isID(id) && !byName) return reply(404, { error: "not_found" });
718
+ // Asked by the Lamps that follow this link: public, as the page is, and cheap (one read, no quota). By its id, or
719
+ // by its maker's username and its name there.
440
720
  if (method === "GET") {
441
721
  let kept;
442
722
  try { kept = store ?? cloudStore(env, fetcher); } catch { return reply(503, { error: "service_unavailable" }); }
443
723
  try {
444
- const record = await kept.get(id);
724
+ const found = byName ? await kept.appID(user, app) : id;
725
+ const record = found ? await kept.get(found) : null;
445
726
  return record ? reply(200, latest(record)) : reply(404, { error: "not_found" });
446
727
  } catch { return reply(503, { error: "service_unavailable" }); }
447
728
  }
@@ -466,14 +747,19 @@ export function createShareHandler({ env = process.env, fetcher = fetch, store,
466
747
  if (retry > 0) return reply(429, { error: "rate_limited" }, { "Retry-After": String(retry) });
467
748
  }
468
749
 
469
- let uploaded = [];
750
+ let uploaded = [], claimed = null;
470
751
  try {
471
752
  const earlier = method === "POST" ? null : await kept.get(id);
472
753
  if (method !== "POST" && (!earlier || !secretMatches(bearer(request), earlier.secret))) return reply(404, { error: "not_found" });
754
+ // Whose it is: a share sent with its maker's account key (their Lamp sends it) goes under their username, and an
755
+ // old link sent again with one goes there from then on. One without a key is shared as links always were.
756
+ const owner = method === "DELETE" ? null : await accountOf(kept, request.headers.get("x-lamp-account"));
473
757
  const oldImages = [earlier?.picture?.url, earlier?.icon?.url].filter(Boolean);
474
758
  if (method === "DELETE") {
759
+ const ideaShots = ideaPictures(await kept.ideas(id).catch(() => []));
475
760
  await kept.remove(id);
476
- await kept.removeImages(blobsOf(earlier));
761
+ if (earlier.owner && earlier.app) await kept.releaseApp(earlier.owner, earlier.app);
762
+ await kept.removeImages([...blobsOf(earlier), ...ideaShots]);
477
763
  await kept.forget(id);
478
764
  return reply(204);
479
765
  }
@@ -486,23 +772,34 @@ export function createShareHandler({ env = process.env, fetcher = fetch, store,
486
772
  const picture = share.picture && { url: await put("picture", share.picture), width: share.picture.width, height: share.picture.height };
487
773
  const icon = share.icon && { url: await put("icon", share.icon) };
488
774
  const time = now().toISOString();
489
- const words = { name: share.name, by: share.by, line: share.line, words: share.words, ideas: share.ideas, asks: share.asks, shape: share.shape,
775
+ const words = { name: share.name, by: share.by, line: share.line, words: share.words, ideas: share.ideas, asks: share.asks, shape: share.shape, github: share.github,
490
776
  ...(share.releases ? { releases: share.releases } : {}), picture: picture || null, icon: icon || null };
777
+ // Its name under the account stays what it was first given, whatever the app is called later.
778
+ const named = earlier?.owner ? { owner: earlier.owner, app: earlier.app }
779
+ : owner ? { owner, app: await claimAppName(kept, owner, words.name, wishID) } : {};
780
+ // A name claimed for this share now, let go again if the record can't be kept.
781
+ if (!earlier?.owner && named.owner) claimed = named;
491
782
  if (method === "PUT") {
492
- await kept.replace({ ...earlier, ...words, updated: time });
783
+ const record = { ...earlier, ...words, ...named, updated: time };
784
+ await kept.replace(record);
785
+ // Kept: the name is the record's now, whatever the cleanup after it does.
493
786
  uploaded = [];
787
+ claimed = null;
494
788
  await kept.removeImages(oldImages);
495
789
  await kept.forget(id);
496
- return reply(200, { id, url: linkFor(url.origin, id, words.name) });
790
+ return reply(200, { id, url: linkOf(url.origin, record) });
497
791
  }
498
792
  const secret = newSecret();
499
- if (!(await kept.create({ id: wishID, ...words, secret: hashSecret(secret), created: time, updated: time }))) {
793
+ const record = { id: wishID, ...words, ...named, secret: hashSecret(secret), created: time, updated: time };
794
+ if (!(await kept.create(record))) {
500
795
  throw new Error("Id taken");
501
796
  }
502
797
  uploaded = [];
503
- return reply(201, { id: wishID, url: linkFor(url.origin, wishID, words.name), secret });
798
+ claimed = null;
799
+ return reply(201, { id: wishID, url: linkOf(url.origin, record), secret });
504
800
  } catch {
505
801
  try { await kept.removeImages(uploaded); } catch {}
802
+ try { if (claimed) await kept.releaseApp(claimed.owner, claimed.app); } catch {}
506
803
  return reply(503, { error: "service_unavailable" });
507
804
  }
508
805
  };
@@ -511,64 +808,189 @@ export function createShareHandler({ env = process.env, fetcher = fetch, store,
511
808
  // MARK: Ideas for the maker
512
809
 
513
810
  /** What someone playing a shared wish may send its maker: their idea, and an address to hear back at if they want. */
514
- export const IDEA_LIMITS = { text: 1000, email: 200, kept: 500, asked: 20, keptDone: 90 * 86400_000 };
811
+ export const IDEA_LIMITS = { text: 1000, note: 2000, thread: 60, earlier: 100, name: 60, email: 200, picture: 400_000, kept: 500, asked: 20, keptDone: 90 * 86400_000 };
515
812
  // A visitor sends a handful an hour; a link takes a few hundred a day; overall, a ceiling on a flood.
516
813
  export const IDEA_QUOTAS = [
517
814
  { name: "idea-ip", perIP: true, limit: 12, seconds: 3600 },
518
815
  { name: "idea-ip-day", perIP: true, limit: 40, seconds: 86400 },
519
816
  { name: "idea-day", limit: 5000, seconds: 86400 },
520
817
  ];
818
+ // Confirmations to a maker's address: one a minute per link, and a few an hour from one place, a ceiling overall.
819
+ export const MAKER_QUOTAS = [
820
+ { name: "maker-ip", perIP: true, limit: 6, seconds: 3600 },
821
+ { name: "maker-day", limit: 1000, seconds: 86400 },
822
+ ];
823
+ const MAKER_RESEND_MS = 60_000;
521
824
  const EMAIL = /^[^\s@<>()",;:]+@[^\s@<>()",;:]+\.[^\s@<>()",;:]+$/;
825
+ // An idea's mailbox outlives its last message by a little more than the idea itself does (`IDEA_LIMITS.keptDone`).
826
+ const MAILBOX_SECONDS = 100 * 86400;
827
+
828
+ /** The domain of the address the site's emails come from (`LAMP_IDEAS_FROM`), for the message ids it gives them. */
829
+ const mailDomain = (env) => /@([^\s>]+)>?\s*$/.exec(env.LAMP_IDEAS_FROM ?? "")?.[1] ?? "lamp.dev";
830
+ /** A new message id for something the maker says, which the email carrying it goes out with. */
831
+ const newMessageID = (env) => `<idea.${randomBytes(12).toString("hex")}@${mailDomain(env)}>`;
522
832
 
523
833
  /**
524
- * Writes to someone who asked to hear back that their idea is in, through Resend (`RESEND_API_KEY`, from
525
- * `LAMP_IDEAS_FROM`, an address on a domain Resend has verified). False, and nothing sent, without them or when Resend
526
- * refuses: the idea still goes. Their address is used for this one email and goes with the idea.
834
+ * Writes to an idea's sender through Resend (`RESEND_API_KEY`, from `LAMP_IDEAS_FROM`, an address on a domain Resend
835
+ * has verified): `kind` "in" when it's in (with the maker's note, if any), "reply" for something the maker says after.
836
+ * The first email gives the idea a mailbox: with `LAMP_IDEAS_REPLY_DOMAIN` set, a reply to any of them goes to
837
+ * `idea-<mailbox>@` it, and comes back to the idea's thread (`createIdeaMailHandler`); each also has a link to stop
838
+ * them. Every one after the first answers the one before, so they read as one conversation. False, and nothing
839
+ * sent, without an address or Resend, or when Resend refuses.
527
840
  */
528
- export async function tellItsIn(idea, record, link, { env = process.env, fetcher = fetch, once } = {}) {
841
+ export async function writeToSender(kept, wishID, idea, record, link, { env = process.env, fetcher = fetch, kind = "in", said, origin, once } = {}) {
529
842
  const key = env.RESEND_API_KEY, from = env.LAMP_IDEAS_FROM;
530
843
  if (!key || !from || !idea.email) return false;
531
- const maker = record.by?.trim() || "Whoever made it";
532
- const text = `${maker} added your idea to ${record.name}:\n\n${idea.text}\n\nPlay it: ${link}\n\n` +
533
- "You asked to hear when it's in, so this is the only email about it.";
844
+ // Only an address its owner has confirmed (one with an account) hears back.
845
+ if (!kept.accountForEmail || !(await kept.accountForEmail(idea.email.toLowerCase()))) return false;
846
+ let subject = idea.subject || (kind === "in" ? `Your idea is in ${record.name}` : `About your idea for ${record.name}`);
847
+ let box = idea.mailbox;
848
+ if (!box) {
849
+ // Two first emails at once each open one, and both go with whichever the idea kept.
850
+ box = await kept.openMailbox(wishID, idea.n);
851
+ const before = await kept.talk(wishID, idea.n, "box", box, subject);
852
+ if (before?.mailbox) {
853
+ try { await kept.closeMailbox(box); } catch {}
854
+ box = before.mailbox;
855
+ subject = before.subject || subject;
856
+ }
857
+ }
858
+ const by = record.by?.trim();
859
+ const maker = by || "Whoever made it";
860
+ const stop = `${origin}/api/wish-ideas?id=${wishID}&n=${idea.n}&stop=${box}`;
861
+ const replies = env.LAMP_IDEAS_REPLY_DOMAIN ? `idea-${box}@${env.LAMP_IDEAS_REPLY_DOMAIN}` : null;
862
+ const note = said?.text;
863
+ const text = (kind === "in"
864
+ ? `${maker} added your idea to ${record.name}:\n\n${idea.text}\n\n` + (note ? `${by ? `${by} wrote` : "Their note"}:\n\n${note}\n\n` : "")
865
+ : `${by ? `${by} wrote` : "A note"} about your idea for ${record.name}:\n\n${note}\n\nYour idea: ${idea.text}\n\n`) +
866
+ `Play it: ${link}\n\n` + (replies ? `Reply to this email to answer ${by || "them"}.\n` : "") +
867
+ `No more emails about this idea: ${stop}`;
868
+ // The thread so far, the emails each way, for mail apps to keep them together.
869
+ const ids = (idea.thread ?? []).map((one) => one.mid).filter((mid) => typeof mid === "string" && /^<[^<>\s]+>$/.test(mid));
870
+ const headers = {
871
+ ...(said?.mid ? { "Message-ID": said.mid } : {}),
872
+ ...(ids.length ? { "In-Reply-To": ids.at(-1), References: ids.slice(-20).join(" ") } : {}),
873
+ "List-Unsubscribe": `<${stop}>`, "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
874
+ };
534
875
  try {
535
876
  const response = await fetcher("https://api.resend.com/emails", {
536
877
  method: "POST", redirect: "error", signal: AbortSignal.timeout(5000),
537
878
  // Resend sends one email for a key however often it's asked within a day.
538
879
  headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...(once ? { "Idempotency-Key": once } : {}) },
539
- body: JSON.stringify({ from, to: [idea.email], subject: `Your idea is in ${record.name}`, text }),
880
+ body: JSON.stringify({
881
+ from, to: [idea.email], subject: idea.mailbox ? `Re: ${subject}` : subject, text, headers,
882
+ ...(replies ? { reply_to: [replies] } : {}),
883
+ }),
884
+ });
885
+ return response.ok;
886
+ } catch { return false; }
887
+ }
888
+
889
+ /** Sends one email through Resend; false, and nothing sent, without Resend or when it refuses. */
890
+ export async function sendMail(env, fetcher, message, once) {
891
+ if (!env.RESEND_API_KEY || !env.LAMP_IDEAS_FROM) return false;
892
+ try {
893
+ const response = await fetcher("https://api.resend.com/emails", {
894
+ method: "POST", redirect: "error", signal: AbortSignal.timeout(5000),
895
+ headers: { Authorization: `Bearer ${env.RESEND_API_KEY}`, "Content-Type": "application/json", ...(once ? { "Idempotency-Key": once } : {}) },
896
+ body: JSON.stringify({ from: env.LAMP_IDEAS_FROM, ...message }),
540
897
  });
541
898
  return response.ok;
542
899
  } catch { return false; }
543
900
  }
544
901
 
545
- /** An idea as sent from a link's page, or null: its words, and an address to hear back at only if it looks like one. */
902
+ /**
903
+ * Writes to a link's maker at their own address (`maker`, once they've confirmed it): `kind` "idea" for a new idea,
904
+ * "reply" for its sender's answer. The first gives the idea a mailbox of the maker's, and a reply to any of them comes
905
+ * back as the maker's own word on its thread, sent on to the sender (`createIdeaMailHandler`). Each idea's emails to
906
+ * the maker are one conversation of their own, and each has a link to stop them for the whole link.
907
+ */
908
+ export async function writeToMaker(kept, wishID, idea, record, link, { env = process.env, fetcher = fetch, kind = "idea", said, origin, maker, once } = {}) {
909
+ if (!env.RESEND_API_KEY || !env.LAMP_IDEAS_FROM || !maker?.confirmed || !maker.email) return false;
910
+ let box = idea.makerBox;
911
+ if (!box) {
912
+ box = await kept.openMailbox(wishID, idea.n, "maker");
913
+ const before = await kept.talk(wishID, idea.n, "makerbox", box);
914
+ if (before?.makerBox) {
915
+ try { await kept.closeMailbox(box); } catch {}
916
+ box = before.makerBox;
917
+ }
918
+ }
919
+ const replies = env.LAMP_IDEAS_REPLY_DOMAIN ? `idea-${box}@${env.LAMP_IDEAS_REPLY_DOMAIN}` : null;
920
+ const stop = `${origin}/api/wish-ideas?id=${wishID}&stopmaker=${maker.key}`;
921
+ const flat = idea.text.replace(/\s+/g, " ");
922
+ const subject = `Idea for ${record.name}: ${[...flat].length > 60 ? `${[...flat].slice(0, 60).join("").trimEnd()}…` : flat}`;
923
+ const root = `<idea-${box}@${mailDomain(env)}>`;
924
+ const text = (kind === "idea"
925
+ ? `${idea.name || "Someone"} playing ${record.name} sent an idea:\n\n${idea.text}\n\n` +
926
+ (typeof idea.picture === "string" && idea.picture.startsWith("https://") ? `The app as they saw it: ${idea.picture}\n\n` : "") +
927
+ (idea.email ? "They'd like to hear back.\n\n" : "They left no address, so what you say shows on the link's page.\n\n")
928
+ : `${idea.name || "They"} answered about their idea for ${record.name}:\n\n${said.text}\n\nTheir idea: ${idea.text}\n\n`) +
929
+ (replies ? "Reply to this email to answer them. It's in Lamp too.\n" : "Answer them in Lamp.\n") +
930
+ `Play it: ${link}\n\nNo more emails about ideas for ${record.name}: ${stop}`;
931
+ return sendMail(env, fetcher, {
932
+ to: [maker.email], subject: kind === "idea" ? subject : `Re: ${subject}`, text,
933
+ headers: {
934
+ ...(kind === "idea" ? { "Message-ID": root } : { "In-Reply-To": root, References: root }),
935
+ "List-Unsubscribe": `<${stop}>`, "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
936
+ },
937
+ ...(replies ? { reply_to: [replies] } : {}),
938
+ }, once);
939
+ }
940
+
941
+ /**
942
+ * An idea as sent from a link's page, or null: its words, an address to hear back at only if it looks like one, and
943
+ * the picture of the app the page took when the sender opened the box, if they kept it in (a PNG or JPEG, base64).
944
+ */
546
945
  export function readIdea(body) {
547
946
  if (!body || typeof body !== "object" || Array.isArray(body)) return null;
548
947
  const text = tidy(body.text, IDEA_LIMITS.text, { lines: true });
549
948
  if (!text) return null;
550
- if (body.email == null || body.email === "") return { text };
949
+ const shot = picture(body.picture, IDEA_LIMITS.picture);
950
+ if (!shot) return null;
951
+ // Their name, if they gave one, for the maker: shown in Lamp and in the maker's emails, where the address never is.
952
+ const name = body.name == null || body.name === "" ? "" : tidy(body.name, IDEA_LIMITS.name);
953
+ if (name == null) return null;
954
+ const idea = { text, ...(name ? { name } : {}), ...(shot.bytes ? { picture: shot } : {}) };
955
+ if (body.email == null || body.email === "") return idea;
551
956
  const email = tidy(body.email, IDEA_LIMITS.email);
552
- return email && EMAIL.test(email) ? { text, email } : null;
957
+ return email && EMAIL.test(email) ? { ...idea, email } : null;
553
958
  }
554
959
 
960
+ /** The pictures a link's ideas carry, to go with them. */
961
+ const ideaPictures = (ideas) => ideas.map((one) => one.picture).filter((url) => typeof url === "string" && url.startsWith("https://"));
962
+
555
963
  /**
556
964
  * Ideas for a link's maker (`/api/wish-ideas?id=`). POST, from the link's page once the sender confirms it, keeps one:
557
- * what someone using it would change, and an address to hear when it's in, if they gave one. GET, with the link's secret, answers them for Lamp to show the maker: never the keys or the addresses, only
558
- * whether someone wants to hear back. DELETE ?n= with the secret lets one go: with `built=1` it's in, and the site writes
559
- * to whoever asked (`tellItsIn`); without, it's dismissed. Either way it's kept a while with its outcome, for the
965
+ * what someone using it would change, an address to hear back at, if they gave one, and the picture of the app the
966
+ * page took as they opened the box, if they kept it in (in the store, until the idea has its outcome). GET, with the
967
+ * link's secret, answers for Lamp to show the maker those still open and those whose sender has answered since
968
+ * (`waiting`), or with `earlier=1` those seen to, each with its picture's address and its thread: never the keys or the addresses, only whether someone
969
+ * wants to hear back. DELETE ?n= with the secret lets one go: with `built=1` it's in, and the site writes to whoever
970
+ * asked (`writeToSender`); without, it's dismissed. Its JSON body may carry the maker's `note`, the first of the idea's
971
+ * thread. PATCH ?n= with the secret and a `note` says something more, written to the sender if they gave an address;
972
+ * without one it only says the maker has seen to their answer. Every idea is kept a while past its last word, for the
560
973
  * sender's own browser, which kept the number and a token the POST answered, to ask after it on a later visit (GET
561
- * `?id=&mine=<n>.<token>,…`, answering only those). Never logged, never on the page for anyone else, gone with the link.
974
+ * `?id=&mine=<n>.<token>,…`, answering only those, with the maker's last word), and for the sender's replies by email.
975
+ * A link in each email (`?stop=<mailbox>`, GET or a mail app's one-click POST) lets the address go. Never logged,
976
+ * never on the page for anyone else, gone with the link.
562
977
  */
563
978
  export function createIdeasHandler({ env = process.env, fetcher = fetch, store, limited = true, now = () => new Date() } = {}) {
564
979
  return async (request) => {
565
980
  const method = request.method;
566
- if (!["GET", "POST", "DELETE"].includes(method)) return reply(405, { error: "method_not_allowed" }, { Allow: "GET, POST, DELETE" });
981
+ if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(method)) return reply(405, { error: "method_not_allowed" }, { Allow: "GET, POST, PUT, PATCH, DELETE" });
567
982
  const url = new URL(request.url);
568
- const origin = request.headers.get("origin");
569
- if (origin && origin !== url.origin) return reply(403, { error: "origin_not_allowed" });
570
983
  const id = url.searchParams.get("id");
571
984
  if (!isID(id)) return reply(404, { error: "not_found" });
985
+ const stop = url.searchParams.get("stop");
986
+ if (stop != null && (method === "GET" || method === "POST")) return stopEmails(id, Number(url.searchParams.get("n")), stop);
987
+ // The links in the maker's own emails: confirming their address, and stopping them.
988
+ for (const step of ["confirm", "stopmaker"]) {
989
+ const given = url.searchParams.get(step);
990
+ if (given != null && (method === "GET" || method === "POST")) return makerLink(id, step, given);
991
+ }
992
+ const origin = request.headers.get("origin");
993
+ if (origin && origin !== url.origin) return reply(403, { error: "origin_not_allowed" });
572
994
 
573
995
  let idea = null;
574
996
  if (method === "POST") {
@@ -577,16 +999,38 @@ export function createIdeasHandler({ env = process.env, fetcher = fetch, store,
577
999
  catch (error) { return reply(error instanceof RangeError ? 413 : 400, { error: "invalid_idea" }); }
578
1000
  if (!idea) return reply(400, { error: "invalid_idea" });
579
1001
  }
580
- const n = method === "DELETE" ? Number(url.searchParams.get("n")) : null;
1002
+ // The maker's own address for the link's ideas (PUT ?maker=1 with the secret): checked here, kept once the secret is.
1003
+ let makerEmail;
1004
+ if (method === "PUT") {
1005
+ if (url.searchParams.get("maker") !== "1") return reply(405, { error: "method_not_allowed" });
1006
+ let given;
1007
+ try { given = JSON.parse((await readBody(request)) || "{}")?.email; }
1008
+ catch (error) { return reply(error instanceof RangeError ? 413 : 400, { error: "invalid_email" }); }
1009
+ makerEmail = given == null || given === "" ? "" : tidy(given, IDEA_LIMITS.email);
1010
+ if (makerEmail === null || (makerEmail && !EMAIL.test(makerEmail))) return reply(400, { error: "invalid_email" });
1011
+ }
1012
+ const n = method === "DELETE" || method === "PATCH" ? Number(url.searchParams.get("n")) : null;
581
1013
  if (n !== null && (!Number.isInteger(n) || n < 1)) return reply(400, { error: "invalid_idea" });
1014
+ // The maker's note to the sender, if they wrote one: a body with nothing in it, or none, is no note.
1015
+ let note = null;
1016
+ if (n !== null && /^application\/json/i.test(request.headers.get("content-type") || "")) {
1017
+ let given;
1018
+ try { given = JSON.parse((await readBody(request)) || "{}")?.note; }
1019
+ catch (error) { return reply(error instanceof RangeError ? 413 : 400, { error: "invalid_idea" }); }
1020
+ if (given != null && given !== "") {
1021
+ note = tidy(given, IDEA_LIMITS.note, { lines: true });
1022
+ if (!note) return reply(400, { error: "invalid_idea" });
1023
+ }
1024
+ }
582
1025
 
583
1026
  let kept;
584
1027
  try { kept = store ?? cloudStore(env, fetcher); } catch { return reply(503, { error: "service_unavailable" }); }
585
- if (limited && method === "POST") {
1028
+ // A new idea, or a confirmation to a maker's address: each an email or more, so each is counted.
1029
+ if (limited && (method === "POST" || (method === "PUT" && makerEmail))) {
586
1030
  const ip = (request.headers.get("x-vercel-forwarded-for") || request.headers.get("x-forwarded-for") || "").trim();
587
1031
  if (!isIP(ip) || !kept.limiter) return reply(503, { error: "service_unavailable" });
588
1032
  let retry;
589
- try { retry = await reserve(kept.limiter, ip, IDEA_QUOTAS, fetcher); }
1033
+ try { retry = await reserve(kept.limiter, ip, method === "POST" ? IDEA_QUOTAS : MAKER_QUOTAS, fetcher); }
590
1034
  catch { return reply(503, { error: "service_unavailable" }); }
591
1035
  if (retry > 0) return reply(429, { error: "rate_limited" }, { "Retry-After": String(retry) });
592
1036
  }
@@ -596,35 +1040,250 @@ export function createIdeasHandler({ env = process.env, fetcher = fetch, store,
596
1040
  if (!record) return reply(404, { error: "not_found" });
597
1041
  if (method === "POST") {
598
1042
  const token = randomBytes(12).toString("base64url");
599
- const number = await kept.addIdea(id, { ...idea, token, at: now().toISOString() });
600
- return number ? reply(201, { n: number, token }) : reply(429, { error: "full" });
1043
+ // The picture goes to the store first, at an address with a random part, and goes again if the idea can't.
1044
+ const { picture: shot, ...given } = idea;
1045
+ // From a signed-in browser, it's by that username, and its news goes to the account's address. Otherwise an
1046
+ // address not yet confirmed is asked to be, once the idea is kept.
1047
+ let words = given, confirming = false;
1048
+ const user = await accountOf(kept, cookieKey(request));
1049
+ const account = user ? await kept.account(user) : null;
1050
+ if (account) words = { ...given, name: user, user, email: account.email };
1051
+ else if (given.email) confirming = !(await kept.accountForEmail(given.email.toLowerCase()));
1052
+ const at = shot ? await kept.putImage(id, "idea", shot) : null;
1053
+ let number = null;
1054
+ const added = { ...words, ...(at ? { picture: at } : {}), token, at: now().toISOString() };
1055
+ try { number = await kept.addIdea(id, added); }
1056
+ finally { if (!number && at) { try { await kept.removeImages([at]); } catch {} } }
1057
+ if (!number) return reply(429, { error: "full" });
1058
+ // To the maker's own address too, if they gave one, and the sender asked to confirm theirs: both at once, so the
1059
+ // answer that carries the sender's token comes inside the page's wait. The idea is kept whether or not they go.
1060
+ const [, confirm] = await Promise.all([
1061
+ (async () => {
1062
+ const maker = await kept.maker(id);
1063
+ if (maker?.confirmed) await writeToMaker(kept, id, { ...added, n: number }, record, linkOf(url.origin, record), { env, fetcher, origin: url.origin, maker, once: `maker-${id}-${number}` });
1064
+ })().catch(() => {}),
1065
+ confirming ? askToConfirm(kept, { email: words.email, name: words.name, app: record.name, origin: url.origin, env, fetcher }).catch(() => false) : false,
1066
+ ]);
1067
+ return reply(201, { n: number, token, ...(user ? { user } : {}), ...(confirm ? { confirm: true } : {}) });
601
1068
  }
602
- // The sender's own ideas, asked by number and token from their browser: what became of each, nothing else.
1069
+ // The sender's own ideas, asked by number and token from their browser: what became of each, and the maker's
1070
+ // last word to them, nothing else.
603
1071
  const mine = method === "GET" ? url.searchParams.get("mine") : null;
604
1072
  if (mine != null) {
605
1073
  const asked = new Map(mine.split(",").slice(0, IDEA_LIMITS.asked).map((one) => one.split("."))
606
1074
  .filter(([n, token]) => /^\d{1,6}$/.test(n ?? "") && /^[A-Za-z0-9_-]{16}$/.test(token ?? "")).map(([n, token]) => [Number(n), token]));
607
1075
  const all = await kept.ideas(id);
608
- return reply(200, { ideas: all.filter((one) => one.token && asked.get(one.n) === one.token)
609
- .map((one) => ({ n: one.n, status: one.status ?? "open", ...(one.done ? { done: one.done } : {}) })) });
1076
+ return reply(200, { ideas: all.filter((one) => one.token && asked.get(one.n) === one.token).map((one) => {
1077
+ // A note given before ideas had threads was kept on its own.
1078
+ const last = (one.thread ?? []).filter((said) => said.by === "maker").at(-1)?.text ?? one.note;
1079
+ return { n: one.n, status: one.status ?? "open", ...(one.done ? { done: one.done } : {}), ...(last ? { note: last } : {}) };
1080
+ }) });
610
1081
  }
611
1082
  if (!secretMatches(bearer(request), record.secret)) return reply(404, { error: "not_found" });
612
- // The maker sees the ideas still open, and whether someone wants to hear back, never their address: the site
613
- // writes to them. Those with an outcome are kept a while for their senders, then go.
1083
+ if (url.searchParams.get("maker") === "1" && (method === "GET" || method === "PUT")) return setMaker(kept, record, id, url.origin, method === "PUT" ? makerEmail : undefined);
1084
+ // The maker sees the ideas still open, and those answered since, and whether someone wants to hear back, never
1085
+ // their address: the site writes to them. Those settled are kept a while past their last word, then go.
614
1086
  if (method === "GET") {
615
1087
  const all = await kept.ideas(id);
616
- const old = all.filter((one) => one.done && now() - new Date(one.done) > IDEA_LIMITS.keptDone);
617
- for (const one of old) await kept.removeIdea(id, one.n);
618
- return reply(200, { ideas: all.filter((one) => !one.status).map(({ email, token: _token, ...shown }) => ({ ...shown, ...(email ? { wantsNews: true } : {}) })) });
1088
+ const old = all.filter((one) => one.done && now() - new Date(one.last ?? one.done) > IDEA_LIMITS.keptDone);
1089
+ for (const one of old) {
1090
+ await kept.removeIdea(id, one.n);
1091
+ for (const box of [one.mailbox, one.makerBox]) if (box) { try { await kept.closeMailbox(box); } catch {} }
1092
+ }
1093
+ try { await kept.removeImages(ideaPictures(old)); } catch {}
1094
+ // `earlier=1`: those seen to, the latest word first, for the maker to look back on or answer again.
1095
+ if (url.searchParams.get("earlier") === "1") {
1096
+ const settled = all.filter((one) => one.status && !one.waiting && !old.includes(one))
1097
+ .sort((a, b) => String(b.last ?? b.done).localeCompare(String(a.last ?? a.done)));
1098
+ return reply(200, { ideas: settled.slice(0, IDEA_LIMITS.earlier).map(shownToMaker) });
1099
+ }
1100
+ return reply(200, { ideas: all.filter((one) => !one.status || one.waiting).map(shownToMaker) });
1101
+ }
1102
+ const link = linkOf(url.origin, record);
1103
+ const write = (to, kind, said, once) => writeToSender(kept, id, to, record, link, { env, fetcher, kind, said, origin: url.origin, once });
1104
+ const said = note ? { by: "maker", text: note, at: now().toISOString(), mid: newMessageID(env) } : null;
1105
+ if (method === "PATCH") {
1106
+ if (!said) return (await kept.talk(id, n, "settle")) ? reply(200, { notified: false }) : reply(404, { error: "not_found" });
1107
+ const was = await kept.talk(id, n, "say", said);
1108
+ if (!was) return (await kept.ideas(id)).some((one) => one.n === n) ? reply(429, { error: "full" }) : reply(404, { error: "not_found" });
1109
+ return reply(200, { notified: await write(was, "reply", said, `idea-${id}-${n}-${said.mid}`) });
619
1110
  }
620
1111
  // An outcome, given once: only the call that gave it writes to the sender, so two at once, or one tried again,
621
1112
  // send one email.
622
1113
  const built = url.searchParams.get("built") === "1";
623
- const was = await kept.finishIdea(id, n, built ? "in" : "dismissed", now().toISOString());
624
- const notified = built && was?.email ? await tellItsIn(was, record, linkFor(url.origin, id, record.name), { env, fetcher, once: `idea-${id}-${n}` }) : false;
1114
+ // Its picture was for the maker, who has seen to it: it goes first, so a store that turns that away leaves the
1115
+ // idea open, with its picture, to be let go again, rather than the picture left with nothing pointing to it.
1116
+ const open = (await kept.ideas(id)).find((one) => one.n === n && !one.status);
1117
+ if (open) await kept.removeImages(ideaPictures([open]));
1118
+ const was = await kept.finishIdea(id, n, built ? "in" : "dismissed", now().toISOString(), said);
1119
+ const notified = built && was?.email ? await write(was, "in", said, `idea-${id}-${n}`) : false;
625
1120
  return reply(200, { notified });
626
1121
  } catch { return reply(503, { error: "service_unavailable" }); }
627
1122
  };
1123
+
1124
+ /**
1125
+ * The maker's own address: as it is (`email` undefined), or given (asked to confirm it first, by an email with a
1126
+ * link in it, unless it's the one they confirmed already) or taken away (""). Never anyone else's to see.
1127
+ */
1128
+ async function setMaker(kept, record, id, origin, email) {
1129
+ let maker = await kept.maker(id);
1130
+ let sent = false;
1131
+ if (email === "") { await kept.setMaker(id, null); maker = null; }
1132
+ else if (email !== undefined && !(maker?.confirmed && maker.email.toLowerCase() === email.toLowerCase())) {
1133
+ // Send Again, or another address, at most once a minute for a link.
1134
+ const since = now() - new Date(maker?.asked ?? 0);
1135
+ if (since < MAKER_RESEND_MS) return reply(429, { error: "rate_limited" }, { "Retry-After": String(Math.ceil((MAKER_RESEND_MS - since) / 1000)) });
1136
+ maker = { email, confirmed: false, key: randomBytes(10).toString("hex"), asked: now().toISOString() };
1137
+ await kept.setMaker(id, maker);
1138
+ sent = await sendMail(env, fetcher, {
1139
+ to: [email], subject: `Get ideas for ${record.name} by email`,
1140
+ text: `To get the ideas people send for ${record.name}, and their replies, at this address, confirm it here:\n\n` +
1141
+ `${origin}/api/wish-ideas?id=${id}&confirm=${maker.key}\n\nIf you didn't ask for this, you can ignore this email.`,
1142
+ });
1143
+ }
1144
+ return reply(200, { email: maker?.email ?? null, confirmed: maker?.confirmed === true, ...(email && !maker?.confirmed ? { sent } : {}) });
1145
+ }
1146
+
1147
+ /** A link from the maker's emails: their address confirmed, or let go, if the key is theirs. */
1148
+ async function makerLink(id, step, key) {
1149
+ if (!/^[0-9a-f]{20}$/.test(key)) return reply(404, { error: "not_found" });
1150
+ try {
1151
+ const kept = store ?? cloudStore(env, fetcher);
1152
+ await kept.makerStep(id, key, step === "confirm" ? "confirm" : "stop");
1153
+ } catch { return reply(503, { error: "service_unavailable" }); }
1154
+ const words = step === "confirm" ? "Ideas for this link, and replies to them, will come to your email." : "You won't get any more emails about this link's ideas.";
1155
+ return new Response(`${words}\n`, { status: 200, headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" } });
1156
+ }
1157
+
1158
+ /** A link from one of the emails: the idea's address goes, if the mailbox is the idea's own. */
1159
+ async function stopEmails(id, n, box) {
1160
+ if (!Number.isInteger(n) || n < 1 || !/^[0-9a-f]{20}$/.test(box)) return reply(404, { error: "not_found" });
1161
+ try {
1162
+ const kept = store ?? cloudStore(env, fetcher);
1163
+ await kept.talk(id, n, "stop", box);
1164
+ } catch { return reply(503, { error: "service_unavailable" }); }
1165
+ // The same words whether or not it was theirs, so the link says nothing about an idea to anyone else.
1166
+ return new Response("You won't get any more emails about this idea.\n", { status: 200, headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" } });
1167
+ }
1168
+ }
1169
+
1170
+ /**
1171
+ * An idea as its maker sees it: never its sender's address, its token or its mailbox, only whether they want to hear.
1172
+ * A note given before ideas had threads is shown as the start of one.
1173
+ */
1174
+ const shownToMaker = ({ email, token: _token, mailbox: _mailbox, makerBox: _makerBox, subject: _subject, note, ...given }) => {
1175
+ const shown = note && !given.thread ? { ...given, thread: [{ by: "maker", text: note, at: given.done ?? given.at }] } : given;
1176
+ return {
1177
+ ...shown,
1178
+ ...(shown.thread ? { thread: shown.thread.map(({ by, text, at }) => ({ by, text, at })) } : {}),
1179
+ ...(email ? { wantsNews: true } : {}),
1180
+ };
1181
+ };
1182
+
1183
+ /** The address in a From line, in lower case: `Someone <a@b.c>` or `a@b.c`. */
1184
+ const addressIn = (from) => (/<([^<>\s]+@[^<>\s]+)>/.exec(from ?? "")?.[1] ?? String(from ?? "").trim()).toLowerCase();
1185
+
1186
+ /**
1187
+ * A reply's own words: what's above the earlier email it quotes (a line "On … wrote:", a "-----Original Message-----"
1188
+ * or a "From:" line after a blank one), without lines quoted with ">", tidied and cut to `IDEA_LIMITS.note`.
1189
+ */
1190
+ export function replyWords(text) {
1191
+ const lines = String(text ?? "").replace(/\r\n?/g, "\n").split("\n");
1192
+ const kept = [];
1193
+ for (let i = 0; i < lines.length; i++) {
1194
+ const line = lines[i];
1195
+ const two = `${line} ${lines[i + 1] ?? ""}`;
1196
+ if (/^\s*-{2,}\s*Original Message\s*-{2,}/i.test(line) || /^\s*_{10,}\s*$/.test(line)) break;
1197
+ if (/^\s*On\b.{0,300}\bwrote:\s*$/i.test(line) || (/^\s*On\b/i.test(line) && /^\s*On\b.{0,300}\bwrote:\s*$/i.test(two))) break;
1198
+ if (/^\s*From:\s/i.test(line) && kept.length && kept.at(-1).trim() === "") break;
1199
+ if (/^\s*>/.test(line)) continue;
1200
+ kept.push(line);
1201
+ }
1202
+ const words = kept.join("\n").trim();
1203
+ return tidy([...words].slice(0, IDEA_LIMITS.note).join(""), IDEA_LIMITS.note, { lines: true });
1204
+ }
1205
+
1206
+ /**
1207
+ * Whether a webhook from Resend is its own (`RESEND_WEBHOOK_SECRET`, `whsec_` and base64): Svix's signature, an
1208
+ * HMAC-SHA256 of `<id>.<timestamp>.<body>`, sent no more than five minutes ago.
1209
+ */
1210
+ export function signedByResend(headers, body, secret, now = Date.now()) {
1211
+ const id = headers.get("svix-id"), at = headers.get("svix-timestamp"), signatures = headers.get("svix-signature");
1212
+ if (!secret || !id || !at || !signatures || !/^\d+$/.test(at) || Math.abs(now / 1000 - Number(at)) > 300) return false;
1213
+ const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
1214
+ const expected = createHmac("sha256", key).update(`${id}.${at}.${body}`).digest();
1215
+ return signatures.split(" ").some((one) => {
1216
+ const [version, signature] = one.split(",");
1217
+ if (version !== "v1" || !signature) return false;
1218
+ const given = Buffer.from(signature, "base64");
1219
+ return given.length === expected.length && timingSafeEqual(given, expected);
1220
+ });
1221
+ }
1222
+
1223
+ /**
1224
+ * Replies to an idea's emails (`/api/idea-mail`, Resend's `email.received` webhook, for mail to
1225
+ * `LAMP_IDEAS_REPLY_DOMAIN`). Each to `idea-<mailbox>@` it goes on that idea's thread, its own words only: from the
1226
+ * sender's mailbox, only from the address the idea was sent with, leaving it waiting on the maker and sent on to the
1227
+ * maker's own address if they gave one; from the maker's, only from their confirmed address, as their word, sent on to
1228
+ * the sender. Anything else is let go. Answers 200 to
1229
+ * whatever Resend signed, so it isn't sent again, and 401 to anything else.
1230
+ */
1231
+ export function createIdeaMailHandler({ env = process.env, fetcher = fetch, store, now = () => new Date() } = {}) {
1232
+ return async (request) => {
1233
+ if (request.method !== "POST") return reply(405, { error: "method_not_allowed" }, { Allow: "POST" });
1234
+ let body;
1235
+ try { body = await readBody(request); } catch { return reply(413, { error: "too_large" }); }
1236
+ if (!signedByResend(request.headers, body, env.RESEND_WEBHOOK_SECRET, now().getTime())) return reply(401, { error: "unsigned" });
1237
+ let event;
1238
+ try { event = JSON.parse(body); } catch { return reply(200, {}); }
1239
+ const mail = event?.data;
1240
+ const domain = (env.LAMP_IDEAS_REPLY_DOMAIN ?? "").toLowerCase();
1241
+ if (event?.type !== "email.received" || !domain || typeof mail?.email_id !== "string") return reply(200, {});
1242
+ const boxes = [...new Set([...(mail.to ?? []), ...(mail.received_for ?? [])].map(addressIn)
1243
+ .map((to) => new RegExp(`^idea-([0-9a-f]{20})@${domain.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`).exec(to)?.[1]).filter(Boolean))];
1244
+ if (!boxes.length) return reply(200, {});
1245
+ const origin = new URL(request.url).origin;
1246
+ try {
1247
+ const kept = store ?? cloudStore(env, fetcher);
1248
+ for (const box of boxes.slice(0, 3)) {
1249
+ const found = await kept.mailbox(box);
1250
+ if (!found) continue;
1251
+ const idea = (await kept.ideas(found.id)).find((one) => one.n === found.n);
1252
+ if (!idea) continue;
1253
+ const maker = await kept.maker(found.id);
1254
+ // Only from the address the idea's emails went to on that side, and only while it's kept.
1255
+ const from = addressIn(mail.from);
1256
+ const fromMaker = found.side === "maker";
1257
+ if (fromMaker ? !(maker?.confirmed && idea.makerBox === box && from === maker.email.toLowerCase())
1258
+ : !(idea.email && idea.mailbox === box && from === idea.email.toLowerCase())) continue;
1259
+ const response = await fetcher(`https://api.resend.com/emails/receiving/${encodeURIComponent(mail.email_id)}`, {
1260
+ redirect: "error", signal: AbortSignal.timeout(5000), headers: { Authorization: `Bearer ${env.RESEND_API_KEY}` },
1261
+ });
1262
+ if (!response.ok) return reply(503, { error: "service_unavailable" });
1263
+ const got = await response.json();
1264
+ const words = replyWords(got.text ?? String(got.html ?? "").replace(/<br\s*\/?>|<\/p>|<\/div>/gi, "\n").replace(/<[^>]*>/g, "")
1265
+ .replace(/&nbsp;/g, " ").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&"));
1266
+ if (!words) continue;
1267
+ const mid = typeof mail.message_id === "string" && /^<[^<>\s]+>$/.test(mail.message_id) ? mail.message_id : undefined;
1268
+ const record = await kept.get(found.id);
1269
+ if (!record) continue;
1270
+ const link = linkOf(origin, record);
1271
+ // The email as Resend received it, which a webhook sent again names the same, keeps it from going on twice.
1272
+ const via = `resend:${mail.email_id}`;
1273
+ if (fromMaker) {
1274
+ // The maker's own word, as if said in Lamp: on the thread, and on to the sender, with a message id of its own.
1275
+ const said = { by: "maker", text: words, at: now().toISOString(), mid: newMessageID(env), via };
1276
+ const was = await kept.talk(found.id, found.n, "say", said);
1277
+ if (was?.email) await writeToSender(kept, found.id, was, record, link, { env, fetcher, kind: "reply", said, origin, once: `idea-${found.id}-${found.n}-${said.mid}` });
1278
+ } else {
1279
+ const said = { by: "sender", text: words, at: now().toISOString(), via, ...(mid ? { mid } : {}) };
1280
+ const was = await kept.talk(found.id, found.n, "say", said);
1281
+ if (was && maker?.confirmed) await writeToMaker(kept, found.id, was, record, link, { env, fetcher, kind: "reply", said, origin, maker, once: `maker-${found.id}-${found.n}-${mail.email_id}` });
1282
+ }
1283
+ }
1284
+ } catch { return reply(503, { error: "service_unavailable" }); }
1285
+ return reply(200, {});
1286
+ };
628
1287
  }
629
1288
 
630
1289
  /** A shared wish for the page and its card, or null when there's none by that id (or it can't be read now). */
@@ -640,8 +1299,9 @@ export async function takeDown(id, { env = process.env, fetcher = fetch } = {})
640
1299
  const kept = cloudStore(env, fetcher);
641
1300
  const record = await kept.get(id);
642
1301
  if (!record) return false;
1302
+ const ideaShots = ideaPictures(await kept.ideas(id).catch(() => []));
643
1303
  await kept.remove(id);
644
- await kept.removeImages(blobsOf(record));
1304
+ await kept.removeImages([...blobsOf(record), ...ideaShots]);
645
1305
  await kept.forget(id);
646
1306
  return true;
647
1307
  }
@@ -652,6 +1312,9 @@ export async function takeDown(id, { env = process.env, fetcher = fetch } = {})
652
1312
  // A page is sent packed within `FILE_LIMITS.total`; what it unpacks to may be more, since a big 3D game's data packs small.
653
1313
  export const PAGE_LIMITS = { files: 3000, total: 128_000_000 };
654
1314
 
1315
+ /** How many of a page's files go to the blob store at once as it's put together. */
1316
+ const PAGE_UPLOADS = 12;
1317
+
655
1318
  /**
656
1319
  * A zip's files, read from its central directory: stored or deflated, never a link, a folder, an absolute path or one
657
1320
  * that steps up. Null for anything else, or past the limits, counting what they unpack to.
@@ -727,16 +1390,54 @@ export function playOrigin(base, id) {
727
1390
  */
728
1391
  /**
729
1392
  * A page's file this big or bigger is played straight from the store instead of through the play host: the CDN keeps
730
- * nothing over 20 MB from a function, so every visit would pass it through again. Only for a page kept with each
1393
+ * nothing over 10 MB from a function, so every visit would pass it through again. Only for a page kept with each
731
1394
  * file's own type (`page.sizes`), since a browser won't run a script the store calls anything else. Never a page's
732
1395
  * HTML, which the store hands out as a download.
733
1396
  */
734
- export const DIRECT_FILE = 16_000_000;
1397
+ export const DIRECT_FILE = 9_000_000;
1398
+ /**
1399
+ * A file of data a game reads with fetch (map tiles, models, sound, JSON) is played straight from the store, whatever
1400
+ * its size: the store's CDN serves it to any page (CORS for all), keeps it whatever the site deploys, and the play host
1401
+ * reads nothing for it. A game may have hundreds of small ones (a map's tiles), all asked for again after each deploy.
1402
+ * From 1 byte, so only for a page kept with each file's size and type (`page.sizes`). The play host reading a game's hundreds of files from the store in bursts is what the store
1403
+ * turned away (403) until the game didn't load. Not a script or a style sheet, whose own imports would then resolve
1404
+ * at the store, nor a picture, which a game may draw from without asking for it cross-origin. Only when the page
1405
+ * fetched it (`Sec-Fetch-Mode: cors`): a video or sound an element of the page loads (no-cors), drawn into WebGL or
1406
+ * played through Web Audio, works only from the page's own origin.
1407
+ */
1408
+ export const DIRECT_DATA = 1;
1409
+ const DATA_FILE = /\.(bin|gz|br|zst|json|geojson|csv|txt|glb|gltf|ktx2|basis|drc|wasm|mp3|ogg|m4a|wav|aac|flac|opus|mp4|webm|mov)$/i;
735
1410
 
736
1411
  /** How long a link's record is remembered for serving its page's files, and how many links at once. */
737
1412
  export const PLAY_MEMORY = { ms: 60_000, links: 200 };
738
1413
 
739
- export function createPlayHandler({ env = process.env, fetcher = fetch, store, anyHost = false, direct = DIRECT_FILE, now = () => Date.now() } = {}) {
1414
+ /**
1415
+ * What a page played on its link is given at the top of its HTML: when the link's page asks for a picture of the app
1416
+ * (as someone opens the box to send its maker an idea), the app's biggest canvas, drawn after the app has drawn its
1417
+ * next frame, scaled to at most 1280 wide, as a JPEG small enough to go with an idea. Only the site's own page may ask,
1418
+ * and only it is answered. Nothing for an app without a canvas, one that can't be read, or one with nothing drawn in it
1419
+ * (see-through all over). A JPEG has no see-through, so what shows through the canvas on the page is put behind it: the
1420
+ * first color behind it there, else white as the browser's own. A scene that's dark (night, space) is a picture.
1421
+ *
1422
+ * A 3D canvas's picture is gone once its frame is on screen, and an app that draws only when something changes (a
1423
+ * builder, a viewer) draws no next frame to catch: so before the app's own code runs, every WebGL canvas made here
1424
+ * keeps its last picture (`preserveDrawingBuffer`), which costs a game little and lets any of them be read.
1425
+ */
1426
+ export function pictureScript(site) {
1427
+ // The most a picture may be as a data: URL to go with an idea (`IDEA_LIMITS.picture` bytes, as base64).
1428
+ const limit = 23 + Math.floor(IDEA_LIMITS.picture / 3) * 4;
1429
+ const keep = `try{const get=HTMLCanvasElement.prototype.getContext;HTMLCanvasElement.prototype.getContext=function(type,options){if(/^(webgl2?|experimental-webgl)$/.test(type))options=Object.assign({},options,{preserveDrawingBuffer:true});return get.call(this,type,options)}}catch(_){}`;
1430
+ // Nothing drawn (a canvas read when it held nothing) is no picture: every point of the copy see-through. Read before
1431
+ // anything is put behind it, and by alpha alone, so a dark scene is kept.
1432
+ const blank = `const blank=x=>{const d=x.getImageData(0,0,x.canvas.width,x.canvas.height).data;for(let i=3;i<d.length;i+=4)if(d[i])return false;return true};`;
1433
+ // The color that shows through the canvas on the page: its own background or the first of the elements around it that
1434
+ // has one. None (the page left as the browser draws it) is white. Only an alpha of 0 is none: the fourth of rgba()'s
1435
+ // values or what follows a slash, never a color's last channel, so black, red and green count.
1436
+ const behind = `const behind=c=>{for(let e=c;e;e=e.parentElement){const b=getComputedStyle(e).backgroundColor;if(b&&b!=="transparent"&&!/^rgba\\(([^,]*,){3}\\s*0(\\.0*)?%?\\s*\\)$|\\/\\s*0(\\.0*)?%?\\s*\\)$/.test(b))return b}return"#fff"};`;
1437
+ return `<script>(()=>{${keep}const site=${JSON.stringify(site)};${blank}${behind}addEventListener("message",e=>{if(e.origin!==site||e.source!==parent||!e.data||e.data.lamp!=="picture")return;const n=e.data.n;requestAnimationFrame(()=>{let picture=null;try{const all=[...document.querySelectorAll("canvas")].filter(c=>c.width&&c.height&&c.getClientRects().length);const c=all.sort((a,b)=>b.clientWidth*b.clientHeight-a.clientWidth*a.clientHeight)[0];if(c){const k=Math.min(1,1280/c.width),o=document.createElement("canvas");o.width=Math.round(c.width*k);o.height=Math.round(c.height*k);const x=o.getContext("2d");x.drawImage(c,0,0,o.width,o.height);if(!blank(x)){x.globalCompositeOperation="destination-over";x.fillStyle=behind(c);x.fillRect(0,0,o.width,o.height);for(const q of [0.8,0.6,0.4]){picture=o.toDataURL("image/jpeg",q);if(picture.length<=${limit})break;picture=null}}}}catch(_){}parent.postMessage({lamp:"picture",n,picture},site)})})})()</script>`;
1438
+ }
1439
+
1440
+ export function createPlayHandler({ env = process.env, fetcher = fetch, store, anyHost = false, direct = DIRECT_FILE, directData = DIRECT_DATA, now = () => Date.now() } = {}) {
740
1441
  // A link's record, remembered a minute per instance: a game asks for hundreds of files as it's played, and reading
741
1442
  // the store for each used up its allowance of requests in a day, which took every link down. A version the memory
742
1443
  // doesn't have (a release since) is read afresh; a link taken down plays on here for that minute at most.
@@ -796,18 +1497,23 @@ export function createPlayHandler({ env = process.env, fetcher = fetch, store, a
796
1497
  // Not for a worker's own script, which a browser only takes from the page's own origin, nor from a store a browser
797
1498
  // can't reach (the dev server's memory). Kept apart by what asked (`Vary`), so a worker never gets the redirect.
798
1499
  const worker = /worker$/i.test(request.headers.get("sec-fetch-dest") ?? "");
799
- if (!rebased && !worker && at.startsWith("https://") && !/\.html?$/i.test(name) && (served.sizes?.[name] ?? 0) >= direct) {
800
- return new Response(null, { status: 302, headers: { Location: at, Vary: "Sec-Fetch-Dest", "Cache-Control": "public, max-age=300, s-maxage=31536000", ...cacheTags(id) } });
1500
+ const size = served.sizes?.[name] ?? 0;
1501
+ if (!rebased && !worker && at.startsWith("https://") && !/\.html?$/i.test(name) && (size >= direct || (DATA_FILE.test(name) && size >= directData && request.headers.get("sec-fetch-mode") === "cors"))) {
1502
+ return new Response(null, { status: 302, headers: { Location: at, Vary: "Sec-Fetch-Dest, Sec-Fetch-Mode", "Cache-Control": "public, max-age=300, s-maxage=31536000", ...cacheTags(id) } });
801
1503
  }
802
- // Passed on as it comes from the store; only a folder's index.html given its base is read whole, to change it.
1504
+ // Passed on as it comes from the store; only a page's HTML is read whole, to be given what the link's page asks of
1505
+ // it (`pictureScript`), and a folder's index.html its base.
803
1506
  const kept = store ?? cloudStore(env, fetcher);
1507
+ const site = env.LAMP_SITE_ORIGIN || (anyHost ? url.origin : "https://lamp.dev");
804
1508
  let bytes, length;
805
1509
  try {
806
- if (rebased) {
807
- const base = `<base href="/w/${id}/play/${version}/${folder.split("/").map(encodeURIComponent).join("/")}/">`;
1510
+ if (/\.html?$/i.test(name) && request.method !== "HEAD") {
1511
+ const base = rebased ? `<base href="/w/${id}/play/${version}/${folder.split("/").map(encodeURIComponent).join("/")}/">` : "";
1512
+ const added = base + pictureScript(site);
808
1513
  const html = (await kept.readFile(at)).toString("utf8");
809
- const head = html.search(/<head[^>]*>/i);
810
- bytes = Buffer.from(head < 0 ? base + html : html.replace(/<head[^>]*>/i, (tag) => tag + base));
1514
+ // In its head, or after its doctype where it has no head of its own: before a doctype, a page is drawn in quirks mode.
1515
+ const head = /<head[^>]*>/i.exec(html) ?? /^(?:\s|<!--[\s\S]*?-->)*<!doctype[^>]*>/i.exec(html);
1516
+ bytes = Buffer.from(head ? html.slice(0, head.index + head[0].length) + added + html.slice(head.index + head[0].length) : added + html);
811
1517
  } else if (request.method !== "HEAD") {
812
1518
  ({ body: bytes, length } = await kept.openFile(at));
813
1519
  }
@@ -815,7 +1521,6 @@ export function createPlayHandler({ env = process.env, fetcher = fetch, store, a
815
1521
  console.error("wish play: a file couldn't be read:", error?.name, error?.message);
816
1522
  return new Response(null, { status: 503 });
817
1523
  }
818
- const site = env.LAMP_SITE_ORIGIN || (anyHost ? url.origin : "https://lamp.dev");
819
1524
  return new Response(request.method === "HEAD" ? null : bytes, {
820
1525
  headers: {
821
1526
  "Content-Type": pageType(name), "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer",
@@ -858,7 +1563,17 @@ export const fileName = (name) => `${(name.normalize("NFKD").replace(/[^\w\s-]/g
858
1563
  * within `FILE_LIMITS.total`, and gives it to the link in place of any it had; DELETE ?id= takes it away. The parts
859
1564
  * are taken away once they're together, or with the link.
860
1565
  */
861
- export function createFileHandler({ env = process.env, fetcher = fetch, store, limited = true, now = () => new Date() } = {}) {
1566
+ /**
1567
+ * Work a request goes on with once it has answered (`waitUntil`, on Vercel), which the function is kept alive for up
1568
+ * to its whole time; elsewhere (a test, `npm run dev`) it's done before the answer.
1569
+ */
1570
+ async function afterAnswering(work, env) {
1571
+ if (!env.VERCEL) return await work();
1572
+ const { waitUntil } = await import("@vercel/functions");
1573
+ waitUntil(work());
1574
+ }
1575
+
1576
+ export function createFileHandler({ env = process.env, fetcher = fetch, store, limited = true, now = () => new Date(), later = (work) => afterAnswering(work, env) } = {}) {
862
1577
  return async (request) => {
863
1578
  const method = request.method;
864
1579
  if (!["PUT", "POST", "DELETE"].includes(method)) return reply(405, { error: "method_not_allowed" }, { Allow: "PUT, POST, DELETE" });
@@ -944,32 +1659,48 @@ export function createFileHandler({ env = process.env, fetcher = fetch, store, l
944
1659
  if (page) {
945
1660
  const files = readPage(bytes);
946
1661
  if (!files) return reply(400, { error: "invalid_page" });
947
- // Each version in a folder of its own, under a random part, so an old one never mixes with a new one.
948
- const token = randomBytes(12).toString("base64url");
949
- const urls = {}, sizes = {};
950
- // A few at a time: a page can be hundreds of files.
951
- for (let n = 0; n < files.length; n += 8) {
952
- await Promise.all(files.slice(n, n + 8).map(async (file) => {
953
- urls[file.path] = await kept.putPageFile(id, token, file.path, file.bytes);
954
- sizes[file.path] = file.bytes.length;
955
- uploaded.push(urls[file.path]);
956
- }));
957
- }
958
1662
  // The count is the link's (`pageVersion`), so a page taken away and sent again never reuses a play address.
959
1663
  const version = (record.page?.version ?? record.pageVersion ?? 0) + 1;
960
- // The page it replaces stays playable as the version before, so a game open on it goes on finding its files
961
- // (a game loads its map as the player moves); the one before that goes now.
962
- const { pageParts: _pageParts, ...rest } = record;
963
- const previousPage = record.page ? { version: record.page.version, files: record.page.files, sizes: record.page.sizes } : undefined;
964
- if (!(await kept.replace({ ...rest, page: { version, files: urls, sizes }, ...(previousPage ? { previousPage } : {}),
965
- pageVersion: version, updated: now().toISOString() }))) {
966
- await kept.removeImages([...uploaded, ...partBlobs(parts)]);
967
- uploaded = [];
968
- return reply(404, { error: "not_found" });
969
- }
970
- uploaded = [];
971
- await kept.removeImages([...Object.values(record.previousPage?.files ?? {}), ...partBlobs(parts)]);
972
- await kept.forget(id);
1664
+ // Checked, it's answered at once, and its files are stored after: a big game's page is hundreds of files, which
1665
+ // take longer than a Lamp waits for an answer, and a Lamp that heard nothing took the link's page down. The
1666
+ // link plays the page it had until this one is in; if this one never gets in, it goes on playing that one.
1667
+ await later(async () => {
1668
+ const stored = [];
1669
+ try {
1670
+ // Each version in a folder of its own, under a random part, so an old one never mixes with a new one.
1671
+ const token = randomBytes(12).toString("base64url");
1672
+ const urls = {}, sizes = {};
1673
+ // Many at a time, within what the blob store takes. Said in the log as it goes, so a page that doesn't get in
1674
+ // shows how far it got.
1675
+ const began = Date.now();
1676
+ console.log(`Page ${version} of ${id}: storing ${files.length} files`);
1677
+ for (let n = 0; n < files.length; n += PAGE_UPLOADS) {
1678
+ await Promise.all(files.slice(n, n + PAGE_UPLOADS).map(async (file) => {
1679
+ // Each file named as it starts, so a page that stops shows on which.
1680
+ console.log(`Page ${version} of ${id}: storing ${file.path} (${file.bytes.length} bytes)`);
1681
+ urls[file.path] = await kept.putPageFile(id, token, file.path, file.bytes);
1682
+ sizes[file.path] = file.bytes.length;
1683
+ stored.push(urls[file.path]);
1684
+ }));
1685
+ if ((n / PAGE_UPLOADS) % 10 === 9) console.log(`Page ${version} of ${id}: ${stored.length} of ${files.length} stored, ${Math.round((Date.now() - began) / 1000)}s`);
1686
+ }
1687
+ console.log(`Page ${version} of ${id}: all ${files.length} stored in ${Math.round((Date.now() - began) / 1000)}s`);
1688
+ // As the record is now, not as it was when the parts were read: the link may have changed meanwhile.
1689
+ const current = await kept.get(id);
1690
+ if (!current || !secretMatches(bearer(request), current.secret)) throw new Error("Gone");
1691
+ // The page it replaces stays playable as the version before, so a game open on it goes on finding its
1692
+ // files (a game loads its map as the player moves); the one before that goes now.
1693
+ const { pageParts: _pageParts, ...rest } = current;
1694
+ const previousPage = current.page ? { version: current.page.version, files: current.page.files, sizes: current.page.sizes } : undefined;
1695
+ if (!(await kept.replace({ ...rest, page: { version, files: urls, sizes }, ...(previousPage ? { previousPage } : {}),
1696
+ pageVersion: Math.max(version, current.pageVersion ?? 0), updated: now().toISOString() }))) throw new Error("Gone");
1697
+ await kept.removeImages([...Object.values(current.previousPage?.files ?? {}), ...partBlobs(parts)]);
1698
+ await kept.forget(id);
1699
+ } catch (error) {
1700
+ console.error(`Page ${version} of ${id} didn't get in, ${stored.length} of ${files.length} stored: ${error?.message ?? error}${error?.cause ? ` (${error.cause.message ?? error.cause})` : ""}`);
1701
+ try { await kept.removeImages(stored); } catch {}
1702
+ }
1703
+ });
973
1704
  return reply(200, { version, files: files.length });
974
1705
  }
975
1706
  if (!isWishFile(bytes)) return reply(400, { error: "invalid_file" });