@rackbops/plugin-tracker 0.36.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +61 -10
  2. package/dist/plugin.js +720 -77
  3. package/package.json +10 -3
package/dist/plugin.js CHANGED
@@ -777,8 +777,15 @@ function followUpKey(occurrenceId) {
777
777
  return `${FOLLOW_UP_PREFIX}${occurrenceId}`;
778
778
  }
779
779
  var FOLLOW_UP_PREFIX = "followup:";
780
+ function runNowKey(taskId, requestId) {
781
+ if (!REQUEST_ID.test(requestId))
782
+ throw new Error("runNowKey: requestId must be 1 to 64 of A-Z a-z 0-9 - _");
783
+ return `${RUN_NOW_PREFIX}${taskId}:${requestId}`;
784
+ }
785
+ var REQUEST_ID = /^[A-Za-z0-9_-]{1,64}$/;
786
+ var RUN_NOW_PREFIX = "now:";
780
787
  function isOffSchedule(dedupeKey) {
781
- return dedupeKey.startsWith(SNOOZE_PREFIX) || dedupeKey.startsWith(FOLLOW_UP_PREFIX);
788
+ return dedupeKey.startsWith(SNOOZE_PREFIX) || dedupeKey.startsWith(FOLLOW_UP_PREFIX) || dedupeKey.startsWith(RUN_NOW_PREFIX);
782
789
  }
783
790
 
784
791
  // node_modules/@rackbops/docket-core/dist/ports.js
@@ -4786,6 +4793,44 @@ function withinLimits(l, config) {
4786
4793
  return true;
4787
4794
  return l.price !== undefined && l.price <= config.maxPrice + 0.000000001;
4788
4795
  }
4796
+ var MAX_EXCLUDE = 20;
4797
+ var EXCLUDE_CHARS = 80;
4798
+ function parseExclude(text) {
4799
+ const out = [];
4800
+ const seen = new Set;
4801
+ for (const raw of text.split(/[,\n]/)) {
4802
+ const entry = raw.trim().slice(0, EXCLUDE_CHARS).trim();
4803
+ const key = wordsOf(entry).join(" ");
4804
+ if (!key || seen.has(key))
4805
+ continue;
4806
+ seen.add(key);
4807
+ out.push(entry);
4808
+ if (out.length === MAX_EXCLUDE)
4809
+ break;
4810
+ }
4811
+ return out;
4812
+ }
4813
+ function isExcluded(title, value) {
4814
+ const exclude = typeof value === "string" ? parseExclude(value) : value;
4815
+ if (!Array.isArray(exclude) || exclude.length === 0)
4816
+ return false;
4817
+ const have = wordsOf(title);
4818
+ return exclude.slice(0, MAX_EXCLUDE).some((entry) => {
4819
+ if (typeof entry !== "string")
4820
+ return false;
4821
+ const want = wordsOf(entry.slice(0, EXCLUDE_CHARS));
4822
+ if (want.length === 0)
4823
+ return false;
4824
+ for (let i = 0;i + want.length <= have.length; i++) {
4825
+ if (want.every((w, j) => have[i + j] === w))
4826
+ return true;
4827
+ }
4828
+ return false;
4829
+ });
4830
+ }
4831
+ function wanted(l, config) {
4832
+ return withinLimits(l, config) && !isExcluded(l.title, config.exclude);
4833
+ }
4789
4834
  function priceText(l, config) {
4790
4835
  return l.price === undefined ? "price not shown" : money(l.price, l.currency ?? config.currency ?? "");
4791
4836
  }
@@ -4865,9 +4910,11 @@ async function pollWant(ctx, sources, stored) {
4865
4910
  throw err;
4866
4911
  }
4867
4912
  const told = new Set(state.reported);
4868
- const within = listings.filter((l) => withinLimits(l, ctx.config));
4913
+ const limited = listings.filter((l) => withinLimits(l, ctx.config));
4914
+ const within = limited.filter((l) => !isExcluded(l.title, ctx.config.exclude));
4869
4915
  const fresh = within.filter((l) => !told.has(listingKey(l.id))).slice(0, MAX_NEW_PER_RUN);
4870
- const summary = `${listings.length} listed, ${within.length} within limits, ${fresh.length} new`;
4916
+ const dropped = limited.length - within.length;
4917
+ const summary = `${listings.length} listed, ${limited.length} within limits, ` + `${dropped > 0 ? `${dropped} excluded, ` : ""}${fresh.length} new`;
4871
4918
  const read = { ...state, misses: 0, warned: false };
4872
4919
  if (fresh.length === 0)
4873
4920
  return { kind: "done", outcome: { state: read, summary } };
@@ -4904,6 +4951,12 @@ function wantlistType(sources) {
4904
4951
  description: "Only listings in this currency",
4905
4952
  required: false,
4906
4953
  kind: "string"
4954
+ },
4955
+ {
4956
+ name: "exclude",
4957
+ description: "Leave out listings whose title has any of these words or phrases, separated by commas",
4958
+ required: false,
4959
+ kind: "string"
4907
4960
  }
4908
4961
  ]
4909
4962
  },
@@ -5164,7 +5217,7 @@ function judgeFailed(ctx, state, kind, detail) {
5164
5217
  return { state: { ...state, failures, retryAt: retry.at }, followUp: retry.followUp, summary };
5165
5218
  }
5166
5219
  const reason = SAID3[kind] ?? "the check failed";
5167
- const within = state.pending.filter((l) => withinLimits(l, ctx.config));
5220
+ const within = state.pending.filter((l) => wanted(l, ctx.config));
5168
5221
  const kept = { ...state, pending: within };
5169
5222
  return {
5170
5223
  state: settled(kept, within.length),
@@ -5181,7 +5234,7 @@ function judgeFailed(ctx, state, kind, detail) {
5181
5234
  };
5182
5235
  }
5183
5236
  function judged(ctx, stored, verdicts) {
5184
- const all = stored.pending.map((l, i) => ({ l, v: verdicts.get(i + 1) ?? { fit: "maybe", why: "", seller: "" } })).filter(({ l }) => withinLimits(l, ctx.config));
5237
+ const all = stored.pending.map((l, i) => ({ l, v: verdicts.get(i + 1) ?? { fit: "maybe", why: "", seller: "" } })).filter(({ l }) => wanted(l, ctx.config));
5185
5238
  const state = { ...stored, pending: all.map((x) => x.l) };
5186
5239
  const rank = { match: 0, maybe: 1, no: 2 };
5187
5240
  const shown = all.filter((x) => x.v.fit !== "no").sort((a, b) => rank[a.v.fit] - rank[b.v.fit]);
@@ -5242,6 +5295,12 @@ function wantjudgeType(sources, o = {}) {
5242
5295
  description: "Only listings in this currency",
5243
5296
  required: false,
5244
5297
  kind: "string"
5298
+ },
5299
+ {
5300
+ name: "exclude",
5301
+ description: "Leave out listings whose title has any of these words or phrases, separated by commas",
5302
+ required: false,
5303
+ kind: "string"
5245
5304
  }
5246
5305
  ]
5247
5306
  },
@@ -6423,6 +6482,10 @@ class SqliteStore {
6423
6482
  const next = this.one("SELECT * FROM occurrences WHERE task_id = ? AND status = 'queued' AND due_at > ? ORDER BY due_at, created_at, seq LIMIT 1", taskId, now);
6424
6483
  return { rows: rows.reverse().map(toOccurrence), total, next: next ? toOccurrence(next) : null };
6425
6484
  }
6485
+ async runsNowSince(ownerId, since) {
6486
+ const row = this.one("SELECT COUNT(*) AS n FROM occurrences o JOIN tasks t ON o.task_id = 't' || t.seq WHERE t.owner_id = ? AND o.dedupe_key LIKE 'now:%' AND o.created_at >= ?", ownerId, since);
6487
+ return Number(row?.n ?? 0);
6488
+ }
6426
6489
  async repliesTo(taskId, occurrenceIds, userId) {
6427
6490
  if (occurrenceIds.length === 0)
6428
6491
  return [];
@@ -6762,6 +6825,7 @@ var MAX_REMINDER_TEXT = 1500;
6762
6825
  var MAX_WHEN = 100;
6763
6826
  var MAX_NOTE = 300;
6764
6827
  var MAX_URL = 1000;
6828
+ var MAX_EXCLUDE_TEXT = 1000;
6765
6829
  var MAX_NEAR = 100;
6766
6830
  var MAX_AMOUNT = 1e6;
6767
6831
  var CURRENCY_LENGTH = 3;
@@ -6908,7 +6972,7 @@ function retimed(s, day, owner) {
6908
6972
  return null;
6909
6973
  }
6910
6974
  async function heldRuns(store, task) {
6911
- return (await store.listOccurrences({ taskId: task.id, status: "queued" })).filter((o) => !o.dedupeKey.startsWith(SNOOZE_PREFIX) && !hasFired(o));
6975
+ return (await store.listOccurrences({ taskId: task.id, status: "queued" })).filter((o) => !isOffSchedule(o.dedupeKey) && !hasFired(o));
6912
6976
  }
6913
6977
  async function restoreHeldRun(store, task, held, before, after, now) {
6914
6978
  const s = task.schedule;
@@ -7562,6 +7626,10 @@ async function pauseLocked(d, user, taskId) {
7562
7626
  const at = d.clock.now().toISOString();
7563
7627
  await d.store.updateTask(task.id, { status: "paused", at });
7564
7628
  await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "paused", detail: "paused by the owner", at });
7629
+ for (const o of await d.store.listOccurrences({ taskId: task.id, status: "queued" })) {
7630
+ if (o.dedupeKey.startsWith(RUN_NOW_PREFIX))
7631
+ await deleteIdleRun(d.store, o);
7632
+ }
7565
7633
  return { ok: true, text: `Paused \`${task.id}\`: nothing is sent until you resume it.` };
7566
7634
  }
7567
7635
  function resumeTask(d, user, taskId) {
@@ -8230,6 +8298,33 @@ function limitsPlan(input, defaultHours, minHours = 1) {
8230
8298
  limits: { name, hours, ...input.max !== undefined ? { maxPrice: input.max } : {}, ...currency ? { currency } : {} }
8231
8299
  };
8232
8300
  }
8301
+ function excludePlan(text, words) {
8302
+ const raw = (text ?? "").trim();
8303
+ if (raw === "" || raw === "-")
8304
+ return { ok: true, exclude: [] };
8305
+ if (raw.length > MAX_EXCLUDE_TEXT)
8306
+ return { ok: false, error: `\`exclude\` is longer than ${MAX_EXCLUDE_TEXT} characters.` };
8307
+ const entries = raw.split(/[,\n]/).map((e) => e.trim()).filter((e) => e !== "");
8308
+ const long = entries.find((e) => e.length > EXCLUDE_CHARS);
8309
+ if (long !== undefined)
8310
+ return { ok: false, error: `Each word or phrase in \`exclude\` is at most ${EXCLUDE_CHARS} characters.` };
8311
+ const distinct = new Set(entries.map((e) => searchWords(e).join(" ")).filter((k) => k !== ""));
8312
+ if (distinct.size > MAX_EXCLUDE)
8313
+ return { ok: false, error: `\`exclude\` takes at most ${MAX_EXCLUDE} words or phrases, separated by commas.` };
8314
+ if (distinct.size === 0)
8315
+ return { ok: false, error: "`exclude` needs words or phrases, separated by commas, such as `investigator`." };
8316
+ const exclude = parseExclude(raw);
8317
+ if (words !== null) {
8318
+ const searched = new Set(searchWords(words));
8319
+ const every = exclude.find((e) => {
8320
+ const w = searchWords(e);
8321
+ return w.length === 1 && searched.has(w[0]);
8322
+ });
8323
+ if (every !== undefined)
8324
+ return { ok: false, error: `\`exclude\` has "${every}", a word this watch searches for, so every listing it finds has it and none would be left.` };
8325
+ }
8326
+ return { ok: true, exclude };
8327
+ }
8233
8328
  function ebaySearchUrl(words, max) {
8234
8329
  const q = new URLSearchParams({ _nkw: words });
8235
8330
  if (max !== undefined)
@@ -8362,11 +8457,15 @@ async function startWant(d, user, input) {
8362
8457
  const capped = await liveTaskCap(d, user);
8363
8458
  if (capped)
8364
8459
  return { ok: false, error: capped };
8460
+ const excluded = excludePlan(input.exclude, words);
8461
+ if (!excluded.ok)
8462
+ return excluded;
8365
8463
  const where = words !== null ? { source: source === "ebay" ? "ebay" : "shopify", target: words } : site && inbox ? { source: "inbox", target: newInboxKey(site), search: inbox.search, ...inbox.game !== null ? { game: String(inbox.game) } : {} } : { source, target: source === "bgg" ? String(parseBggThingId(target)) : target };
8366
8464
  const config = {
8367
8465
  ...where,
8368
8466
  ...limits.maxPrice !== undefined ? { maxPrice: limits.maxPrice } : {},
8369
- ...limits.currency ? { currency: limits.currency } : {}
8467
+ ...limits.currency ? { currency: limits.currency } : {},
8468
+ ...excluded.exclude.length > 0 ? { exclude: excluded.exclude } : {}
8370
8469
  };
8371
8470
  return { ok: true, start: { title: limits.name, hours: limits.hours, config, judge: judged.judge } };
8372
8471
  }
@@ -8375,8 +8474,9 @@ async function previewWant(d, start) {
8375
8474
  return { ok: true, text: "" };
8376
8475
  try {
8377
8476
  const listings = await pageSource.search(start.config.target, d.fetch ?? undefined);
8378
- const within = listings.filter((l) => withinLimits(l, start.config)).length;
8379
- const limited = start.config.maxPrice !== undefined || start.config.currency ? `, ${within} within your limits` : "";
8477
+ const within = listings.filter((l) => wanted(l, start.config)).length;
8478
+ const c = start.config;
8479
+ const limited = c.maxPrice !== undefined || c.currency || (c.exclude?.length ?? 0) > 0 ? `, ${within} within your limits` : "";
8380
8480
  return { ok: true, text: `The page lists ${plural4(listings.length, "thing")} now${limited}.` };
8381
8481
  } catch (err) {
8382
8482
  const cause = err instanceof Error ? err.cause : undefined;
@@ -8427,7 +8527,8 @@ async function finishWant(d, asked, start, seen) {
8427
8527
  start.judge ? `I look at ${where} every ${plural4(start.hours, "hour")}, and the model looks at each listing${limits.length ? ` ${limits.join(" ")}` : ""} I have not shown you before -- whether it is the thing, and ${shows} -- then I DM you the ones worth a look, with its notes; the first look starts within a minute. Those looks are model runs, charged like research; \`judge: false\` makes a watch without them. Press Done on a DM once you have it, or \`/task done ${task.id}\`.` : `I look at ${where} every ${plural4(start.hours, "hour")} and DM you each listing${limits.length ? ` ${limits.join(" ")}` : ""} I have not shown you before; ${first}. Press Done on a DM once you have it, or \`/task done ${task.id}\`.`,
8428
8528
  ...inboxNote(c, task.id, d.mail && watchSite(c) === "ebay" ? d.mail.addressFor(user.id, now) : null),
8429
8529
  ...c.source === "shopify" ? ["Shopify's search finds new copies at Shopify stores only (not eBay or used copies), and I keep a product only when its title has every word you are watching for. Each look is a Firecrawl search (5 credits)."] : [],
8430
- ...c.source === "ebay" ? ["I search eBay US through eBay's own API, newest listings first, new and used, an auction at its current bid (or its Buy It Now price), and count a listing only when its title has every word you are watching for. I never open an eBay page, and the model does not check these listings. eBay's terms let me keep its listings only briefly, so they are in the DM alone, and my own copy of it is cleared after 6 hours."] : []
8530
+ ...c.source === "ebay" ? ["I search eBay US through eBay's own API, newest listings first, new and used, an auction at its current bid (or its Buy It Now price), and count a listing only when its title has every word you are watching for. I never open an eBay page, and the model does not check these listings. eBay's terms let me keep its listings only briefly, so they are in the DM alone, and my own copy of it is cleared after 6 hours."] : [],
8531
+ ...c.exclude && c.exclude.length > 0 ? [`I leave out any listing whose title has ${c.exclude.map((e) => `"${e}"`).join(", ")}.`] : []
8431
8532
  ].join(`
8432
8533
  `))
8433
8534
  };
@@ -8463,13 +8564,18 @@ async function editWantLocked(d, user, taskId, input) {
8463
8564
  const { limits } = planned;
8464
8565
  if (config.source === "ebay" && limits.currency && limits.currency !== "USD")
8465
8566
  return { ok: false, error: EBAY_USD };
8567
+ const searchedBy = config.source === "shopify" || config.source === "ebay" ? config.target : null;
8568
+ const excluded = input.exclude === undefined ? { ok: true, exclude: config.exclude ?? [] } : excludePlan(input.exclude, searchedBy);
8569
+ if (!excluded.ok)
8570
+ return excluded;
8466
8571
  const nextConfig = {
8467
8572
  source: config.source,
8468
8573
  target: config.target,
8469
8574
  ...config.search !== undefined ? { search: config.search } : {},
8470
8575
  ...config.game !== undefined ? { game: config.game } : {},
8471
8576
  ...limits.maxPrice !== undefined ? { maxPrice: limits.maxPrice } : {},
8472
- ...limits.currency ? { currency: limits.currency } : {}
8577
+ ...limits.currency ? { currency: limits.currency } : {},
8578
+ ...excluded.exclude.length > 0 ? { exclude: excluded.exclude } : {}
8473
8579
  };
8474
8580
  const patch = {};
8475
8581
  if (limits.name !== task.title)
@@ -9465,6 +9571,19 @@ var MIGRATIONS = [
9465
9571
  ended_at TEXT
9466
9572
  );
9467
9573
  CREATE UNIQUE INDEX intake_conversations_open ON intake_conversations (user_id) WHERE status = 'open';
9574
+ `,
9575
+ `
9576
+ CREATE TABLE api_idempotency (
9577
+ user_id TEXT NOT NULL,
9578
+ key TEXT NOT NULL,
9579
+ request_hash TEXT NOT NULL,
9580
+ claim TEXT NOT NULL,
9581
+ status INTEGER,
9582
+ answer TEXT,
9583
+ created_at TEXT NOT NULL,
9584
+ PRIMARY KEY (user_id, key)
9585
+ );
9586
+ CREATE INDEX api_idempotency_created ON api_idempotency (created_at);
9468
9587
  `
9469
9588
  ];
9470
9589
  var BUSY_TIMEOUT_MS = 5000;
@@ -10102,6 +10221,7 @@ function scoutSchedule(every, zone, now) {
10102
10221
  const start = `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
10103
10222
  return { kind: "calendar", every, unit: "day", start };
10104
10223
  }
10224
+ var SCOUT_CAP = `You already have ${MAX_LIVE_SCOUTS} scouts, the most one person may. Delete or change one instead.`;
10105
10225
  async function liveScouts(d, user) {
10106
10226
  const active = await d.store.listTasks({ ownerId: user.id, status: "active", type: "scout" });
10107
10227
  const paused = await d.store.listTasks({ ownerId: user.id, status: "paused", type: "scout" });
@@ -10118,7 +10238,7 @@ async function createScout(d, user, input) {
10118
10238
  if (capped)
10119
10239
  return { ok: false, error: capped };
10120
10240
  if (await liveScouts(d, user) >= MAX_LIVE_SCOUTS) {
10121
- return { ok: false, error: `You already have ${MAX_LIVE_SCOUTS} scouts, the most one person may. Delete or change one instead.` };
10241
+ return { ok: false, error: SCOUT_CAP };
10122
10242
  }
10123
10243
  const now = d.clock.now();
10124
10244
  const actor = { userId: user.id, admin: user.admin };
@@ -10734,6 +10854,7 @@ class Roster {
10734
10854
  del("web_sessions", "user_id = ?", userId);
10735
10855
  del("web_login_tokens", "user_id = ?", userId);
10736
10856
  del("api_tokens", "user_id = ?", userId);
10857
+ del("api_idempotency", "user_id = ?", userId);
10737
10858
  del("notices", "key LIKE 'budget:person:' || ? || ':%'", userId);
10738
10859
  del("notices", "key LIKE 'digest:' || ? || ':%'", userId);
10739
10860
  del("ceiling_changes", "user_id = ?", userId);
@@ -10760,6 +10881,24 @@ function replyText(r) {
10760
10881
  return `chose ${String(r.payload?.choice ?? "?")}`;
10761
10882
  return r.kind === "opt_out" ? "opted out" : r.kind;
10762
10883
  }
10884
+ function owed(r) {
10885
+ return r.retryAt !== null || r.status === "claimed" || r.status === "pending";
10886
+ }
10887
+ function deliveryView(o, rows, people, viewer, seesEveryone) {
10888
+ const shown = seesEveryone ? rows : rows.filter((r) => r.userId === viewer.id);
10889
+ const sends = shown.map((r) => ({ to: people.get(r.userId)?.displayName ?? r.userId, status: r.status, error: r.error ? clip2(r.error, 120) : null }));
10890
+ if (shown.length === 0) {
10891
+ const finished = o.status !== "queued" && o.status !== "running";
10892
+ return { state: finished ? "nothing_to_send" : "not_run", reason: null, sends };
10893
+ }
10894
+ const lost = shown.find((r) => r.status === "failed" && !owed(r));
10895
+ if (lost)
10896
+ return { state: "failed", reason: lost.error ? clip2(lost.error, 120) : null, sends };
10897
+ const unsure = shown.find((r) => r.status === "unconfirmed");
10898
+ if (unsure)
10899
+ return { state: "unconfirmed", reason: unsure.error ? clip2(unsure.error, 120) : null, sends };
10900
+ return { state: shown.some(owed) ? "sending" : "delivered", reason: null, sends };
10901
+ }
10763
10902
  var OTHER_RECIPIENT_ERROR = "a recipient cannot be messaged";
10764
10903
  function runView(o, replies, people, viewer, now, seesEveryone) {
10765
10904
  const error = o.status === "failed" && o.error ? seesEveryone || !errorNamesOther(o.error, viewer.id) ? clip2(o.error, 120) : OTHER_RECIPIENT_ERROR : null;
@@ -10834,8 +10973,11 @@ async function loadHistory(d, user, taskId) {
10834
10973
  const tail = await d.store.changesTail(task.id, seesEveryone ? null : user.id, HISTORY_CHANGES);
10835
10974
  const lead = tail.before?.kind === "paused" ? [tail.before] : [];
10836
10975
  const changes = seesEveryone ? tail.rows : changesForRecipient([...lead, ...tail.rows], user.id).slice(lead.length);
10976
+ const deliveries = new Map;
10977
+ for (const o of runs.rows)
10978
+ deliveries.set(o.id, await d.store.listDeliveries({ occurrenceId: o.id }));
10837
10979
  const people = new Map;
10838
- for (const r of replies) {
10980
+ for (const r of [...replies, ...[...deliveries.values()].flat()]) {
10839
10981
  if (!people.has(r.userId)) {
10840
10982
  const u = await d.store.getUser(r.userId);
10841
10983
  if (u)
@@ -10858,7 +11000,7 @@ async function loadHistory(d, user, taskId) {
10858
11000
  task,
10859
11001
  cadence: task.schedule && owner ? describeSchedule(task.schedule, owner, user.timeZone, now) : "no schedule",
10860
11002
  next: next ? formatInstant(next.dueAt, user.timeZone, now) : null,
10861
- runs: runs.rows.map((o) => runView(o, replies, people, user, now, seesEveryone)),
11003
+ runs: runs.rows.map((o) => ({ ...runView(o, replies, people, user, now, seesEveryone), delivery: deliveryView(o, deliveries.get(o.id) ?? [], people, user, seesEveryone) })),
10862
11004
  earlierRuns: Math.max(0, runs.total - HISTORY_RUNS),
10863
11005
  changes: changes.map((e) => changeView(e, user, now, names)),
10864
11006
  earlierChanges: Math.max(0, tail.total - HISTORY_CHANGES),
@@ -11230,12 +11372,13 @@ function createSurface(w) {
11230
11372
  },
11231
11373
  {
11232
11374
  name: "want",
11233
- build: (b) => b.setDescription("Watch for something you want: I DM you new listings until you have it").addStringOption((o) => o.setName("name").setDescription("What you want, e.g. Wingspan Oceania expansion").setRequired(true).setMaxLength(MAX_TITLE)).addStringOption((o) => o.setName("source").setDescription("Where to look").setRequired(true).addChoices({ name: "a listing page you paste", value: "page" }, { name: "BoardGameGeek's marketplace", value: "bgg" }, { name: "eBay (through eBay's own API when set up, else from listings you send in)", value: "ebay" }, { name: "Shopify stores (new copies in stock, at most once a day)", value: "shopify" })).addStringOption((o) => o.setName("target").setDescription("The page's address, or the BGG game's address or id; for eBay or Shopify, the words to search").setMaxLength(MAX_URL)).addNumberOption((o) => o.setName("max").setDescription("Only listings at or under this price").setMinValue(0.01)).addStringOption((o) => o.setName("currency").setDescription("Only listings in this currency, e.g. USD").setMinLength(CURRENCY_LENGTH).setMaxLength(CURRENCY_LENGTH)).addIntegerOption((o) => o.setName("hours").setDescription(`Hours between looks (default: page ${DEFAULT_PAGE_HOURS}, BGG API ${DEFAULT_BGG_HOURS}, eBay ${DEFAULT_EBAY_HOURS}, BGG without it ${DEFAULT_INBOX_HOURS}, Shopify ${DEFAULT_SHOPIFY_HOURS})`).setMinValue(1).setMaxValue(MAX_WANT_HOURS)).addBooleanOption((o) => o.setName("judge").setDescription("Have the model check each new listing and its seller first (default: yes, when it can)")),
11375
+ build: (b) => b.setDescription("Watch for something you want: I DM you new listings until you have it").addStringOption((o) => o.setName("name").setDescription("What you want, e.g. Wingspan Oceania expansion").setRequired(true).setMaxLength(MAX_TITLE)).addStringOption((o) => o.setName("source").setDescription("Where to look").setRequired(true).addChoices({ name: "a listing page you paste", value: "page" }, { name: "BoardGameGeek's marketplace", value: "bgg" }, { name: "eBay (through eBay's own API when set up, else from listings you send in)", value: "ebay" }, { name: "Shopify stores (new copies in stock, at most once a day)", value: "shopify" })).addStringOption((o) => o.setName("target").setDescription("The page's address, or the BGG game's address or id; for eBay or Shopify, the words to search").setMaxLength(MAX_URL)).addNumberOption((o) => o.setName("max").setDescription("Only listings at or under this price").setMinValue(0.01)).addStringOption((o) => o.setName("currency").setDescription("Only listings in this currency, e.g. USD").setMinLength(CURRENCY_LENGTH).setMaxLength(CURRENCY_LENGTH)).addStringOption((o) => o.setName("exclude").setDescription("Leave out listings whose title has any of these words, separated by commas").setMaxLength(MAX_EXCLUDE_TEXT)).addIntegerOption((o) => o.setName("hours").setDescription(`Hours between looks (default: page ${DEFAULT_PAGE_HOURS}, BGG API ${DEFAULT_BGG_HOURS}, eBay ${DEFAULT_EBAY_HOURS}, BGG without it ${DEFAULT_INBOX_HOURS}, Shopify ${DEFAULT_SHOPIFY_HOURS})`).setMinValue(1).setMaxValue(MAX_WANT_HOURS)).addBooleanOption((o) => o.setName("judge").setDescription("Have the model check each new listing and its seller first (default: yes, when it can)")),
11234
11376
  handle: (interaction) => run(interaction, "registered", (d, user) => {
11235
11377
  const target = interaction.options.getString("target");
11236
11378
  const judge = interaction.options.getBoolean("judge");
11237
11379
  const max = interaction.options.getNumber("max");
11238
11380
  const currency = interaction.options.getString("currency");
11381
+ const exclude = interaction.options.getString("exclude");
11239
11382
  const hours = interaction.options.getInteger("hours");
11240
11383
  return wantCommand(d, user, {
11241
11384
  name: interaction.options.getString("name", true),
@@ -11243,6 +11386,7 @@ function createSurface(w) {
11243
11386
  ...target !== null ? { target } : {},
11244
11387
  ...max !== null ? { max } : {},
11245
11388
  ...currency !== null ? { currency } : {},
11389
+ ...exclude !== null ? { exclude } : {},
11246
11390
  ...hours !== null ? { hours } : {},
11247
11391
  ...judge !== null ? { judge } : {}
11248
11392
  });
@@ -11395,6 +11539,52 @@ async function loadUsage(store, now, days = USAGE_DAYS) {
11395
11539
  return usageReport(rows, now, days);
11396
11540
  }
11397
11541
 
11542
+ // plugins/tracker/src/run-now.ts
11543
+ var RUNNABLE = new Set(["reminder", "price", "wantlist", "wantjudge", "scout"]);
11544
+ var RUN_NOW_PER_DAY = 10;
11545
+ var DAY_MS4 = 24 * 3600000;
11546
+ var NOT_NOW = "That task cannot run now:";
11547
+ function runNow(d, user, taskId) {
11548
+ return d.locks.turn(taskId.trim(), () => runNowLocked(d, user, taskId), () => ({ ok: false, error: TASK_BUSY }));
11549
+ }
11550
+ async function runNowLocked(d, user, taskId) {
11551
+ const task = await ownLiveTask(d, user, taskId);
11552
+ if (!task)
11553
+ return { ok: false, error: NO_SUCH_TASK };
11554
+ if (task.status !== "active")
11555
+ return { ok: false, error: `That task is ${task.status}, not active.` };
11556
+ if (task.type === "renewal")
11557
+ return { ok: false, error: `${NOT_NOW} a renewal asks about its own date, at its own time.` };
11558
+ if (task.type === "research")
11559
+ return { ok: false, error: `${NOT_NOW} a research request runs once.` };
11560
+ if (!RUNNABLE.has(task.type))
11561
+ return { ok: false, error: `${NOT_NOW} a ${task.type} runs only on its own schedule.` };
11562
+ if (!task.schedule || task.schedule.kind === "once") {
11563
+ return { ok: false, error: `${NOT_NOW} a one-off runs once, at its own time; change its time instead.` };
11564
+ }
11565
+ const now = d.clock.now();
11566
+ const queued = await d.store.listOccurrences({ taskId: task.id, status: "queued" });
11567
+ if (queued.some((o) => !hasFired(o) && Date.parse(o.dueAt) <= now.getTime())) {
11568
+ return { ok: false, error: `${NOT_NOW} it already has a run due, which is on its way.` };
11569
+ }
11570
+ if (queued.some((o) => !hasFired(o) && o.dedupeKey.startsWith(FOLLOW_UP_PREFIX))) {
11571
+ return { ok: false, error: `${NOT_NOW} it is waiting to try its last look again.` };
11572
+ }
11573
+ const since = new Date(now.getTime() - DAY_MS4).toISOString();
11574
+ if (await d.store.runsNowSince(user.id, since) >= RUN_NOW_PER_DAY) {
11575
+ return { ok: false, error: `You already ran ${RUN_NOW_PER_DAY} tasks now in the last 24 hours; each runs on its own schedule meanwhile.` };
11576
+ }
11577
+ const at = now.toISOString();
11578
+ const made = await d.store.createOccurrence({ taskId: task.id, lane: task.lane, dueAt: at, dedupeKey: runNowKey(task.id, crypto.randomUUID()), at });
11579
+ if (!made)
11580
+ return { ok: false, error: TASK_BUSY };
11581
+ d.log.info(`run now: ${task.id} for ${user.id} (${made.id})`);
11582
+ return { ok: true, text: `Running \`${task.id}\` now: ${when2(task)}, and its schedule stays as it was.` };
11583
+ }
11584
+ function when2(task) {
11585
+ return task.lane === "execute" ? "it goes to the model runner on its next turn (a daily ceiling met holds it until midnight Eastern)" : "it goes within a minute";
11586
+ }
11587
+
11398
11588
  // node_modules/@rackbops/styles/rackbops-noir/bundle.css
11399
11589
  var bundle_default = `:where([data-rb-style], [data-rb-style] *), :where([data-rb-style], [data-rb-style] *):before, :where([data-rb-style], [data-rb-style] *):after {
11400
11590
  box-sizing: border-box;
@@ -12739,7 +12929,7 @@ function loginPage(base, input) {
12739
12929
  </section>`
12740
12930
  });
12741
12931
  }
12742
- function when2(entry, viewer, now) {
12932
+ function when3(entry, viewer, now) {
12743
12933
  return entry.next ? formatInstant(entry.next.dueAt, viewer.timeZone, now) : "nothing due";
12744
12934
  }
12745
12935
  function cadence(entry, viewer, now) {
@@ -12762,7 +12952,7 @@ function tasksPage(v, data, now, flash = null, research = false) {
12762
12952
  const rows = data.entries.map((e) => html`<tr>
12763
12953
  <td><a class="rb-link" href="${v.base}/tasks/${e.task.id}">${e.task.title}</a></td>
12764
12954
  <td>${e.from ? name3(e.from) : "you"}</td>
12765
- <td>${when2(e, v.user, now)}</td>
12955
+ <td>${when3(e, v.user, now)}</td>
12766
12956
  <td>${cadence(e, v.user, now)}</td>
12767
12957
  </tr>`);
12768
12958
  const active = data.entries.length === 0 ? html`<p class="rb-muted">You have no active tasks.</p>` : html`<div class="rb-table-scroll"><table class="rb-table">
@@ -12921,6 +13111,7 @@ var JUDGE_OPTIONS = [["yes", "yes: the model checks each new listing and its sel
12921
13111
  var wantLimits = (mode, surface) => [
12922
13112
  { name: "max", label: "Top price", kind: "text", decimal: true, help: "Only listings at or under it, and so only listings that show a price. Leave it empty for any price." },
12923
13113
  { name: "currency", label: "Currency", kind: "text", maxlength: CURRENCY_LENGTH, help: "Only listings in it, such as USD. Leave it empty for any." },
13114
+ { name: "exclude", label: "Leave out titles with", kind: "text", maxlength: MAX_EXCLUDE_TEXT, help: "Words or phrases, separated by commas: a listing whose title has any of them is left out, such as investigator for a campaign box. At most 20. Leave it empty to leave nothing out." },
12924
13115
  { name: "hours", label: "Hours between looks", kind: "number", min: 1, max: MAX_WANT_HOURS, help: `1 to ${MAX_WANT_HOURS}, and at least ${MIN_SHOPIFY_HOURS} for Shopify, since each Shopify look is a paid Firecrawl search. ${empty(mode, `When empty, ${DEFAULT_PAGE_HOURS} for a page, ${DEFAULT_BGG_HOURS} for BGG, ${DEFAULT_EBAY_HOURS} for eBay, ${DEFAULT_INBOX_HOURS} for BGG when this bot has no BGG access, whose listings are sent in, and ${DEFAULT_SHOPIFY_HOURS} for Shopify.`, surface)}` }
12925
13116
  ];
12926
13117
  function fieldsFor(type, mode, surface = "web") {
@@ -13168,6 +13359,7 @@ function scoutEdit(form) {
13168
13359
  }
13169
13360
  function wantInput(form) {
13170
13361
  const target = opt(form, "target");
13362
+ const exclude = opt(form, "exclude");
13171
13363
  const max = decimal(form, "max");
13172
13364
  const currency = opt(form, "currency");
13173
13365
  const hours = int(form, "hours");
@@ -13179,6 +13371,7 @@ function wantInput(form) {
13179
13371
  ...target !== undefined ? { target } : {},
13180
13372
  ...max !== undefined ? { max } : {},
13181
13373
  ...currency !== undefined ? { currency } : {},
13374
+ ...exclude !== undefined ? { exclude } : {},
13182
13375
  ...hours !== undefined ? { hours } : {}
13183
13376
  };
13184
13377
  }
@@ -13189,6 +13382,7 @@ function wantEdit(form) {
13189
13382
  ...name !== undefined ? { name } : {},
13190
13383
  ...form.has("max") ? { max: decimal(form, "max") ?? null } : {},
13191
13384
  ...form.has("currency") ? { currency: str3(form, "currency") } : {},
13385
+ ...form.has("exclude") ? { exclude: str3(form, "exclude") } : {},
13192
13386
  ...hours !== undefined ? { hours } : {}
13193
13387
  };
13194
13388
  }
@@ -13282,7 +13476,8 @@ function editValues(d, user, task) {
13282
13476
  if (task.type === "wantlist" || task.type === "wantjudge") {
13283
13477
  const c = task.config;
13284
13478
  const every = task.schedule?.kind === "poll" ? task.schedule.every : "";
13285
- return { name: task.title, max: c.maxPrice !== undefined ? String(c.maxPrice) : "", currency: c.currency ?? "", hours: String(every) };
13479
+ const exclude = Array.isArray(c.exclude) ? c.exclude.filter((e) => typeof e === "string").join(", ") : "";
13480
+ return { name: task.title, max: c.maxPrice !== undefined ? String(c.maxPrice) : "", currency: c.currency ?? "", exclude, hours: String(every) };
13286
13481
  }
13287
13482
  if (task.type !== "price")
13288
13483
  return {};
@@ -13291,6 +13486,31 @@ function editValues(d, user, task) {
13291
13486
  return { name: task.title, hours: String(every), drop: String(c.dropPercent ?? 10), baseline: c.baseline ?? "last" };
13292
13487
  }
13293
13488
 
13489
+ // plugins/tracker/src/web/task-version.ts
13490
+ function taskVersion(task) {
13491
+ const owned = { title: task.title, config: task.config, schedule: task.schedule, status: task.status, capabilities: task.capabilities };
13492
+ return sha256(canonicalJson(owned)).slice(0, 16);
13493
+ }
13494
+ function canonicalJson(value) {
13495
+ if (Array.isArray(value))
13496
+ return `[${value.map(canonicalJson).join(",")}]`;
13497
+ if (value !== null && typeof value === "object") {
13498
+ const o = value;
13499
+ return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`).join(",")}}`;
13500
+ }
13501
+ return JSON.stringify(value);
13502
+ }
13503
+ function etag(version) {
13504
+ return `"${version}"`;
13505
+ }
13506
+ function ifMatchVersion(header) {
13507
+ if (header === null)
13508
+ return null;
13509
+ const m = /^\s*"([0-9a-f]{16})"\s*$/.exec(header);
13510
+ return m?.[1] ?? null;
13511
+ }
13512
+ var VERSION_MISMATCH = "That task has changed since you read it. Read it again (GET the task) and send its new version in If-Match.";
13513
+
13294
13514
  // plugins/tracker/src/web/editor.ts
13295
13515
  var READING = "I am still reading a page for your last new price tracker or want-list watch; try again when it is done.";
13296
13516
  var GONE = NO_LONGER_LISTED;
@@ -13298,7 +13518,14 @@ var writer = (e) => ({ d: e.d, queue: e.queue, reading: e.reading, userId: e.v.u
13298
13518
  function asUser(w, fn) {
13299
13519
  return w.queue(async () => {
13300
13520
  const user = await w.d.store.getUser(w.userId);
13301
- return user ? fn(user) : { ok: false, error: GONE };
13521
+ if (!user)
13522
+ return { ok: false, error: GONE };
13523
+ if (w.ifMatch) {
13524
+ const task = await w.d.store.getTask(w.ifMatch.taskId);
13525
+ if (!task || taskVersion(task) !== w.ifMatch.version)
13526
+ return { ok: false, error: VERSION_MISMATCH };
13527
+ }
13528
+ return fn(user);
13302
13529
  });
13303
13530
  }
13304
13531
  var WEB_READ_MS = 8000;
@@ -13391,6 +13618,9 @@ function saveEdit(w, task, form) {
13391
13618
  return asUser(w, (u) => editWant(w.d, u, task.id, wantEdit(form)));
13392
13619
  return asUser(w, (u) => editPrice(w.d, u, task.id, priceEdit(form)));
13393
13620
  }
13621
+ function runOn(w, taskId) {
13622
+ return asUser(w, (u) => runNow(w.d, u, taskId));
13623
+ }
13394
13624
  function actOn(w, taskId, action) {
13395
13625
  const act = action === "pause" ? pauseTask : action === "resume" ? resumeTask : deleteTask;
13396
13626
  return asUser(w, (u) => act(w.d, u, taskId));
@@ -13555,12 +13785,16 @@ function refusal(error) {
13555
13785
  return problem(503, "slow_page", error, { "Retry-After": "10" });
13556
13786
  if (error === NOT_EDITABLE)
13557
13787
  return problem(409, "conflict", error);
13788
+ if (error === VERSION_MISMATCH)
13789
+ return problem(412, "version_mismatch", error);
13558
13790
  if (error === RESEARCH_OFF || error === SCOUT_OFF)
13559
13791
  return problem(503, "unavailable", error);
13560
13792
  if (error === FINISHED || /^That task is [a-z]+, not [a-z]+\.$/.test(error))
13561
13793
  return problem(409, "conflict", error);
13562
- if (/^You already (have|track|watch for) [0-9]+ /.test(error))
13794
+ if (/^You already (have|track|watch for|ran) [0-9]+ /.test(error))
13563
13795
  return problem(409, "limit_reached", error);
13796
+ if (error.startsWith(NOT_NOW))
13797
+ return problem(409, "cannot_run_now", error);
13564
13798
  if (error === INBOX_OFF || error === JUDGE_OFF)
13565
13799
  return problem(503, "unavailable", error);
13566
13800
  if (/ (is|are) not available on this bot\.$/.test(error))
@@ -13603,6 +13837,7 @@ async function taskJson(d, user, task) {
13603
13837
  type: task.type,
13604
13838
  title: task.title,
13605
13839
  status: task.status === "archived" ? "deleted" : task.status,
13840
+ version: taskVersion(task),
13606
13841
  cadence: task.schedule ? describeSchedule(task.schedule, user, user.timeZone, d.clock.now()) : "no schedule",
13607
13842
  nextAt: await nextAt(d, task),
13608
13843
  createdAt: task.createdAt,
@@ -13621,12 +13856,74 @@ function watchJson(c) {
13621
13856
  ...site ? { site, ...c.search !== undefined ? { search: c.search } : {}, ...c.game !== undefined ? { game: c.game } : {} } : {}
13622
13857
  };
13623
13858
  }
13624
- async function listAnswer(d, user) {
13625
- const tasks = (await d.store.listTasks({ ownerId: user.id })).filter((t) => t.status !== "archived");
13859
+ var MAX_PAGE = 200;
13860
+ var cursorOf = (taskId) => Buffer.from(taskId).toString("base64url");
13861
+ var CURSOR = /^[A-Za-z0-9_-]{1,64}$/;
13862
+ async function listAnswer(d, user, query = new URLSearchParams) {
13863
+ const rawLimit = query.get("limit");
13864
+ const rawCursor = query.get("cursor");
13865
+ for (const key of ["limit", "cursor"]) {
13866
+ if (query.getAll(key).length > 1)
13867
+ return problem(400, "invalid", `\`${key}\` is given more than once.`);
13868
+ }
13869
+ let limit = null;
13870
+ if (rawLimit !== null) {
13871
+ limit = /^[0-9]{1,4}$/.test(rawLimit) ? Number(rawLimit) : 0;
13872
+ if (limit < 1 || limit > MAX_PAGE)
13873
+ return problem(400, "invalid", `\`limit\` is a whole number from 1 to ${MAX_PAGE}.`);
13874
+ }
13875
+ if (rawCursor !== null && limit === null)
13876
+ return problem(400, "invalid", "`cursor` goes with `limit`.");
13877
+ const all = await d.store.listTasks({ ownerId: user.id });
13878
+ let start = 0;
13879
+ if (rawCursor !== null) {
13880
+ const at = CURSOR.test(rawCursor) ? all.findIndex((t) => cursorOf(t.id) === rawCursor) : -1;
13881
+ if (at < 0)
13882
+ return problem(400, "invalid_cursor", "That cursor is not one this API gave you: start again without it.");
13883
+ start = at + 1;
13884
+ }
13626
13885
  const out = [];
13627
- for (const t of tasks)
13886
+ let last = null;
13887
+ let more = false;
13888
+ for (let i = start;i < all.length; i++) {
13889
+ const t = all[i];
13890
+ if (t.status === "archived")
13891
+ continue;
13892
+ if (limit !== null && out.length === limit) {
13893
+ more = true;
13894
+ break;
13895
+ }
13628
13896
  out.push(await taskJson(d, user, t));
13629
- return { status: 200, body: { tasks: out } };
13897
+ last = t;
13898
+ }
13899
+ return { status: 200, body: { tasks: out, ...limit !== null ? { nextCursor: more && last ? cursorOf(last.id) : null } : {} } };
13900
+ }
13901
+ function capabilitiesAnswer(d, user, rate) {
13902
+ return {
13903
+ status: 200,
13904
+ body: {
13905
+ types: intakeTypes(d),
13906
+ owner: { timeZone: user.timeZone, preferredHour: user.preferredHour },
13907
+ limits: {
13908
+ liveTasks: MAX_LIVE_TASKS,
13909
+ priceTrackers: MAX_PRICE_TASKS,
13910
+ researchWaiting: MAX_LIVE_RESEARCH,
13911
+ scouts: MAX_LIVE_SCOUTS,
13912
+ wantWatches: MAX_WANT_TASKS,
13913
+ pageSize: MAX_PAGE,
13914
+ requestsBurst: rate.burst,
13915
+ requestsPerSecond: rate.perSecond
13916
+ },
13917
+ sources: {
13918
+ runner: d.research === true,
13919
+ bgg: d.bgg === true,
13920
+ ebay: d.ebay === true,
13921
+ shopify: d.shopify === true,
13922
+ mail: d.mail != null,
13923
+ webArea: d.webEditor
13924
+ }
13925
+ }
13926
+ };
13630
13927
  }
13631
13928
  async function getAnswer(d, user, id) {
13632
13929
  const task = await ownTask(d, user, id);
@@ -13637,6 +13934,7 @@ async function getAnswer(d, user, id) {
13637
13934
  return notFound();
13638
13935
  return {
13639
13936
  status: 200,
13937
+ headers: { ETag: etag(taskVersion(view.task)) },
13640
13938
  body: {
13641
13939
  task: await taskJson(d, user, view.task),
13642
13940
  history: { next: view.next, runs: view.runs, earlierRuns: view.earlierRuns, changes: view.changes, earlierChanges: view.earlierChanges },
@@ -13648,10 +13946,11 @@ async function written(w, user, result, status, base) {
13648
13946
  if (!result.ok)
13649
13947
  return refusal(result.error);
13650
13948
  const task = await w.d.store.getTask(result.task.id) ?? result.task;
13949
+ const tag = { ETag: etag(taskVersion(task)) };
13651
13950
  return {
13652
13951
  status,
13653
13952
  body: { task: await taskJson(w.d, user, task), message: result.text },
13654
- ...status === 201 ? { headers: { Location: `${base}/tasks/${encodeURIComponent(task.id)}` } } : {}
13953
+ headers: status === 201 ? { ...tag, Location: `${base}/tasks/${encodeURIComponent(task.id)}` } : tag
13655
13954
  };
13656
13955
  }
13657
13956
  async function createAnswer(w, user, body, base) {
@@ -13668,6 +13967,9 @@ async function createAnswer(w, user, body, base) {
13668
13967
  return form;
13669
13968
  return written(w, user, await makeTask(w, type, form), 201, base);
13670
13969
  }
13970
+ function bound(w, task) {
13971
+ return w.ifMatch ? { ...w, ifMatch: { taskId: task.id, version: w.ifMatch.version } } : w;
13972
+ }
13671
13973
  async function editAnswer(w, user, id, body, base) {
13672
13974
  const task = await editableTask(w.d, user, id);
13673
13975
  if (!task)
@@ -13675,17 +13977,27 @@ async function editAnswer(w, user, id, body, base) {
13675
13977
  const form = asFields(body, task.type, "edit");
13676
13978
  if (!(form instanceof URLSearchParams))
13677
13979
  return form;
13678
- return written(w, user, await saveEdit(w, task, form), 200, base);
13980
+ return written(w, user, await saveEdit(bound(w, task), task, form), 200, base);
13981
+ }
13982
+ async function runNowAnswer(w, user, id) {
13983
+ const task = await ownTask(w.d, user, id);
13984
+ if (!task || task.status === "archived")
13985
+ return notFound();
13986
+ const done = await runOn(w, task.id);
13987
+ if (!done.ok)
13988
+ return refusal(done.error);
13989
+ const after = await w.d.store.getTask(task.id) ?? task;
13990
+ return { status: 202, headers: { ETag: etag(taskVersion(after)) }, body: { task: await taskJson(w.d, user, after), message: done.text } };
13679
13991
  }
13680
13992
  async function actAnswer(w, user, id, action) {
13681
13993
  const task = await ownTask(w.d, user, id);
13682
13994
  if (!task || task.status === "archived")
13683
13995
  return notFound();
13684
- const done = await actOn(w, task.id, action);
13996
+ const done = await actOn(bound(w, task), task.id, action);
13685
13997
  if (!done.ok)
13686
13998
  return refusal(done.error);
13687
13999
  const after = await w.d.store.getTask(task.id) ?? task;
13688
- return { status: 200, body: { task: await taskJson(w.d, user, after), message: done.text } };
14000
+ return { status: 200, headers: { ETag: etag(taskVersion(after)) }, body: { task: await taskJson(w.d, user, after), message: done.text } };
13689
14001
  }
13690
14002
 
13691
14003
  // plugins/tracker/src/intake.ts
@@ -14170,7 +14482,7 @@ var MAX_TOKENS_PER_PERSON = 10;
14170
14482
  var MAX_TOKEN_NAME = 50;
14171
14483
  var TOKEN_EXPIRY_DAYS = [30, 90, 365];
14172
14484
  var DEFAULT_EXPIRY_DAYS = 90;
14173
- var DAY_MS4 = 24 * 60 * 60 * 1000;
14485
+ var DAY_MS5 = 24 * 60 * 60 * 1000;
14174
14486
  var TOUCH_EVERY_MS = 60 * 1000;
14175
14487
  function isApiTokenShape(value) {
14176
14488
  return /^trk_[A-Za-z0-9_-]{43}$/.test(value);
@@ -14222,7 +14534,7 @@ class ApiTokens {
14222
14534
  return { ok: false, error: `You already have ${MAX_TOKENS_PER_PERSON} API tokens, the most one person may. Revoke one first.` };
14223
14535
  }
14224
14536
  const token = `${TOKEN_PREFIX}${randomToken()}`;
14225
- const expiresAt = new Date(now.getTime() + input.days * DAY_MS4).toISOString();
14537
+ const expiresAt = new Date(now.getTime() + input.days * DAY_MS5).toISOString();
14226
14538
  const r = this.db.query(`INSERT INTO api_tokens (token_hash, user_id, name, created_at, expires_at, last_used_at, member_checked_at)
14227
14539
  VALUES (?, ?, ?, ?, ?, NULL, ?) RETURNING ${COLUMNS}`).get(sha256(token), userId, name, at, expiresAt, memberCheckedAt);
14228
14540
  return { ok: true, token, row: toToken(r) };
@@ -14798,7 +15110,7 @@ function startUsrRun(a, usr, me, kind) {
14798
15110
  const current = usrLinkRuns.get(usr);
14799
15111
  if (current?.running)
14800
15112
  return { ok: false, text: `Already ${RUN_WORDS[current.kind].doing}: ${current.done} of ${current.total} done.` };
14801
- const waiting = a.d.roster.people().filter((p) => p.discordId !== null && wanted(kind, usr.links.subjectOf(p.id)));
15113
+ const waiting = a.d.roster.people().filter((p) => p.discordId !== null && wanted2(kind, usr.links.subjectOf(p.id)));
14802
15114
  if (waiting.length === 0)
14803
15115
  return { ok: true, text: RUN_WORDS[kind].none };
14804
15116
  const run = { kind, running: true, done: 0, total: waiting.length, result: null };
@@ -14816,7 +15128,7 @@ function startUsrRun(a, usr, me, kind) {
14816
15128
  function personsOwn400(err) {
14817
15129
  return err.status === 400 && /^(discord_user_id|display_name)\b/.test(err.reason);
14818
15130
  }
14819
- function wanted(kind, subject) {
15131
+ function wanted2(kind, subject) {
14820
15132
  return kind === "link" ? subject === null : subject !== null;
14821
15133
  }
14822
15134
  async function usrRun(a, usr, invoker, by, guildId, ids, run) {
@@ -14841,7 +15153,7 @@ async function usrRun(a, usr, invoker, by, guildId, ids, run) {
14841
15153
  const notAdmin = () => end(false, `${words.stopped}: ${by}, who started it, is no longer an admin here. ${soFar()}.`);
14842
15154
  const stillWaiting = (id) => fresh(a, true, async () => {
14843
15155
  const p = await a.d.store.getUser(id);
14844
- return p && p.discordId !== null && wanted(run.kind, usr.links.subjectOf(id)) ? p : "skip";
15156
+ return p && p.discordId !== null && wanted2(run.kind, usr.links.subjectOf(id)) ? p : "skip";
14845
15157
  });
14846
15158
  for (const id of ids) {
14847
15159
  const first = await stillWaiting(id);
@@ -14929,6 +15241,46 @@ async function forgetRoute(a, method, form) {
14929
15241
  return redirect(`${base}/signin?forgotten=1`, [a.signOutCookie]);
14930
15242
  }
14931
15243
 
15244
+ // plugins/tracker/src/web/api-delegation.ts
15245
+ var DELEGATES_ENV = "TRACKER_API_DELEGATES";
15246
+ var DELEGATES_FORMAT = "^[0-9]+(,[0-9]+)*$";
15247
+ function parseDelegates(env, usrOn) {
15248
+ const raw = env[DELEGATES_ENV]?.trim();
15249
+ if (raw === undefined || raw === "")
15250
+ return new Set;
15251
+ if (!usrOn)
15252
+ throw new Error(`${DELEGATES_ENV} needs TRACKER_USR_URL: delegation tokens come from usr`);
15253
+ if (!new RegExp(DELEGATES_FORMAT).test(raw))
15254
+ throw new Error(`${DELEGATES_ENV} is usr API key ids separated by commas, such as 12,31`);
15255
+ return new Set(raw.split(",").map((id) => `key:${Number(id)}`));
15256
+ }
15257
+ function operationOf(r, method) {
15258
+ switch (r.kind) {
15259
+ case "me":
15260
+ return method === "GET" ? "whoami" : null;
15261
+ case "types":
15262
+ return method === "GET" ? "list_task_types" : null;
15263
+ case "capabilities":
15264
+ return method === "GET" ? "get_capabilities" : null;
15265
+ case "validate":
15266
+ return method === "POST" ? "validate_task" : null;
15267
+ case "tasks":
15268
+ return method === "GET" ? "list_tasks" : method === "POST" ? "create_task" : null;
15269
+ case "task":
15270
+ return method === "GET" ? "get_task" : method === "PATCH" ? "update_task" : method === "DELETE" ? "delete_task" : null;
15271
+ case "act":
15272
+ return method === "POST" ? r.action === "pause" ? "pause_task" : "resume_task" : null;
15273
+ case "run":
15274
+ return method === "POST" ? "run_task" : null;
15275
+ case "listings":
15276
+ return method === "POST" ? "send_listings" : null;
15277
+ case "sources":
15278
+ return method === "POST" ? "send_sources" : null;
15279
+ case "ready":
15280
+ return method === "POST" ? "sources_ready" : null;
15281
+ }
15282
+ }
15283
+
14932
15284
  // plugins/tracker/src/web/api-listings.ts
14933
15285
  var EBAY_DOMAINS = [
14934
15286
  "ebay.com",
@@ -15138,6 +15490,148 @@ async function fullerPass(w, user, task, owner, followUp, held, now) {
15138
15490
  };
15139
15491
  }
15140
15492
 
15493
+ // plugins/tracker/src/web/api-idempotency.ts
15494
+ import { randomBytes as randomBytes3 } from "crypto";
15495
+ var KEEP_MS = 24 * 60 * 60 * 1000;
15496
+ var PENDING_MS = 2 * 60 * 1000;
15497
+ function isIdempotencyKey(value) {
15498
+ return /^[A-Za-z0-9_.:-]{8,128}$/.test(value);
15499
+ }
15500
+ var OUTCOME_UNKNOWN = "That request failed partway and may have made its change. Read your tasks before you send it again, under a new Idempotency-Key.";
15501
+
15502
+ class ApiIdempotency {
15503
+ db;
15504
+ constructor(db) {
15505
+ this.db = db;
15506
+ }
15507
+ begin(userId, key, requestHash, now) {
15508
+ const at = now.toISOString();
15509
+ return this.db.transaction(() => {
15510
+ this.db.query("DELETE FROM api_idempotency WHERE created_at <= ?").run(new Date(now.getTime() - KEEP_MS).toISOString());
15511
+ const row = this.db.query("SELECT request_hash, status, answer, created_at FROM api_idempotency WHERE user_id = ? AND key = ?").get(userId, key);
15512
+ if (row !== null) {
15513
+ const stale = row.status === null && Date.parse(row.created_at) <= now.getTime() - PENDING_MS;
15514
+ if (!stale) {
15515
+ if (row.request_hash !== requestHash)
15516
+ return { kind: "conflict" };
15517
+ if (row.status === null || row.answer === null)
15518
+ return { kind: "in_progress" };
15519
+ const kept = JSON.parse(row.answer);
15520
+ return { kind: "replay", answer: { status: row.status, body: kept.body, headers: kept.headers } };
15521
+ }
15522
+ this.db.query("DELETE FROM api_idempotency WHERE user_id = ? AND key = ?").run(userId, key);
15523
+ }
15524
+ const claim = randomBytes3(16).toString("hex");
15525
+ this.db.query("INSERT INTO api_idempotency (user_id, key, request_hash, claim, status, answer, created_at) VALUES (?, ?, ?, ?, NULL, NULL, ?)").run(userId, key, requestHash, claim, at);
15526
+ return { kind: "new", claim };
15527
+ })();
15528
+ }
15529
+ finish(userId, key, claim, answer) {
15530
+ if (answer.status >= 200 && answer.status < 300)
15531
+ this.keep(userId, key, claim, answer);
15532
+ else
15533
+ this.abandon(userId, key, claim);
15534
+ }
15535
+ fail(userId, key, claim) {
15536
+ this.keep(userId, key, claim, { status: 500, body: { error: { code: "outcome_unknown", message: OUTCOME_UNKNOWN } }, headers: {} });
15537
+ }
15538
+ abandon(userId, key, claim) {
15539
+ this.db.query("DELETE FROM api_idempotency WHERE user_id = ? AND key = ? AND claim = ? AND status IS NULL").run(userId, key, claim);
15540
+ }
15541
+ keep(userId, key, claim, answer) {
15542
+ this.db.query("UPDATE api_idempotency SET status = ?, answer = ? WHERE user_id = ? AND key = ? AND claim = ? AND status IS NULL").run(answer.status, JSON.stringify({ body: answer.body, headers: answer.headers }), userId, key, claim);
15543
+ }
15544
+ }
15545
+
15546
+ // plugins/tracker/src/web/api-validate.ts
15547
+ function firstDueAfter2(schedule, now) {
15548
+ if (schedule.kind === "once")
15549
+ return new Date(0);
15550
+ if (schedule.kind === "poll")
15551
+ return new Date(Date.parse(schedule.start) - 1);
15552
+ return now;
15553
+ }
15554
+ function runs(schedule, user, now, renewal) {
15555
+ const ctx = { zone: user.timeZone, preferredHour: user.preferredHour };
15556
+ if (renewal) {
15557
+ const ask = nextDue(schedule, new Date(0), ctx);
15558
+ if (ask && ask.getTime() <= now.getTime())
15559
+ return { first: ask, second: nextDue(schedule, now, ctx) };
15560
+ }
15561
+ const first = nextDue(schedule, firstDueAfter2(schedule, now), ctx);
15562
+ return { first, second: first && schedule.kind !== "once" ? nextDue(schedule, first, ctx) : null };
15563
+ }
15564
+ async function validateAnswer(d, user, body) {
15565
+ const type = body.type;
15566
+ if (typeof type !== "string" || !NEW_TYPES.includes(type)) {
15567
+ return problem(400, "invalid", `\`type\` is one of ${offeredTypes(d).join(", ")}.`);
15568
+ }
15569
+ if (type === "research" && !d.research)
15570
+ return refusal(RESEARCH_OFF);
15571
+ if (type === "scout" && (!d.types.scout || !d.research))
15572
+ return refusal(SCOUT_OFF);
15573
+ if (type === "reminder" && !d.types.reminder)
15574
+ return refusal("Reminders are not available on this bot.");
15575
+ if (type === "renewal" && !d.types.renewal)
15576
+ return refusal("Renewals are not available on this bot.");
15577
+ const form = asFields(body, type, "new");
15578
+ if (!(form instanceof URLSearchParams))
15579
+ return form;
15580
+ const now = d.clock.now();
15581
+ let schedule = null;
15582
+ if (type === "reminder") {
15583
+ const plan = reminderPlan(d, user, reminderInput(form));
15584
+ if (!plan.ok)
15585
+ return refusal(plan.error);
15586
+ schedule = plan.schedule;
15587
+ } else if (type === "renewal") {
15588
+ const plan = renewalPlan(d, user, renewalInput(form));
15589
+ if (!plan.ok)
15590
+ return refusal(plan.error);
15591
+ schedule = plan.schedule;
15592
+ } else if (type === "scout") {
15593
+ const plan = scoutPlan(scoutInput(form));
15594
+ if (!plan.ok)
15595
+ return refusal(plan.error);
15596
+ schedule = scoutSchedule(plan.every, user.timeZone, now);
15597
+ }
15598
+ const capped = await liveTaskCap(d, user);
15599
+ if (capped)
15600
+ return refusal(capped);
15601
+ if (type === "scout" && await liveScouts(d, user) >= MAX_LIVE_SCOUTS) {
15602
+ return refusal(SCOUT_CAP);
15603
+ }
15604
+ if (schedule === null) {
15605
+ return {
15606
+ status: 200,
15607
+ body: {
15608
+ valid: true,
15609
+ type,
15610
+ checked: "fields",
15611
+ message: `The fields of that ${type} are right, and you are under the live-task cap. The rest is checked when it is made: ${type === "research" ? "the model runner takes it then" : "its page or source is read then"}. Nothing was made.`
15612
+ }
15613
+ };
15614
+ }
15615
+ const problems = scheduleProblems(schedule);
15616
+ if (problems.length > 0)
15617
+ return refusal(`That schedule does not work: ${problems.join("; ")}.`);
15618
+ const { first, second: then } = runs(schedule, user, now, type === "renewal");
15619
+ const cadence = describeSchedule(schedule, user, user.timeZone, now);
15620
+ return {
15621
+ status: 200,
15622
+ body: {
15623
+ valid: true,
15624
+ type,
15625
+ checked: "full",
15626
+ timeZone: user.timeZone,
15627
+ cadence,
15628
+ firstAt: first?.toISOString() ?? null,
15629
+ thenAt: then?.toISOString() ?? null,
15630
+ message: first ? `That ${type} would first run ${first.getTime() <= now.getTime() ? "at once" : formatInstant(first.toISOString(), user.timeZone, now)}${then ? `, then ${formatInstant(then.toISOString(), user.timeZone, now)}` : ""} (${cadence}). Nothing was made.` : `That ${type} would never run: its time has no next run. Nothing was made.`
15631
+ }
15632
+ };
15633
+ }
15634
+
15141
15635
  // plugins/tracker/src/web/api.ts
15142
15636
  var API_PREFIX = "/api/v1";
15143
15637
  var MAX_API_BYTES = 16 * 1024;
@@ -15193,6 +15687,7 @@ function bearer(header) {
15193
15687
  return m?.[1] ?? null;
15194
15688
  }
15195
15689
  var ACT = /^\/tasks\/([^/]+)\/(pause|resume)$/;
15690
+ var RUN = /^\/tasks\/([^/]+)\/run$/;
15196
15691
  var LISTINGS = /^\/tasks\/([^/]+)\/listings$/;
15197
15692
  var SOURCES = /^\/tasks\/([^/]+)\/sources(\/ready)?$/;
15198
15693
  var ONE = /^\/tasks\/([^/]+)$/;
@@ -15208,11 +15703,18 @@ function route(rest) {
15208
15703
  return { kind: "me" };
15209
15704
  if (rest === "/types")
15210
15705
  return { kind: "types" };
15706
+ if (rest === "/capabilities")
15707
+ return { kind: "capabilities" };
15211
15708
  if (rest === "/tasks")
15212
15709
  return { kind: "tasks" };
15710
+ if (rest === "/tasks/validate")
15711
+ return { kind: "validate" };
15213
15712
  const act = ACT.exec(rest);
15214
15713
  if (act)
15215
15714
  return { kind: "act", id: segment(act[1]), action: act[2] };
15715
+ const run = RUN.exec(rest);
15716
+ if (run)
15717
+ return { kind: "run", id: segment(run[1]) };
15216
15718
  const listings = LISTINGS.exec(rest);
15217
15719
  if (listings)
15218
15720
  return { kind: "listings", id: segment(listings[1]) };
@@ -15227,7 +15729,7 @@ function allowedMethods(r) {
15227
15729
  return "GET, POST";
15228
15730
  if (r.kind === "task")
15229
15731
  return "GET, PATCH, DELETE";
15230
- if (r.kind === "act" || r.kind === "listings" || r.kind === "sources" || r.kind === "ready")
15732
+ if (r.kind === "validate" || r.kind === "act" || r.kind === "run" || r.kind === "listings" || r.kind === "sources" || r.kind === "ready")
15231
15733
  return "POST";
15232
15734
  return "GET";
15233
15735
  }
@@ -15258,6 +15760,10 @@ async function authenticate(w, request) {
15258
15760
  const invalid = problem(401, "invalid_token", "That token is unknown, revoked or expired.", { "WWW-Authenticate": `${CHALLENGE}, error="invalid_token"` });
15259
15761
  const sent = bearer(header);
15260
15762
  const now = d.clock.now();
15763
+ const delegates = d.usr?.delegates;
15764
+ if (sent !== null && !sent.startsWith(TOKEN_PREFIX) && d.usr && delegates && delegates.size > 0) {
15765
+ return authenticateDelegation(w, d.usr, delegates, sent, invalid);
15766
+ }
15261
15767
  const token = sent === null ? null : d.apiTokens.find(sent, now);
15262
15768
  if (!token) {
15263
15769
  const waitFailed = w.failed.take(FAILED_KEY, now.getTime());
@@ -15282,7 +15788,61 @@ async function authenticate(w, request) {
15282
15788
  if (!current)
15283
15789
  return no(invalid);
15284
15790
  d.apiTokens.touch(current, d.clock.now());
15285
- return { ok: true, token: current, user };
15791
+ return { ok: true, via: { kind: "token", token: current }, user };
15792
+ }
15793
+ async function authenticateDelegation(w, usr, delegates, sent, invalid) {
15794
+ const { d } = w;
15795
+ const no = (answer) => ({ ok: false, answer });
15796
+ const failed = () => {
15797
+ const wait = w.failed.take(FAILED_KEY, d.clock.now().getTime());
15798
+ return no(wait > 0 ? problem(429, "rate_limited", `Too many requests with bad tokens; try again in ${wait} s.`, { "Retry-After": String(wait) }) : invalid);
15799
+ };
15800
+ const checked = await usr.verifier.verifyDelegation(sent, usr.app);
15801
+ if (checked === "unavailable")
15802
+ return no(problem(503, "usr_unavailable", "I could not fetch usr's signing keys to check that token; try again in a minute.", { "Retry-After": "60" }));
15803
+ if (checked === "invalid" || !delegates.has(checked.actor))
15804
+ return failed();
15805
+ const wait = w.limiter.take(`dlg:${checked.sub}`, d.clock.now().getTime());
15806
+ if (wait > 0)
15807
+ return no(problem(429, "rate_limited", `Too many requests for this person; try again in ${wait} s.`, { "Retry-After": String(wait) }));
15808
+ const userId = usr.links.userOfSubject(checked.sub);
15809
+ const user = userId ? await d.store.getUser(userId) : null;
15810
+ if (!user || !d.admissions.isRegistered(user.id))
15811
+ return failed();
15812
+ const found = await w.recheck(user, w.confirmedAt(user.id));
15813
+ if (found === "not-member")
15814
+ return no(problem(403, "not_member", "That person is no longer a member of this tracker's server, or lost the role it asks for."));
15815
+ if (found === "unknown")
15816
+ return no(problem(503, "membership_unknown", MEMBERSHIP_UNKNOWN2, { "Retry-After": "60" }));
15817
+ return { ok: true, via: { kind: "delegation", delegation: checked }, user };
15818
+ }
15819
+ async function write(writer, user, r, method, body, base) {
15820
+ if (method === "DELETE")
15821
+ return actAnswer(writer, user, r.id, "delete");
15822
+ if (r.kind === "tasks")
15823
+ return createAnswer(writer, user, body, base);
15824
+ if (r.kind === "task")
15825
+ return editAnswer(writer, user, r.id, body, base);
15826
+ if (r.kind === "act") {
15827
+ if (Object.keys(body).length > 0)
15828
+ return problem(400, "unknown_field", `\`${r.action}\` takes no fields: send {}.`);
15829
+ return actAnswer(writer, user, r.id, r.action);
15830
+ }
15831
+ if (r.kind === "run") {
15832
+ if (Object.keys(body).length > 0)
15833
+ return problem(400, "unknown_field", "`run` takes no fields: send {}.");
15834
+ return runNowAnswer(writer, user, r.id);
15835
+ }
15836
+ if (r.kind === "listings")
15837
+ return listingsAnswer(writer, user, r.id, body);
15838
+ if (r.kind === "sources")
15839
+ return sourcesAnswer(writer, user, r.id, body);
15840
+ if (r.kind === "ready") {
15841
+ if (Object.keys(body).length > 0)
15842
+ return problem(400, "unknown_field", "`ready` takes no fields: send {}.");
15843
+ return sourcesReadyAnswer(writer, user, r.id);
15844
+ }
15845
+ return problem(405, "method_not_allowed", "Not a write.");
15286
15846
  }
15287
15847
  async function handleApi(w, request, path) {
15288
15848
  if (request.headers.get("origin") !== null)
@@ -15298,45 +15858,83 @@ async function handleApi(w, request, path) {
15298
15858
  const method = request.method;
15299
15859
  if (!allowed.split(", ").includes(method))
15300
15860
  return json2(problem(405, "method_not_allowed", `Use ${allowed}.`, { Allow: allowed }));
15861
+ if (auth.via.kind === "delegation") {
15862
+ const operation = operationOf(r, method);
15863
+ if (operation === null)
15864
+ return json2(problem(403, "insufficient_scope", "No delegation token can do this."));
15865
+ const needed = `${w.d.usr?.app}:${operation}`;
15866
+ if (!auth.via.delegation.scopes.has(needed)) {
15867
+ return json2(problem(403, "insufficient_scope", `That token does not allow this: it needs ${needed}.`, { "WWW-Authenticate": `${CHALLENGE}, error="insufficient_scope", scope="${needed}"` }));
15868
+ }
15869
+ if (method !== "GET")
15870
+ w.d.log.info(`api: ${operation} for ${user.id} by usr ${auth.via.delegation.actor} (token ${auth.via.delegation.jti})`);
15871
+ }
15301
15872
  const writer = { d: w.d, queue: w.queue, reading: w.reading, userId: user.id, arrived: w.arrived };
15302
15873
  const base = `${w.base}${API_PREFIX}`;
15303
- if (method === "POST" || method === "PATCH") {
15304
- const read = await readJson(request, r.kind === "listings" ? MAX_LISTINGS_BYTES : r.kind === "sources" ? MAX_SOURCES_BYTES : MAX_API_BYTES);
15874
+ if (r.kind === "validate") {
15875
+ if (request.headers.get("if-match") !== null)
15876
+ return json2(problem(400, "if_match_unsupported", "If-Match goes only with PATCH or DELETE of a task, or its pause or resume."));
15877
+ const read = await readJson(request);
15305
15878
  if (!read.ok)
15306
15879
  return json2(read.answer);
15307
- const { body } = read;
15308
- if (r.kind === "tasks")
15309
- return json2(await createAnswer(writer, user, body, base));
15310
- if (r.kind === "task")
15311
- return json2(await editAnswer(writer, user, r.id, body, base));
15312
- if (r.kind === "act") {
15313
- if (Object.keys(body).length > 0)
15314
- return json2(problem(400, "unknown_field", `\`${r.action}\` takes no fields: send {}.`));
15315
- return json2(await actAnswer(writer, user, r.id, r.action));
15880
+ return json2(await validateAnswer(w.d, user, read.body));
15881
+ }
15882
+ if (method === "POST" || method === "PATCH" || method === "DELETE") {
15883
+ const ifMatchRaw = request.headers.get("if-match");
15884
+ if (ifMatchRaw !== null) {
15885
+ const onOneTask = r.kind === "task" && method !== "POST" || r.kind === "act";
15886
+ if (!onOneTask)
15887
+ return json2(problem(400, "if_match_unsupported", "If-Match goes only with PATCH or DELETE of a task, or its pause or resume."));
15888
+ const version = ifMatchVersion(ifMatchRaw);
15889
+ if (version === null)
15890
+ return json2(problem(400, "invalid_if_match", 'If-Match is one task version in quotes, as its ETag gives it: "0123456789abcdef".'));
15891
+ writer.ifMatch = { taskId: r.id.trim(), version };
15316
15892
  }
15317
- if (r.kind === "listings")
15318
- return json2(await listingsAnswer(writer, user, r.id, body));
15319
- if (r.kind === "sources")
15320
- return json2(await sourcesAnswer(writer, user, r.id, body));
15321
- if (r.kind === "ready") {
15322
- if (Object.keys(body).length > 0)
15323
- return json2(problem(400, "unknown_field", "`ready` takes no fields: send {}."));
15324
- return json2(await sourcesReadyAnswer(writer, user, r.id));
15893
+ const key = request.headers.get("idempotency-key");
15894
+ if (key !== null && !isIdempotencyKey(key))
15895
+ return json2(problem(400, "invalid_idempotency_key", "Idempotency-Key is 8 to 128 letters, digits, '-', '_', '.' or ':'; a UUID will do."));
15896
+ let body = {};
15897
+ if (method !== "DELETE") {
15898
+ const read = await readJson(request, r.kind === "listings" ? MAX_LISTINGS_BYTES : r.kind === "sources" ? MAX_SOURCES_BYTES : MAX_API_BYTES);
15899
+ if (!read.ok)
15900
+ return json2(read.answer);
15901
+ body = read.body;
15902
+ }
15903
+ if (key === null)
15904
+ return json2(await write(writer, user, r, method, body, base));
15905
+ const hash = sha256(`${method} ${path}
15906
+ ${writer.ifMatch?.version ?? ""}
15907
+ ${canonicalJson(body)}`);
15908
+ const begun = w.d.apiIdempotency.begin(user.id, key, hash, w.d.clock.now());
15909
+ if (begun.kind === "conflict")
15910
+ return json2(problem(409, "idempotency_conflict", "That Idempotency-Key was used for a different request; use a new key for a new request."));
15911
+ if (begun.kind === "in_progress")
15912
+ return json2(problem(409, "idempotency_in_progress", "A request with that Idempotency-Key is still running; try again in a moment.", { "Retry-After": "1" }));
15913
+ if (begun.kind === "replay")
15914
+ return json2({ status: begun.answer.status, body: begun.answer.body, headers: { ...begun.answer.headers, "Idempotency-Replayed": "true" } });
15915
+ let answer;
15916
+ try {
15917
+ answer = await write(writer, user, r, method, body, base);
15918
+ } catch (err) {
15919
+ w.d.apiIdempotency.fail(user.id, key, begun.claim);
15920
+ throw err;
15325
15921
  }
15922
+ w.d.apiIdempotency.finish(user.id, key, begun.claim, { status: answer.status, body: answer.body, headers: answer.headers ?? {} });
15923
+ return json2(answer);
15326
15924
  }
15327
- if (method === "DELETE" && r.kind === "task")
15328
- return json2(await actAnswer(writer, user, r.id, "delete"));
15329
15925
  if (r.kind === "types")
15330
15926
  return json2(typesAnswer(w.d));
15331
15927
  if (r.kind === "tasks")
15332
- return json2(await listAnswer(w.d, user));
15928
+ return json2(await listAnswer(w.d, user, new URL(request.url).searchParams));
15929
+ if (r.kind === "capabilities")
15930
+ return json2(capabilitiesAnswer(w.d, user, { burst: RATE_BURST, perSecond: RATE_PER_SECOND }));
15333
15931
  if (r.kind === "task")
15334
15932
  return json2(await getAnswer(w.d, user, r.id));
15335
15933
  return json2({
15336
15934
  status: 200,
15337
15935
  body: {
15338
15936
  user: { id: user.id, name: user.displayName, timeZone: user.timeZone, preferredHour: user.preferredHour },
15339
- token: { id: auth.token.id, name: auth.token.name, createdAt: auth.token.createdAt, expiresAt: auth.token.expiresAt }
15937
+ ...auth.via.kind === "token" ? { token: { id: auth.via.token.id, name: auth.via.token.name, createdAt: auth.via.token.createdAt, expiresAt: auth.via.token.expiresAt } } : { delegation: { actor: auth.via.delegation.actor, scopes: [...auth.via.delegation.scopes].sort(), expiresAt: new Date(auth.via.delegation.expiresAt).toISOString() } }
15340
15938
  }
15341
15939
  });
15342
15940
  }
@@ -15544,6 +16142,9 @@ var JWKS_RETRY_MS = 30 * 1000;
15544
16142
  var JWKS_TIMEOUT_MS = 5000;
15545
16143
  var MAX_TOKEN = 8192;
15546
16144
  var MAX_JWKS = 64 * 1024;
16145
+ var MAX_DELEGATION_MS = 30 * 60 * 1000;
16146
+ var DELEGATION_SKEW_MS = 60 * 1000;
16147
+ var JTI = /^[A-Za-z0-9_-]{1,64}$/;
15547
16148
  function decode(part) {
15548
16149
  try {
15549
16150
  return JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
@@ -15556,38 +16157,70 @@ class UsrVerifier {
15556
16157
  o;
15557
16158
  keys = [];
15558
16159
  fetchedAt = Number.NEGATIVE_INFINITY;
16160
+ loadFailed = false;
15559
16161
  pending = null;
15560
16162
  constructor(o) {
15561
16163
  this.o = o;
15562
16164
  }
15563
16165
  async verify(token) {
15564
- if (!token || token.length > MAX_TOKEN)
16166
+ const signed = await this.signed(token);
16167
+ if (typeof signed === "string" || signed.header.typ === "dlg+jwt")
16168
+ return null;
16169
+ const claims = signed.claims;
16170
+ if (claims.iss !== "usr" || claims.act !== undefined)
16171
+ return null;
16172
+ if (typeof claims.exp !== "number" || claims.exp * 1000 <= this.o.now())
16173
+ return null;
16174
+ if (typeof claims.sub !== "string" || claims.sub === "")
15565
16175
  return null;
16176
+ const roles = Array.isArray(claims.roles) ? claims.roles.filter((r) => typeof r === "string") : [];
16177
+ return { sub: claims.sub, roles };
16178
+ }
16179
+ async verifyDelegation(token, audience) {
16180
+ const signed = await this.signed(token);
16181
+ if (typeof signed === "string")
16182
+ return signed;
16183
+ if (signed.header.typ !== "dlg+jwt")
16184
+ return "invalid";
16185
+ const c = signed.claims;
16186
+ if (c.iss !== "usr" || c.aud !== audience)
16187
+ return "invalid";
16188
+ if (typeof c.sub !== "string" || c.sub === "" || typeof c.scope !== "string" || typeof c.jti !== "string" || !JTI.test(c.jti))
16189
+ return "invalid";
16190
+ const actor = c.act?.sub;
16191
+ if (typeof actor !== "string" || !/^key:[0-9]+$/.test(actor))
16192
+ return "invalid";
16193
+ if (typeof c.exp !== "number")
16194
+ return "invalid";
16195
+ const expiresAt = c.exp * 1000;
16196
+ const now = this.o.now();
16197
+ if (expiresAt <= now || expiresAt - now > MAX_DELEGATION_MS + DELEGATION_SKEW_MS)
16198
+ return "invalid";
16199
+ return { sub: c.sub, actor, scopes: new Set(c.scope.split(" ").filter((x) => x !== "")), jti: c.jti, expiresAt };
16200
+ }
16201
+ async signed(token) {
16202
+ if (!token || token.length > MAX_TOKEN)
16203
+ return "invalid";
15566
16204
  const parts = token.split(".");
15567
16205
  if (parts.length !== 3)
15568
- return null;
16206
+ return "invalid";
15569
16207
  const [h, p, s] = parts;
15570
16208
  const header = decode(h);
15571
- if (!header || header.alg !== "ES256" || header.typ === "dlg+jwt" || typeof header.kid !== "string")
15572
- return null;
16209
+ if (!header || header.alg !== "ES256" || typeof header.kid !== "string")
16210
+ return "invalid";
15573
16211
  const key = await this.keyFor(header.kid, false) ?? await this.keyFor(header.kid, true);
15574
16212
  if (!key)
15575
- return null;
16213
+ return this.loadFailed ? "unavailable" : "invalid";
15576
16214
  const signature = Buffer.from(s, "base64url");
15577
16215
  if (signature.length !== 64)
15578
- return null;
16216
+ return "invalid";
15579
16217
  const ok = await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, signature, new TextEncoder().encode(`${h}.${p}`));
15580
16218
  if (!ok)
15581
- return null;
16219
+ return "invalid";
15582
16220
  const claims = decode(p);
15583
- if (!claims || claims.iss !== "usr" || claims.act !== undefined)
15584
- return null;
15585
- if (typeof claims.exp !== "number" || claims.exp * 1000 <= this.o.now())
15586
- return null;
15587
- if (typeof claims.sub !== "string" || claims.sub === "")
15588
- return null;
15589
- const roles = Array.isArray(claims.roles) ? claims.roles.filter((r) => typeof r === "string") : [];
15590
- return { sub: claims.sub, roles };
16221
+ if (claims === null || typeof claims !== "object")
16222
+ return "invalid";
16223
+ return { header, claims };
15591
16224
  }
15592
16225
  async keyFor(kid, force) {
15593
16226
  const fresh = () => {
@@ -15614,6 +16247,7 @@ class UsrVerifier {
15614
16247
  }
15615
16248
  async load() {
15616
16249
  this.fetchedAt = this.o.now();
16250
+ this.loadFailed = true;
15617
16251
  try {
15618
16252
  const res = await (this.o.fetchImpl ?? fetch)(`${this.o.url}/.well-known/jwks.json`, {
15619
16253
  headers: { Accept: "application/json" },
@@ -15638,6 +16272,7 @@ class UsrVerifier {
15638
16272
  } catch {}
15639
16273
  }
15640
16274
  this.keys = keys;
16275
+ this.loadFailed = false;
15641
16276
  } catch {}
15642
16277
  }
15643
16278
  }
@@ -15731,6 +16366,7 @@ function createWebHandler(w) {
15731
16366
  const shared = pending;
15732
16367
  return lookupWithin(() => shared, MEMBER_CHECK_TIMEOUT_MS);
15733
16368
  }
16369
+ const memberConfirmed = new Map;
15734
16370
  async function recheck(d, user, checkedAt) {
15735
16371
  if (w.guildIds === null)
15736
16372
  return "ok";
@@ -15750,12 +16386,14 @@ function createWebHandler(w) {
15750
16386
  failedAt.set(user.id, d.clock.now().getTime());
15751
16387
  }
15752
16388
  if (found === "not-member") {
16389
+ memberConfirmed.delete(user.id);
15753
16390
  d.sessions.deleteForUser(user.id);
15754
16391
  d.apiTokens.deleteForUser(user.id);
15755
16392
  return "not-member";
15756
16393
  }
15757
16394
  if (found === "member") {
15758
16395
  const at = d.clock.now().toISOString();
16396
+ memberConfirmed.set(user.id, at);
15759
16397
  d.sessions.confirmMember(user.id, at);
15760
16398
  d.apiTokens.confirmMember(user.id, at);
15761
16399
  return "ok";
@@ -15905,7 +16543,7 @@ function createWebHandler(w) {
15905
16543
  const d = w.deps();
15906
16544
  if (!d)
15907
16545
  return json2(problem(503, "starting", "The tracker is starting up; try again in a minute.", { "Retry-After": "60" }));
15908
- return handleApi({ d, queue: w.queue, reading, base, limiter, failed, arrived, recheck: (user, at) => recheck(d, user, at) }, request, info.path);
16546
+ return handleApi({ d, queue: w.queue, reading, base, limiter, failed, arrived, recheck: (user, at) => recheck(d, user, at), confirmedAt: (userId) => memberConfirmed.get(userId) ?? null }, request, info.path);
15909
16547
  }
15910
16548
  const method = request.method;
15911
16549
  if (method !== "GET" && method !== "POST")
@@ -16052,7 +16690,7 @@ function createWebHandler(w) {
16052
16690
  }
16053
16691
 
16054
16692
  // plugins/tracker/src/mail.ts
16055
- import { randomBytes as randomBytes3 } from "crypto";
16693
+ import { randomBytes as randomBytes4 } from "crypto";
16056
16694
 
16057
16695
  // plugins/tracker/src/ebay-alert.ts
16058
16696
  var MAX_ALERT_LISTINGS = MAX_LISTINGS_PER_POST;
@@ -16233,7 +16871,7 @@ class MailAddresses {
16233
16871
  const row = this.db.query("SELECT local FROM mail_addresses WHERE user_id = ?").get(userId);
16234
16872
  if (row)
16235
16873
  return `${row.local}@${this.domain}`;
16236
- const local = `m-${randomBytes3(12).toString("hex")}`;
16874
+ const local = `m-${randomBytes4(12).toString("hex")}`;
16237
16875
  this.db.query("INSERT INTO mail_addresses (user_id, local, created_at) VALUES (?, ?, ?)").run(userId, local, now.toISOString());
16238
16876
  return `${local}@${this.domain}`;
16239
16877
  }
@@ -16681,6 +17319,7 @@ function createPlugin(host, options = {}) {
16681
17319
  const webOrigin = parseWebUrl(host.env.TRACKER_WEB_URL);
16682
17320
  const cityHall = parseCityHallConfig(host.env);
16683
17321
  const usrConfig = parseUsrConfig(host.env);
17322
+ const delegates = parseDelegates(host.env, usrConfig !== null);
16684
17323
  const mailConfig = parseMailConfig(host.env);
16685
17324
  const usrClient = usrConfig ? createUsrClient({ config: usrConfig, ...options.usrFetch ? { fetchImpl: options.usrFetch } : {} }) : null;
16686
17325
  const budgetUnlimited = parseBudgetUnlimited(host.env[BUDGET_UNLIMITED_KEY]);
@@ -16962,6 +17601,8 @@ function createPlugin(host, options = {}) {
16962
17601
  host.log.warn(`execute lane is off, so /research is unavailable: ${cityHall.missing.join(", ")} not set`);
16963
17602
  if (usrConfig)
16964
17603
  host.log.info(`usr link on: ${usrConfig.url}, app ${usrConfig.app}`);
17604
+ if (delegates.size > 0)
17605
+ host.log.info(`task API takes usr delegation tokens from ${[...delegates].join(", ")}`);
16965
17606
  const openedDeps = {
16966
17607
  store: openedStore,
16967
17608
  admissions: new Admissions(opened),
@@ -16976,6 +17617,7 @@ function createPlugin(host, options = {}) {
16976
17617
  logins: new LoginLinks(opened),
16977
17618
  sessions: new Sessions(opened),
16978
17619
  apiTokens: new ApiTokens(opened),
17620
+ apiIdempotency: new ApiIdempotency(opened),
16979
17621
  roster: new Roster(opened),
16980
17622
  ceilings,
16981
17623
  budgetUnlimited,
@@ -16995,7 +17637,8 @@ function createPlugin(host, options = {}) {
16995
17637
  url: usrConfig.url,
16996
17638
  verifier: new UsrVerifier({ url: usrConfig.url, now: () => clock.now().getTime(), ...options.usrFetch ? { fetchImpl: options.usrFetch } : {} }),
16997
17639
  app: usrConfig.app,
16998
- links: new UsrLinks(opened)
17640
+ links: new UsrLinks(opened),
17641
+ delegates
16999
17642
  } : null
17000
17643
  };
17001
17644
  if (guildIds === null)