@rackbops/plugin-tracker 0.36.0 → 0.37.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 +58 -7
  2. package/dist/plugin.js +667 -70
  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 [];
@@ -6908,7 +6971,7 @@ function retimed(s, day, owner) {
6908
6971
  return null;
6909
6972
  }
6910
6973
  async function heldRuns(store, task) {
6911
- return (await store.listOccurrences({ taskId: task.id, status: "queued" })).filter((o) => !o.dedupeKey.startsWith(SNOOZE_PREFIX) && !hasFired(o));
6974
+ return (await store.listOccurrences({ taskId: task.id, status: "queued" })).filter((o) => !isOffSchedule(o.dedupeKey) && !hasFired(o));
6912
6975
  }
6913
6976
  async function restoreHeldRun(store, task, held, before, after, now) {
6914
6977
  const s = task.schedule;
@@ -7562,6 +7625,10 @@ async function pauseLocked(d, user, taskId) {
7562
7625
  const at = d.clock.now().toISOString();
7563
7626
  await d.store.updateTask(task.id, { status: "paused", at });
7564
7627
  await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "paused", detail: "paused by the owner", at });
7628
+ for (const o of await d.store.listOccurrences({ taskId: task.id, status: "queued" })) {
7629
+ if (o.dedupeKey.startsWith(RUN_NOW_PREFIX))
7630
+ await deleteIdleRun(d.store, o);
7631
+ }
7565
7632
  return { ok: true, text: `Paused \`${task.id}\`: nothing is sent until you resume it.` };
7566
7633
  }
7567
7634
  function resumeTask(d, user, taskId) {
@@ -9465,6 +9532,19 @@ var MIGRATIONS = [
9465
9532
  ended_at TEXT
9466
9533
  );
9467
9534
  CREATE UNIQUE INDEX intake_conversations_open ON intake_conversations (user_id) WHERE status = 'open';
9535
+ `,
9536
+ `
9537
+ CREATE TABLE api_idempotency (
9538
+ user_id TEXT NOT NULL,
9539
+ key TEXT NOT NULL,
9540
+ request_hash TEXT NOT NULL,
9541
+ claim TEXT NOT NULL,
9542
+ status INTEGER,
9543
+ answer TEXT,
9544
+ created_at TEXT NOT NULL,
9545
+ PRIMARY KEY (user_id, key)
9546
+ );
9547
+ CREATE INDEX api_idempotency_created ON api_idempotency (created_at);
9468
9548
  `
9469
9549
  ];
9470
9550
  var BUSY_TIMEOUT_MS = 5000;
@@ -10102,6 +10182,7 @@ function scoutSchedule(every, zone, now) {
10102
10182
  const start = `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
10103
10183
  return { kind: "calendar", every, unit: "day", start };
10104
10184
  }
10185
+ var SCOUT_CAP = `You already have ${MAX_LIVE_SCOUTS} scouts, the most one person may. Delete or change one instead.`;
10105
10186
  async function liveScouts(d, user) {
10106
10187
  const active = await d.store.listTasks({ ownerId: user.id, status: "active", type: "scout" });
10107
10188
  const paused = await d.store.listTasks({ ownerId: user.id, status: "paused", type: "scout" });
@@ -10118,7 +10199,7 @@ async function createScout(d, user, input) {
10118
10199
  if (capped)
10119
10200
  return { ok: false, error: capped };
10120
10201
  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.` };
10202
+ return { ok: false, error: SCOUT_CAP };
10122
10203
  }
10123
10204
  const now = d.clock.now();
10124
10205
  const actor = { userId: user.id, admin: user.admin };
@@ -10734,6 +10815,7 @@ class Roster {
10734
10815
  del("web_sessions", "user_id = ?", userId);
10735
10816
  del("web_login_tokens", "user_id = ?", userId);
10736
10817
  del("api_tokens", "user_id = ?", userId);
10818
+ del("api_idempotency", "user_id = ?", userId);
10737
10819
  del("notices", "key LIKE 'budget:person:' || ? || ':%'", userId);
10738
10820
  del("notices", "key LIKE 'digest:' || ? || ':%'", userId);
10739
10821
  del("ceiling_changes", "user_id = ?", userId);
@@ -10760,6 +10842,24 @@ function replyText(r) {
10760
10842
  return `chose ${String(r.payload?.choice ?? "?")}`;
10761
10843
  return r.kind === "opt_out" ? "opted out" : r.kind;
10762
10844
  }
10845
+ function owed(r) {
10846
+ return r.retryAt !== null || r.status === "claimed" || r.status === "pending";
10847
+ }
10848
+ function deliveryView(o, rows, people, viewer, seesEveryone) {
10849
+ const shown = seesEveryone ? rows : rows.filter((r) => r.userId === viewer.id);
10850
+ const sends = shown.map((r) => ({ to: people.get(r.userId)?.displayName ?? r.userId, status: r.status, error: r.error ? clip2(r.error, 120) : null }));
10851
+ if (shown.length === 0) {
10852
+ const finished = o.status !== "queued" && o.status !== "running";
10853
+ return { state: finished ? "nothing_to_send" : "not_run", reason: null, sends };
10854
+ }
10855
+ const lost = shown.find((r) => r.status === "failed" && !owed(r));
10856
+ if (lost)
10857
+ return { state: "failed", reason: lost.error ? clip2(lost.error, 120) : null, sends };
10858
+ const unsure = shown.find((r) => r.status === "unconfirmed");
10859
+ if (unsure)
10860
+ return { state: "unconfirmed", reason: unsure.error ? clip2(unsure.error, 120) : null, sends };
10861
+ return { state: shown.some(owed) ? "sending" : "delivered", reason: null, sends };
10862
+ }
10763
10863
  var OTHER_RECIPIENT_ERROR = "a recipient cannot be messaged";
10764
10864
  function runView(o, replies, people, viewer, now, seesEveryone) {
10765
10865
  const error = o.status === "failed" && o.error ? seesEveryone || !errorNamesOther(o.error, viewer.id) ? clip2(o.error, 120) : OTHER_RECIPIENT_ERROR : null;
@@ -10834,8 +10934,11 @@ async function loadHistory(d, user, taskId) {
10834
10934
  const tail = await d.store.changesTail(task.id, seesEveryone ? null : user.id, HISTORY_CHANGES);
10835
10935
  const lead = tail.before?.kind === "paused" ? [tail.before] : [];
10836
10936
  const changes = seesEveryone ? tail.rows : changesForRecipient([...lead, ...tail.rows], user.id).slice(lead.length);
10937
+ const deliveries = new Map;
10938
+ for (const o of runs.rows)
10939
+ deliveries.set(o.id, await d.store.listDeliveries({ occurrenceId: o.id }));
10837
10940
  const people = new Map;
10838
- for (const r of replies) {
10941
+ for (const r of [...replies, ...[...deliveries.values()].flat()]) {
10839
10942
  if (!people.has(r.userId)) {
10840
10943
  const u = await d.store.getUser(r.userId);
10841
10944
  if (u)
@@ -10858,7 +10961,7 @@ async function loadHistory(d, user, taskId) {
10858
10961
  task,
10859
10962
  cadence: task.schedule && owner ? describeSchedule(task.schedule, owner, user.timeZone, now) : "no schedule",
10860
10963
  next: next ? formatInstant(next.dueAt, user.timeZone, now) : null,
10861
- runs: runs.rows.map((o) => runView(o, replies, people, user, now, seesEveryone)),
10964
+ runs: runs.rows.map((o) => ({ ...runView(o, replies, people, user, now, seesEveryone), delivery: deliveryView(o, deliveries.get(o.id) ?? [], people, user, seesEveryone) })),
10862
10965
  earlierRuns: Math.max(0, runs.total - HISTORY_RUNS),
10863
10966
  changes: changes.map((e) => changeView(e, user, now, names)),
10864
10967
  earlierChanges: Math.max(0, tail.total - HISTORY_CHANGES),
@@ -11395,6 +11498,52 @@ async function loadUsage(store, now, days = USAGE_DAYS) {
11395
11498
  return usageReport(rows, now, days);
11396
11499
  }
11397
11500
 
11501
+ // plugins/tracker/src/run-now.ts
11502
+ var RUNNABLE = new Set(["reminder", "price", "wantlist", "wantjudge", "scout"]);
11503
+ var RUN_NOW_PER_DAY = 10;
11504
+ var DAY_MS4 = 24 * 3600000;
11505
+ var NOT_NOW = "That task cannot run now:";
11506
+ function runNow(d, user, taskId) {
11507
+ return d.locks.turn(taskId.trim(), () => runNowLocked(d, user, taskId), () => ({ ok: false, error: TASK_BUSY }));
11508
+ }
11509
+ async function runNowLocked(d, user, taskId) {
11510
+ const task = await ownLiveTask(d, user, taskId);
11511
+ if (!task)
11512
+ return { ok: false, error: NO_SUCH_TASK };
11513
+ if (task.status !== "active")
11514
+ return { ok: false, error: `That task is ${task.status}, not active.` };
11515
+ if (task.type === "renewal")
11516
+ return { ok: false, error: `${NOT_NOW} a renewal asks about its own date, at its own time.` };
11517
+ if (task.type === "research")
11518
+ return { ok: false, error: `${NOT_NOW} a research request runs once.` };
11519
+ if (!RUNNABLE.has(task.type))
11520
+ return { ok: false, error: `${NOT_NOW} a ${task.type} runs only on its own schedule.` };
11521
+ if (!task.schedule || task.schedule.kind === "once") {
11522
+ return { ok: false, error: `${NOT_NOW} a one-off runs once, at its own time; change its time instead.` };
11523
+ }
11524
+ const now = d.clock.now();
11525
+ const queued = await d.store.listOccurrences({ taskId: task.id, status: "queued" });
11526
+ if (queued.some((o) => !hasFired(o) && Date.parse(o.dueAt) <= now.getTime())) {
11527
+ return { ok: false, error: `${NOT_NOW} it already has a run due, which is on its way.` };
11528
+ }
11529
+ if (queued.some((o) => !hasFired(o) && o.dedupeKey.startsWith(FOLLOW_UP_PREFIX))) {
11530
+ return { ok: false, error: `${NOT_NOW} it is waiting to try its last look again.` };
11531
+ }
11532
+ const since = new Date(now.getTime() - DAY_MS4).toISOString();
11533
+ if (await d.store.runsNowSince(user.id, since) >= RUN_NOW_PER_DAY) {
11534
+ 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.` };
11535
+ }
11536
+ const at = now.toISOString();
11537
+ const made = await d.store.createOccurrence({ taskId: task.id, lane: task.lane, dueAt: at, dedupeKey: runNowKey(task.id, crypto.randomUUID()), at });
11538
+ if (!made)
11539
+ return { ok: false, error: TASK_BUSY };
11540
+ d.log.info(`run now: ${task.id} for ${user.id} (${made.id})`);
11541
+ return { ok: true, text: `Running \`${task.id}\` now: ${when2(task)}, and its schedule stays as it was.` };
11542
+ }
11543
+ function when2(task) {
11544
+ 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";
11545
+ }
11546
+
11398
11547
  // node_modules/@rackbops/styles/rackbops-noir/bundle.css
11399
11548
  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
11549
  box-sizing: border-box;
@@ -12739,7 +12888,7 @@ function loginPage(base, input) {
12739
12888
  </section>`
12740
12889
  });
12741
12890
  }
12742
- function when2(entry, viewer, now) {
12891
+ function when3(entry, viewer, now) {
12743
12892
  return entry.next ? formatInstant(entry.next.dueAt, viewer.timeZone, now) : "nothing due";
12744
12893
  }
12745
12894
  function cadence(entry, viewer, now) {
@@ -12762,7 +12911,7 @@ function tasksPage(v, data, now, flash = null, research = false) {
12762
12911
  const rows = data.entries.map((e) => html`<tr>
12763
12912
  <td><a class="rb-link" href="${v.base}/tasks/${e.task.id}">${e.task.title}</a></td>
12764
12913
  <td>${e.from ? name3(e.from) : "you"}</td>
12765
- <td>${when2(e, v.user, now)}</td>
12914
+ <td>${when3(e, v.user, now)}</td>
12766
12915
  <td>${cadence(e, v.user, now)}</td>
12767
12916
  </tr>`);
12768
12917
  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">
@@ -13291,6 +13440,31 @@ function editValues(d, user, task) {
13291
13440
  return { name: task.title, hours: String(every), drop: String(c.dropPercent ?? 10), baseline: c.baseline ?? "last" };
13292
13441
  }
13293
13442
 
13443
+ // plugins/tracker/src/web/task-version.ts
13444
+ function taskVersion(task) {
13445
+ const owned = { title: task.title, config: task.config, schedule: task.schedule, status: task.status, capabilities: task.capabilities };
13446
+ return sha256(canonicalJson(owned)).slice(0, 16);
13447
+ }
13448
+ function canonicalJson(value) {
13449
+ if (Array.isArray(value))
13450
+ return `[${value.map(canonicalJson).join(",")}]`;
13451
+ if (value !== null && typeof value === "object") {
13452
+ const o = value;
13453
+ return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`).join(",")}}`;
13454
+ }
13455
+ return JSON.stringify(value);
13456
+ }
13457
+ function etag(version) {
13458
+ return `"${version}"`;
13459
+ }
13460
+ function ifMatchVersion(header) {
13461
+ if (header === null)
13462
+ return null;
13463
+ const m = /^\s*"([0-9a-f]{16})"\s*$/.exec(header);
13464
+ return m?.[1] ?? null;
13465
+ }
13466
+ 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.";
13467
+
13294
13468
  // plugins/tracker/src/web/editor.ts
13295
13469
  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
13470
  var GONE = NO_LONGER_LISTED;
@@ -13298,7 +13472,14 @@ var writer = (e) => ({ d: e.d, queue: e.queue, reading: e.reading, userId: e.v.u
13298
13472
  function asUser(w, fn) {
13299
13473
  return w.queue(async () => {
13300
13474
  const user = await w.d.store.getUser(w.userId);
13301
- return user ? fn(user) : { ok: false, error: GONE };
13475
+ if (!user)
13476
+ return { ok: false, error: GONE };
13477
+ if (w.ifMatch) {
13478
+ const task = await w.d.store.getTask(w.ifMatch.taskId);
13479
+ if (!task || taskVersion(task) !== w.ifMatch.version)
13480
+ return { ok: false, error: VERSION_MISMATCH };
13481
+ }
13482
+ return fn(user);
13302
13483
  });
13303
13484
  }
13304
13485
  var WEB_READ_MS = 8000;
@@ -13391,6 +13572,9 @@ function saveEdit(w, task, form) {
13391
13572
  return asUser(w, (u) => editWant(w.d, u, task.id, wantEdit(form)));
13392
13573
  return asUser(w, (u) => editPrice(w.d, u, task.id, priceEdit(form)));
13393
13574
  }
13575
+ function runOn(w, taskId) {
13576
+ return asUser(w, (u) => runNow(w.d, u, taskId));
13577
+ }
13394
13578
  function actOn(w, taskId, action) {
13395
13579
  const act = action === "pause" ? pauseTask : action === "resume" ? resumeTask : deleteTask;
13396
13580
  return asUser(w, (u) => act(w.d, u, taskId));
@@ -13555,12 +13739,16 @@ function refusal(error) {
13555
13739
  return problem(503, "slow_page", error, { "Retry-After": "10" });
13556
13740
  if (error === NOT_EDITABLE)
13557
13741
  return problem(409, "conflict", error);
13742
+ if (error === VERSION_MISMATCH)
13743
+ return problem(412, "version_mismatch", error);
13558
13744
  if (error === RESEARCH_OFF || error === SCOUT_OFF)
13559
13745
  return problem(503, "unavailable", error);
13560
13746
  if (error === FINISHED || /^That task is [a-z]+, not [a-z]+\.$/.test(error))
13561
13747
  return problem(409, "conflict", error);
13562
- if (/^You already (have|track|watch for) [0-9]+ /.test(error))
13748
+ if (/^You already (have|track|watch for|ran) [0-9]+ /.test(error))
13563
13749
  return problem(409, "limit_reached", error);
13750
+ if (error.startsWith(NOT_NOW))
13751
+ return problem(409, "cannot_run_now", error);
13564
13752
  if (error === INBOX_OFF || error === JUDGE_OFF)
13565
13753
  return problem(503, "unavailable", error);
13566
13754
  if (/ (is|are) not available on this bot\.$/.test(error))
@@ -13603,6 +13791,7 @@ async function taskJson(d, user, task) {
13603
13791
  type: task.type,
13604
13792
  title: task.title,
13605
13793
  status: task.status === "archived" ? "deleted" : task.status,
13794
+ version: taskVersion(task),
13606
13795
  cadence: task.schedule ? describeSchedule(task.schedule, user, user.timeZone, d.clock.now()) : "no schedule",
13607
13796
  nextAt: await nextAt(d, task),
13608
13797
  createdAt: task.createdAt,
@@ -13621,12 +13810,74 @@ function watchJson(c) {
13621
13810
  ...site ? { site, ...c.search !== undefined ? { search: c.search } : {}, ...c.game !== undefined ? { game: c.game } : {} } : {}
13622
13811
  };
13623
13812
  }
13624
- async function listAnswer(d, user) {
13625
- const tasks = (await d.store.listTasks({ ownerId: user.id })).filter((t) => t.status !== "archived");
13813
+ var MAX_PAGE = 200;
13814
+ var cursorOf = (taskId) => Buffer.from(taskId).toString("base64url");
13815
+ var CURSOR = /^[A-Za-z0-9_-]{1,64}$/;
13816
+ async function listAnswer(d, user, query = new URLSearchParams) {
13817
+ const rawLimit = query.get("limit");
13818
+ const rawCursor = query.get("cursor");
13819
+ for (const key of ["limit", "cursor"]) {
13820
+ if (query.getAll(key).length > 1)
13821
+ return problem(400, "invalid", `\`${key}\` is given more than once.`);
13822
+ }
13823
+ let limit = null;
13824
+ if (rawLimit !== null) {
13825
+ limit = /^[0-9]{1,4}$/.test(rawLimit) ? Number(rawLimit) : 0;
13826
+ if (limit < 1 || limit > MAX_PAGE)
13827
+ return problem(400, "invalid", `\`limit\` is a whole number from 1 to ${MAX_PAGE}.`);
13828
+ }
13829
+ if (rawCursor !== null && limit === null)
13830
+ return problem(400, "invalid", "`cursor` goes with `limit`.");
13831
+ const all = await d.store.listTasks({ ownerId: user.id });
13832
+ let start = 0;
13833
+ if (rawCursor !== null) {
13834
+ const at = CURSOR.test(rawCursor) ? all.findIndex((t) => cursorOf(t.id) === rawCursor) : -1;
13835
+ if (at < 0)
13836
+ return problem(400, "invalid_cursor", "That cursor is not one this API gave you: start again without it.");
13837
+ start = at + 1;
13838
+ }
13626
13839
  const out = [];
13627
- for (const t of tasks)
13840
+ let last = null;
13841
+ let more = false;
13842
+ for (let i = start;i < all.length; i++) {
13843
+ const t = all[i];
13844
+ if (t.status === "archived")
13845
+ continue;
13846
+ if (limit !== null && out.length === limit) {
13847
+ more = true;
13848
+ break;
13849
+ }
13628
13850
  out.push(await taskJson(d, user, t));
13629
- return { status: 200, body: { tasks: out } };
13851
+ last = t;
13852
+ }
13853
+ return { status: 200, body: { tasks: out, ...limit !== null ? { nextCursor: more && last ? cursorOf(last.id) : null } : {} } };
13854
+ }
13855
+ function capabilitiesAnswer(d, user, rate) {
13856
+ return {
13857
+ status: 200,
13858
+ body: {
13859
+ types: intakeTypes(d),
13860
+ owner: { timeZone: user.timeZone, preferredHour: user.preferredHour },
13861
+ limits: {
13862
+ liveTasks: MAX_LIVE_TASKS,
13863
+ priceTrackers: MAX_PRICE_TASKS,
13864
+ researchWaiting: MAX_LIVE_RESEARCH,
13865
+ scouts: MAX_LIVE_SCOUTS,
13866
+ wantWatches: MAX_WANT_TASKS,
13867
+ pageSize: MAX_PAGE,
13868
+ requestsBurst: rate.burst,
13869
+ requestsPerSecond: rate.perSecond
13870
+ },
13871
+ sources: {
13872
+ runner: d.research === true,
13873
+ bgg: d.bgg === true,
13874
+ ebay: d.ebay === true,
13875
+ shopify: d.shopify === true,
13876
+ mail: d.mail != null,
13877
+ webArea: d.webEditor
13878
+ }
13879
+ }
13880
+ };
13630
13881
  }
13631
13882
  async function getAnswer(d, user, id) {
13632
13883
  const task = await ownTask(d, user, id);
@@ -13637,6 +13888,7 @@ async function getAnswer(d, user, id) {
13637
13888
  return notFound();
13638
13889
  return {
13639
13890
  status: 200,
13891
+ headers: { ETag: etag(taskVersion(view.task)) },
13640
13892
  body: {
13641
13893
  task: await taskJson(d, user, view.task),
13642
13894
  history: { next: view.next, runs: view.runs, earlierRuns: view.earlierRuns, changes: view.changes, earlierChanges: view.earlierChanges },
@@ -13648,10 +13900,11 @@ async function written(w, user, result, status, base) {
13648
13900
  if (!result.ok)
13649
13901
  return refusal(result.error);
13650
13902
  const task = await w.d.store.getTask(result.task.id) ?? result.task;
13903
+ const tag = { ETag: etag(taskVersion(task)) };
13651
13904
  return {
13652
13905
  status,
13653
13906
  body: { task: await taskJson(w.d, user, task), message: result.text },
13654
- ...status === 201 ? { headers: { Location: `${base}/tasks/${encodeURIComponent(task.id)}` } } : {}
13907
+ headers: status === 201 ? { ...tag, Location: `${base}/tasks/${encodeURIComponent(task.id)}` } : tag
13655
13908
  };
13656
13909
  }
13657
13910
  async function createAnswer(w, user, body, base) {
@@ -13668,6 +13921,9 @@ async function createAnswer(w, user, body, base) {
13668
13921
  return form;
13669
13922
  return written(w, user, await makeTask(w, type, form), 201, base);
13670
13923
  }
13924
+ function bound(w, task) {
13925
+ return w.ifMatch ? { ...w, ifMatch: { taskId: task.id, version: w.ifMatch.version } } : w;
13926
+ }
13671
13927
  async function editAnswer(w, user, id, body, base) {
13672
13928
  const task = await editableTask(w.d, user, id);
13673
13929
  if (!task)
@@ -13675,17 +13931,27 @@ async function editAnswer(w, user, id, body, base) {
13675
13931
  const form = asFields(body, task.type, "edit");
13676
13932
  if (!(form instanceof URLSearchParams))
13677
13933
  return form;
13678
- return written(w, user, await saveEdit(w, task, form), 200, base);
13934
+ return written(w, user, await saveEdit(bound(w, task), task, form), 200, base);
13935
+ }
13936
+ async function runNowAnswer(w, user, id) {
13937
+ const task = await ownTask(w.d, user, id);
13938
+ if (!task || task.status === "archived")
13939
+ return notFound();
13940
+ const done = await runOn(w, task.id);
13941
+ if (!done.ok)
13942
+ return refusal(done.error);
13943
+ const after = await w.d.store.getTask(task.id) ?? task;
13944
+ return { status: 202, headers: { ETag: etag(taskVersion(after)) }, body: { task: await taskJson(w.d, user, after), message: done.text } };
13679
13945
  }
13680
13946
  async function actAnswer(w, user, id, action) {
13681
13947
  const task = await ownTask(w.d, user, id);
13682
13948
  if (!task || task.status === "archived")
13683
13949
  return notFound();
13684
- const done = await actOn(w, task.id, action);
13950
+ const done = await actOn(bound(w, task), task.id, action);
13685
13951
  if (!done.ok)
13686
13952
  return refusal(done.error);
13687
13953
  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 } };
13954
+ return { status: 200, headers: { ETag: etag(taskVersion(after)) }, body: { task: await taskJson(w.d, user, after), message: done.text } };
13689
13955
  }
13690
13956
 
13691
13957
  // plugins/tracker/src/intake.ts
@@ -14170,7 +14436,7 @@ var MAX_TOKENS_PER_PERSON = 10;
14170
14436
  var MAX_TOKEN_NAME = 50;
14171
14437
  var TOKEN_EXPIRY_DAYS = [30, 90, 365];
14172
14438
  var DEFAULT_EXPIRY_DAYS = 90;
14173
- var DAY_MS4 = 24 * 60 * 60 * 1000;
14439
+ var DAY_MS5 = 24 * 60 * 60 * 1000;
14174
14440
  var TOUCH_EVERY_MS = 60 * 1000;
14175
14441
  function isApiTokenShape(value) {
14176
14442
  return /^trk_[A-Za-z0-9_-]{43}$/.test(value);
@@ -14222,7 +14488,7 @@ class ApiTokens {
14222
14488
  return { ok: false, error: `You already have ${MAX_TOKENS_PER_PERSON} API tokens, the most one person may. Revoke one first.` };
14223
14489
  }
14224
14490
  const token = `${TOKEN_PREFIX}${randomToken()}`;
14225
- const expiresAt = new Date(now.getTime() + input.days * DAY_MS4).toISOString();
14491
+ const expiresAt = new Date(now.getTime() + input.days * DAY_MS5).toISOString();
14226
14492
  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
14493
  VALUES (?, ?, ?, ?, ?, NULL, ?) RETURNING ${COLUMNS}`).get(sha256(token), userId, name, at, expiresAt, memberCheckedAt);
14228
14494
  return { ok: true, token, row: toToken(r) };
@@ -14798,7 +15064,7 @@ function startUsrRun(a, usr, me, kind) {
14798
15064
  const current = usrLinkRuns.get(usr);
14799
15065
  if (current?.running)
14800
15066
  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)));
15067
+ const waiting = a.d.roster.people().filter((p) => p.discordId !== null && wanted2(kind, usr.links.subjectOf(p.id)));
14802
15068
  if (waiting.length === 0)
14803
15069
  return { ok: true, text: RUN_WORDS[kind].none };
14804
15070
  const run = { kind, running: true, done: 0, total: waiting.length, result: null };
@@ -14816,7 +15082,7 @@ function startUsrRun(a, usr, me, kind) {
14816
15082
  function personsOwn400(err) {
14817
15083
  return err.status === 400 && /^(discord_user_id|display_name)\b/.test(err.reason);
14818
15084
  }
14819
- function wanted(kind, subject) {
15085
+ function wanted2(kind, subject) {
14820
15086
  return kind === "link" ? subject === null : subject !== null;
14821
15087
  }
14822
15088
  async function usrRun(a, usr, invoker, by, guildId, ids, run) {
@@ -14841,7 +15107,7 @@ async function usrRun(a, usr, invoker, by, guildId, ids, run) {
14841
15107
  const notAdmin = () => end(false, `${words.stopped}: ${by}, who started it, is no longer an admin here. ${soFar()}.`);
14842
15108
  const stillWaiting = (id) => fresh(a, true, async () => {
14843
15109
  const p = await a.d.store.getUser(id);
14844
- return p && p.discordId !== null && wanted(run.kind, usr.links.subjectOf(id)) ? p : "skip";
15110
+ return p && p.discordId !== null && wanted2(run.kind, usr.links.subjectOf(id)) ? p : "skip";
14845
15111
  });
14846
15112
  for (const id of ids) {
14847
15113
  const first = await stillWaiting(id);
@@ -14929,6 +15195,46 @@ async function forgetRoute(a, method, form) {
14929
15195
  return redirect(`${base}/signin?forgotten=1`, [a.signOutCookie]);
14930
15196
  }
14931
15197
 
15198
+ // plugins/tracker/src/web/api-delegation.ts
15199
+ var DELEGATES_ENV = "TRACKER_API_DELEGATES";
15200
+ var DELEGATES_FORMAT = "^[0-9]+(,[0-9]+)*$";
15201
+ function parseDelegates(env, usrOn) {
15202
+ const raw = env[DELEGATES_ENV]?.trim();
15203
+ if (raw === undefined || raw === "")
15204
+ return new Set;
15205
+ if (!usrOn)
15206
+ throw new Error(`${DELEGATES_ENV} needs TRACKER_USR_URL: delegation tokens come from usr`);
15207
+ if (!new RegExp(DELEGATES_FORMAT).test(raw))
15208
+ throw new Error(`${DELEGATES_ENV} is usr API key ids separated by commas, such as 12,31`);
15209
+ return new Set(raw.split(",").map((id) => `key:${Number(id)}`));
15210
+ }
15211
+ function operationOf(r, method) {
15212
+ switch (r.kind) {
15213
+ case "me":
15214
+ return method === "GET" ? "whoami" : null;
15215
+ case "types":
15216
+ return method === "GET" ? "list_task_types" : null;
15217
+ case "capabilities":
15218
+ return method === "GET" ? "get_capabilities" : null;
15219
+ case "validate":
15220
+ return method === "POST" ? "validate_task" : null;
15221
+ case "tasks":
15222
+ return method === "GET" ? "list_tasks" : method === "POST" ? "create_task" : null;
15223
+ case "task":
15224
+ return method === "GET" ? "get_task" : method === "PATCH" ? "update_task" : method === "DELETE" ? "delete_task" : null;
15225
+ case "act":
15226
+ return method === "POST" ? r.action === "pause" ? "pause_task" : "resume_task" : null;
15227
+ case "run":
15228
+ return method === "POST" ? "run_task" : null;
15229
+ case "listings":
15230
+ return method === "POST" ? "send_listings" : null;
15231
+ case "sources":
15232
+ return method === "POST" ? "send_sources" : null;
15233
+ case "ready":
15234
+ return method === "POST" ? "sources_ready" : null;
15235
+ }
15236
+ }
15237
+
14932
15238
  // plugins/tracker/src/web/api-listings.ts
14933
15239
  var EBAY_DOMAINS = [
14934
15240
  "ebay.com",
@@ -15138,6 +15444,148 @@ async function fullerPass(w, user, task, owner, followUp, held, now) {
15138
15444
  };
15139
15445
  }
15140
15446
 
15447
+ // plugins/tracker/src/web/api-idempotency.ts
15448
+ import { randomBytes as randomBytes3 } from "crypto";
15449
+ var KEEP_MS = 24 * 60 * 60 * 1000;
15450
+ var PENDING_MS = 2 * 60 * 1000;
15451
+ function isIdempotencyKey(value) {
15452
+ return /^[A-Za-z0-9_.:-]{8,128}$/.test(value);
15453
+ }
15454
+ 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.";
15455
+
15456
+ class ApiIdempotency {
15457
+ db;
15458
+ constructor(db) {
15459
+ this.db = db;
15460
+ }
15461
+ begin(userId, key, requestHash, now) {
15462
+ const at = now.toISOString();
15463
+ return this.db.transaction(() => {
15464
+ this.db.query("DELETE FROM api_idempotency WHERE created_at <= ?").run(new Date(now.getTime() - KEEP_MS).toISOString());
15465
+ const row = this.db.query("SELECT request_hash, status, answer, created_at FROM api_idempotency WHERE user_id = ? AND key = ?").get(userId, key);
15466
+ if (row !== null) {
15467
+ const stale = row.status === null && Date.parse(row.created_at) <= now.getTime() - PENDING_MS;
15468
+ if (!stale) {
15469
+ if (row.request_hash !== requestHash)
15470
+ return { kind: "conflict" };
15471
+ if (row.status === null || row.answer === null)
15472
+ return { kind: "in_progress" };
15473
+ const kept = JSON.parse(row.answer);
15474
+ return { kind: "replay", answer: { status: row.status, body: kept.body, headers: kept.headers } };
15475
+ }
15476
+ this.db.query("DELETE FROM api_idempotency WHERE user_id = ? AND key = ?").run(userId, key);
15477
+ }
15478
+ const claim = randomBytes3(16).toString("hex");
15479
+ 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);
15480
+ return { kind: "new", claim };
15481
+ })();
15482
+ }
15483
+ finish(userId, key, claim, answer) {
15484
+ if (answer.status >= 200 && answer.status < 300)
15485
+ this.keep(userId, key, claim, answer);
15486
+ else
15487
+ this.abandon(userId, key, claim);
15488
+ }
15489
+ fail(userId, key, claim) {
15490
+ this.keep(userId, key, claim, { status: 500, body: { error: { code: "outcome_unknown", message: OUTCOME_UNKNOWN } }, headers: {} });
15491
+ }
15492
+ abandon(userId, key, claim) {
15493
+ this.db.query("DELETE FROM api_idempotency WHERE user_id = ? AND key = ? AND claim = ? AND status IS NULL").run(userId, key, claim);
15494
+ }
15495
+ keep(userId, key, claim, answer) {
15496
+ 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);
15497
+ }
15498
+ }
15499
+
15500
+ // plugins/tracker/src/web/api-validate.ts
15501
+ function firstDueAfter2(schedule, now) {
15502
+ if (schedule.kind === "once")
15503
+ return new Date(0);
15504
+ if (schedule.kind === "poll")
15505
+ return new Date(Date.parse(schedule.start) - 1);
15506
+ return now;
15507
+ }
15508
+ function runs(schedule, user, now, renewal) {
15509
+ const ctx = { zone: user.timeZone, preferredHour: user.preferredHour };
15510
+ if (renewal) {
15511
+ const ask = nextDue(schedule, new Date(0), ctx);
15512
+ if (ask && ask.getTime() <= now.getTime())
15513
+ return { first: ask, second: nextDue(schedule, now, ctx) };
15514
+ }
15515
+ const first = nextDue(schedule, firstDueAfter2(schedule, now), ctx);
15516
+ return { first, second: first && schedule.kind !== "once" ? nextDue(schedule, first, ctx) : null };
15517
+ }
15518
+ async function validateAnswer(d, user, body) {
15519
+ const type = body.type;
15520
+ if (typeof type !== "string" || !NEW_TYPES.includes(type)) {
15521
+ return problem(400, "invalid", `\`type\` is one of ${offeredTypes(d).join(", ")}.`);
15522
+ }
15523
+ if (type === "research" && !d.research)
15524
+ return refusal(RESEARCH_OFF);
15525
+ if (type === "scout" && (!d.types.scout || !d.research))
15526
+ return refusal(SCOUT_OFF);
15527
+ if (type === "reminder" && !d.types.reminder)
15528
+ return refusal("Reminders are not available on this bot.");
15529
+ if (type === "renewal" && !d.types.renewal)
15530
+ return refusal("Renewals are not available on this bot.");
15531
+ const form = asFields(body, type, "new");
15532
+ if (!(form instanceof URLSearchParams))
15533
+ return form;
15534
+ const now = d.clock.now();
15535
+ let schedule = null;
15536
+ if (type === "reminder") {
15537
+ const plan = reminderPlan(d, user, reminderInput(form));
15538
+ if (!plan.ok)
15539
+ return refusal(plan.error);
15540
+ schedule = plan.schedule;
15541
+ } else if (type === "renewal") {
15542
+ const plan = renewalPlan(d, user, renewalInput(form));
15543
+ if (!plan.ok)
15544
+ return refusal(plan.error);
15545
+ schedule = plan.schedule;
15546
+ } else if (type === "scout") {
15547
+ const plan = scoutPlan(scoutInput(form));
15548
+ if (!plan.ok)
15549
+ return refusal(plan.error);
15550
+ schedule = scoutSchedule(plan.every, user.timeZone, now);
15551
+ }
15552
+ const capped = await liveTaskCap(d, user);
15553
+ if (capped)
15554
+ return refusal(capped);
15555
+ if (type === "scout" && await liveScouts(d, user) >= MAX_LIVE_SCOUTS) {
15556
+ return refusal(SCOUT_CAP);
15557
+ }
15558
+ if (schedule === null) {
15559
+ return {
15560
+ status: 200,
15561
+ body: {
15562
+ valid: true,
15563
+ type,
15564
+ checked: "fields",
15565
+ 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.`
15566
+ }
15567
+ };
15568
+ }
15569
+ const problems = scheduleProblems(schedule);
15570
+ if (problems.length > 0)
15571
+ return refusal(`That schedule does not work: ${problems.join("; ")}.`);
15572
+ const { first, second: then } = runs(schedule, user, now, type === "renewal");
15573
+ const cadence = describeSchedule(schedule, user, user.timeZone, now);
15574
+ return {
15575
+ status: 200,
15576
+ body: {
15577
+ valid: true,
15578
+ type,
15579
+ checked: "full",
15580
+ timeZone: user.timeZone,
15581
+ cadence,
15582
+ firstAt: first?.toISOString() ?? null,
15583
+ thenAt: then?.toISOString() ?? null,
15584
+ 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.`
15585
+ }
15586
+ };
15587
+ }
15588
+
15141
15589
  // plugins/tracker/src/web/api.ts
15142
15590
  var API_PREFIX = "/api/v1";
15143
15591
  var MAX_API_BYTES = 16 * 1024;
@@ -15193,6 +15641,7 @@ function bearer(header) {
15193
15641
  return m?.[1] ?? null;
15194
15642
  }
15195
15643
  var ACT = /^\/tasks\/([^/]+)\/(pause|resume)$/;
15644
+ var RUN = /^\/tasks\/([^/]+)\/run$/;
15196
15645
  var LISTINGS = /^\/tasks\/([^/]+)\/listings$/;
15197
15646
  var SOURCES = /^\/tasks\/([^/]+)\/sources(\/ready)?$/;
15198
15647
  var ONE = /^\/tasks\/([^/]+)$/;
@@ -15208,11 +15657,18 @@ function route(rest) {
15208
15657
  return { kind: "me" };
15209
15658
  if (rest === "/types")
15210
15659
  return { kind: "types" };
15660
+ if (rest === "/capabilities")
15661
+ return { kind: "capabilities" };
15211
15662
  if (rest === "/tasks")
15212
15663
  return { kind: "tasks" };
15664
+ if (rest === "/tasks/validate")
15665
+ return { kind: "validate" };
15213
15666
  const act = ACT.exec(rest);
15214
15667
  if (act)
15215
15668
  return { kind: "act", id: segment(act[1]), action: act[2] };
15669
+ const run = RUN.exec(rest);
15670
+ if (run)
15671
+ return { kind: "run", id: segment(run[1]) };
15216
15672
  const listings = LISTINGS.exec(rest);
15217
15673
  if (listings)
15218
15674
  return { kind: "listings", id: segment(listings[1]) };
@@ -15227,7 +15683,7 @@ function allowedMethods(r) {
15227
15683
  return "GET, POST";
15228
15684
  if (r.kind === "task")
15229
15685
  return "GET, PATCH, DELETE";
15230
- if (r.kind === "act" || r.kind === "listings" || r.kind === "sources" || r.kind === "ready")
15686
+ if (r.kind === "validate" || r.kind === "act" || r.kind === "run" || r.kind === "listings" || r.kind === "sources" || r.kind === "ready")
15231
15687
  return "POST";
15232
15688
  return "GET";
15233
15689
  }
@@ -15258,6 +15714,10 @@ async function authenticate(w, request) {
15258
15714
  const invalid = problem(401, "invalid_token", "That token is unknown, revoked or expired.", { "WWW-Authenticate": `${CHALLENGE}, error="invalid_token"` });
15259
15715
  const sent = bearer(header);
15260
15716
  const now = d.clock.now();
15717
+ const delegates = d.usr?.delegates;
15718
+ if (sent !== null && !sent.startsWith(TOKEN_PREFIX) && d.usr && delegates && delegates.size > 0) {
15719
+ return authenticateDelegation(w, d.usr, delegates, sent, invalid);
15720
+ }
15261
15721
  const token = sent === null ? null : d.apiTokens.find(sent, now);
15262
15722
  if (!token) {
15263
15723
  const waitFailed = w.failed.take(FAILED_KEY, now.getTime());
@@ -15282,7 +15742,61 @@ async function authenticate(w, request) {
15282
15742
  if (!current)
15283
15743
  return no(invalid);
15284
15744
  d.apiTokens.touch(current, d.clock.now());
15285
- return { ok: true, token: current, user };
15745
+ return { ok: true, via: { kind: "token", token: current }, user };
15746
+ }
15747
+ async function authenticateDelegation(w, usr, delegates, sent, invalid) {
15748
+ const { d } = w;
15749
+ const no = (answer) => ({ ok: false, answer });
15750
+ const failed = () => {
15751
+ const wait = w.failed.take(FAILED_KEY, d.clock.now().getTime());
15752
+ return no(wait > 0 ? problem(429, "rate_limited", `Too many requests with bad tokens; try again in ${wait} s.`, { "Retry-After": String(wait) }) : invalid);
15753
+ };
15754
+ const checked = await usr.verifier.verifyDelegation(sent, usr.app);
15755
+ if (checked === "unavailable")
15756
+ 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" }));
15757
+ if (checked === "invalid" || !delegates.has(checked.actor))
15758
+ return failed();
15759
+ const wait = w.limiter.take(`dlg:${checked.sub}`, d.clock.now().getTime());
15760
+ if (wait > 0)
15761
+ return no(problem(429, "rate_limited", `Too many requests for this person; try again in ${wait} s.`, { "Retry-After": String(wait) }));
15762
+ const userId = usr.links.userOfSubject(checked.sub);
15763
+ const user = userId ? await d.store.getUser(userId) : null;
15764
+ if (!user || !d.admissions.isRegistered(user.id))
15765
+ return failed();
15766
+ const found = await w.recheck(user, w.confirmedAt(user.id));
15767
+ if (found === "not-member")
15768
+ 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."));
15769
+ if (found === "unknown")
15770
+ return no(problem(503, "membership_unknown", MEMBERSHIP_UNKNOWN2, { "Retry-After": "60" }));
15771
+ return { ok: true, via: { kind: "delegation", delegation: checked }, user };
15772
+ }
15773
+ async function write(writer, user, r, method, body, base) {
15774
+ if (method === "DELETE")
15775
+ return actAnswer(writer, user, r.id, "delete");
15776
+ if (r.kind === "tasks")
15777
+ return createAnswer(writer, user, body, base);
15778
+ if (r.kind === "task")
15779
+ return editAnswer(writer, user, r.id, body, base);
15780
+ if (r.kind === "act") {
15781
+ if (Object.keys(body).length > 0)
15782
+ return problem(400, "unknown_field", `\`${r.action}\` takes no fields: send {}.`);
15783
+ return actAnswer(writer, user, r.id, r.action);
15784
+ }
15785
+ if (r.kind === "run") {
15786
+ if (Object.keys(body).length > 0)
15787
+ return problem(400, "unknown_field", "`run` takes no fields: send {}.");
15788
+ return runNowAnswer(writer, user, r.id);
15789
+ }
15790
+ if (r.kind === "listings")
15791
+ return listingsAnswer(writer, user, r.id, body);
15792
+ if (r.kind === "sources")
15793
+ return sourcesAnswer(writer, user, r.id, body);
15794
+ if (r.kind === "ready") {
15795
+ if (Object.keys(body).length > 0)
15796
+ return problem(400, "unknown_field", "`ready` takes no fields: send {}.");
15797
+ return sourcesReadyAnswer(writer, user, r.id);
15798
+ }
15799
+ return problem(405, "method_not_allowed", "Not a write.");
15286
15800
  }
15287
15801
  async function handleApi(w, request, path) {
15288
15802
  if (request.headers.get("origin") !== null)
@@ -15298,45 +15812,83 @@ async function handleApi(w, request, path) {
15298
15812
  const method = request.method;
15299
15813
  if (!allowed.split(", ").includes(method))
15300
15814
  return json2(problem(405, "method_not_allowed", `Use ${allowed}.`, { Allow: allowed }));
15815
+ if (auth.via.kind === "delegation") {
15816
+ const operation = operationOf(r, method);
15817
+ if (operation === null)
15818
+ return json2(problem(403, "insufficient_scope", "No delegation token can do this."));
15819
+ const needed = `${w.d.usr?.app}:${operation}`;
15820
+ if (!auth.via.delegation.scopes.has(needed)) {
15821
+ return json2(problem(403, "insufficient_scope", `That token does not allow this: it needs ${needed}.`, { "WWW-Authenticate": `${CHALLENGE}, error="insufficient_scope", scope="${needed}"` }));
15822
+ }
15823
+ if (method !== "GET")
15824
+ w.d.log.info(`api: ${operation} for ${user.id} by usr ${auth.via.delegation.actor} (token ${auth.via.delegation.jti})`);
15825
+ }
15301
15826
  const writer = { d: w.d, queue: w.queue, reading: w.reading, userId: user.id, arrived: w.arrived };
15302
15827
  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);
15828
+ if (r.kind === "validate") {
15829
+ if (request.headers.get("if-match") !== null)
15830
+ return json2(problem(400, "if_match_unsupported", "If-Match goes only with PATCH or DELETE of a task, or its pause or resume."));
15831
+ const read = await readJson(request);
15305
15832
  if (!read.ok)
15306
15833
  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));
15834
+ return json2(await validateAnswer(w.d, user, read.body));
15835
+ }
15836
+ if (method === "POST" || method === "PATCH" || method === "DELETE") {
15837
+ const ifMatchRaw = request.headers.get("if-match");
15838
+ if (ifMatchRaw !== null) {
15839
+ const onOneTask = r.kind === "task" && method !== "POST" || r.kind === "act";
15840
+ if (!onOneTask)
15841
+ return json2(problem(400, "if_match_unsupported", "If-Match goes only with PATCH or DELETE of a task, or its pause or resume."));
15842
+ const version = ifMatchVersion(ifMatchRaw);
15843
+ if (version === null)
15844
+ return json2(problem(400, "invalid_if_match", 'If-Match is one task version in quotes, as its ETag gives it: "0123456789abcdef".'));
15845
+ writer.ifMatch = { taskId: r.id.trim(), version };
15846
+ }
15847
+ const key = request.headers.get("idempotency-key");
15848
+ if (key !== null && !isIdempotencyKey(key))
15849
+ return json2(problem(400, "invalid_idempotency_key", "Idempotency-Key is 8 to 128 letters, digits, '-', '_', '.' or ':'; a UUID will do."));
15850
+ let body = {};
15851
+ if (method !== "DELETE") {
15852
+ const read = await readJson(request, r.kind === "listings" ? MAX_LISTINGS_BYTES : r.kind === "sources" ? MAX_SOURCES_BYTES : MAX_API_BYTES);
15853
+ if (!read.ok)
15854
+ return json2(read.answer);
15855
+ body = read.body;
15316
15856
  }
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));
15857
+ if (key === null)
15858
+ return json2(await write(writer, user, r, method, body, base));
15859
+ const hash = sha256(`${method} ${path}
15860
+ ${writer.ifMatch?.version ?? ""}
15861
+ ${canonicalJson(body)}`);
15862
+ const begun = w.d.apiIdempotency.begin(user.id, key, hash, w.d.clock.now());
15863
+ if (begun.kind === "conflict")
15864
+ return json2(problem(409, "idempotency_conflict", "That Idempotency-Key was used for a different request; use a new key for a new request."));
15865
+ if (begun.kind === "in_progress")
15866
+ return json2(problem(409, "idempotency_in_progress", "A request with that Idempotency-Key is still running; try again in a moment.", { "Retry-After": "1" }));
15867
+ if (begun.kind === "replay")
15868
+ return json2({ status: begun.answer.status, body: begun.answer.body, headers: { ...begun.answer.headers, "Idempotency-Replayed": "true" } });
15869
+ let answer;
15870
+ try {
15871
+ answer = await write(writer, user, r, method, body, base);
15872
+ } catch (err) {
15873
+ w.d.apiIdempotency.fail(user.id, key, begun.claim);
15874
+ throw err;
15325
15875
  }
15876
+ w.d.apiIdempotency.finish(user.id, key, begun.claim, { status: answer.status, body: answer.body, headers: answer.headers ?? {} });
15877
+ return json2(answer);
15326
15878
  }
15327
- if (method === "DELETE" && r.kind === "task")
15328
- return json2(await actAnswer(writer, user, r.id, "delete"));
15329
15879
  if (r.kind === "types")
15330
15880
  return json2(typesAnswer(w.d));
15331
15881
  if (r.kind === "tasks")
15332
- return json2(await listAnswer(w.d, user));
15882
+ return json2(await listAnswer(w.d, user, new URL(request.url).searchParams));
15883
+ if (r.kind === "capabilities")
15884
+ return json2(capabilitiesAnswer(w.d, user, { burst: RATE_BURST, perSecond: RATE_PER_SECOND }));
15333
15885
  if (r.kind === "task")
15334
15886
  return json2(await getAnswer(w.d, user, r.id));
15335
15887
  return json2({
15336
15888
  status: 200,
15337
15889
  body: {
15338
15890
  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 }
15891
+ ...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
15892
  }
15341
15893
  });
15342
15894
  }
@@ -15544,6 +16096,9 @@ var JWKS_RETRY_MS = 30 * 1000;
15544
16096
  var JWKS_TIMEOUT_MS = 5000;
15545
16097
  var MAX_TOKEN = 8192;
15546
16098
  var MAX_JWKS = 64 * 1024;
16099
+ var MAX_DELEGATION_MS = 30 * 60 * 1000;
16100
+ var DELEGATION_SKEW_MS = 60 * 1000;
16101
+ var JTI = /^[A-Za-z0-9_-]{1,64}$/;
15547
16102
  function decode(part) {
15548
16103
  try {
15549
16104
  return JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
@@ -15556,38 +16111,70 @@ class UsrVerifier {
15556
16111
  o;
15557
16112
  keys = [];
15558
16113
  fetchedAt = Number.NEGATIVE_INFINITY;
16114
+ loadFailed = false;
15559
16115
  pending = null;
15560
16116
  constructor(o) {
15561
16117
  this.o = o;
15562
16118
  }
15563
16119
  async verify(token) {
15564
- if (!token || token.length > MAX_TOKEN)
16120
+ const signed = await this.signed(token);
16121
+ if (typeof signed === "string" || signed.header.typ === "dlg+jwt")
16122
+ return null;
16123
+ const claims = signed.claims;
16124
+ if (claims.iss !== "usr" || claims.act !== undefined)
16125
+ return null;
16126
+ if (typeof claims.exp !== "number" || claims.exp * 1000 <= this.o.now())
16127
+ return null;
16128
+ if (typeof claims.sub !== "string" || claims.sub === "")
15565
16129
  return null;
16130
+ const roles = Array.isArray(claims.roles) ? claims.roles.filter((r) => typeof r === "string") : [];
16131
+ return { sub: claims.sub, roles };
16132
+ }
16133
+ async verifyDelegation(token, audience) {
16134
+ const signed = await this.signed(token);
16135
+ if (typeof signed === "string")
16136
+ return signed;
16137
+ if (signed.header.typ !== "dlg+jwt")
16138
+ return "invalid";
16139
+ const c = signed.claims;
16140
+ if (c.iss !== "usr" || c.aud !== audience)
16141
+ return "invalid";
16142
+ if (typeof c.sub !== "string" || c.sub === "" || typeof c.scope !== "string" || typeof c.jti !== "string" || !JTI.test(c.jti))
16143
+ return "invalid";
16144
+ const actor = c.act?.sub;
16145
+ if (typeof actor !== "string" || !/^key:[0-9]+$/.test(actor))
16146
+ return "invalid";
16147
+ if (typeof c.exp !== "number")
16148
+ return "invalid";
16149
+ const expiresAt = c.exp * 1000;
16150
+ const now = this.o.now();
16151
+ if (expiresAt <= now || expiresAt - now > MAX_DELEGATION_MS + DELEGATION_SKEW_MS)
16152
+ return "invalid";
16153
+ return { sub: c.sub, actor, scopes: new Set(c.scope.split(" ").filter((x) => x !== "")), jti: c.jti, expiresAt };
16154
+ }
16155
+ async signed(token) {
16156
+ if (!token || token.length > MAX_TOKEN)
16157
+ return "invalid";
15566
16158
  const parts = token.split(".");
15567
16159
  if (parts.length !== 3)
15568
- return null;
16160
+ return "invalid";
15569
16161
  const [h, p, s] = parts;
15570
16162
  const header = decode(h);
15571
- if (!header || header.alg !== "ES256" || header.typ === "dlg+jwt" || typeof header.kid !== "string")
15572
- return null;
16163
+ if (!header || header.alg !== "ES256" || typeof header.kid !== "string")
16164
+ return "invalid";
15573
16165
  const key = await this.keyFor(header.kid, false) ?? await this.keyFor(header.kid, true);
15574
16166
  if (!key)
15575
- return null;
16167
+ return this.loadFailed ? "unavailable" : "invalid";
15576
16168
  const signature = Buffer.from(s, "base64url");
15577
16169
  if (signature.length !== 64)
15578
- return null;
16170
+ return "invalid";
15579
16171
  const ok = await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, signature, new TextEncoder().encode(`${h}.${p}`));
15580
16172
  if (!ok)
15581
- return null;
16173
+ return "invalid";
15582
16174
  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 };
16175
+ if (claims === null || typeof claims !== "object")
16176
+ return "invalid";
16177
+ return { header, claims };
15591
16178
  }
15592
16179
  async keyFor(kid, force) {
15593
16180
  const fresh = () => {
@@ -15614,6 +16201,7 @@ class UsrVerifier {
15614
16201
  }
15615
16202
  async load() {
15616
16203
  this.fetchedAt = this.o.now();
16204
+ this.loadFailed = true;
15617
16205
  try {
15618
16206
  const res = await (this.o.fetchImpl ?? fetch)(`${this.o.url}/.well-known/jwks.json`, {
15619
16207
  headers: { Accept: "application/json" },
@@ -15638,6 +16226,7 @@ class UsrVerifier {
15638
16226
  } catch {}
15639
16227
  }
15640
16228
  this.keys = keys;
16229
+ this.loadFailed = false;
15641
16230
  } catch {}
15642
16231
  }
15643
16232
  }
@@ -15731,6 +16320,7 @@ function createWebHandler(w) {
15731
16320
  const shared = pending;
15732
16321
  return lookupWithin(() => shared, MEMBER_CHECK_TIMEOUT_MS);
15733
16322
  }
16323
+ const memberConfirmed = new Map;
15734
16324
  async function recheck(d, user, checkedAt) {
15735
16325
  if (w.guildIds === null)
15736
16326
  return "ok";
@@ -15750,12 +16340,14 @@ function createWebHandler(w) {
15750
16340
  failedAt.set(user.id, d.clock.now().getTime());
15751
16341
  }
15752
16342
  if (found === "not-member") {
16343
+ memberConfirmed.delete(user.id);
15753
16344
  d.sessions.deleteForUser(user.id);
15754
16345
  d.apiTokens.deleteForUser(user.id);
15755
16346
  return "not-member";
15756
16347
  }
15757
16348
  if (found === "member") {
15758
16349
  const at = d.clock.now().toISOString();
16350
+ memberConfirmed.set(user.id, at);
15759
16351
  d.sessions.confirmMember(user.id, at);
15760
16352
  d.apiTokens.confirmMember(user.id, at);
15761
16353
  return "ok";
@@ -15905,7 +16497,7 @@ function createWebHandler(w) {
15905
16497
  const d = w.deps();
15906
16498
  if (!d)
15907
16499
  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);
16500
+ 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
16501
  }
15910
16502
  const method = request.method;
15911
16503
  if (method !== "GET" && method !== "POST")
@@ -16052,7 +16644,7 @@ function createWebHandler(w) {
16052
16644
  }
16053
16645
 
16054
16646
  // plugins/tracker/src/mail.ts
16055
- import { randomBytes as randomBytes3 } from "crypto";
16647
+ import { randomBytes as randomBytes4 } from "crypto";
16056
16648
 
16057
16649
  // plugins/tracker/src/ebay-alert.ts
16058
16650
  var MAX_ALERT_LISTINGS = MAX_LISTINGS_PER_POST;
@@ -16233,7 +16825,7 @@ class MailAddresses {
16233
16825
  const row = this.db.query("SELECT local FROM mail_addresses WHERE user_id = ?").get(userId);
16234
16826
  if (row)
16235
16827
  return `${row.local}@${this.domain}`;
16236
- const local = `m-${randomBytes3(12).toString("hex")}`;
16828
+ const local = `m-${randomBytes4(12).toString("hex")}`;
16237
16829
  this.db.query("INSERT INTO mail_addresses (user_id, local, created_at) VALUES (?, ?, ?)").run(userId, local, now.toISOString());
16238
16830
  return `${local}@${this.domain}`;
16239
16831
  }
@@ -16681,6 +17273,7 @@ function createPlugin(host, options = {}) {
16681
17273
  const webOrigin = parseWebUrl(host.env.TRACKER_WEB_URL);
16682
17274
  const cityHall = parseCityHallConfig(host.env);
16683
17275
  const usrConfig = parseUsrConfig(host.env);
17276
+ const delegates = parseDelegates(host.env, usrConfig !== null);
16684
17277
  const mailConfig = parseMailConfig(host.env);
16685
17278
  const usrClient = usrConfig ? createUsrClient({ config: usrConfig, ...options.usrFetch ? { fetchImpl: options.usrFetch } : {} }) : null;
16686
17279
  const budgetUnlimited = parseBudgetUnlimited(host.env[BUDGET_UNLIMITED_KEY]);
@@ -16962,6 +17555,8 @@ function createPlugin(host, options = {}) {
16962
17555
  host.log.warn(`execute lane is off, so /research is unavailable: ${cityHall.missing.join(", ")} not set`);
16963
17556
  if (usrConfig)
16964
17557
  host.log.info(`usr link on: ${usrConfig.url}, app ${usrConfig.app}`);
17558
+ if (delegates.size > 0)
17559
+ host.log.info(`task API takes usr delegation tokens from ${[...delegates].join(", ")}`);
16965
17560
  const openedDeps = {
16966
17561
  store: openedStore,
16967
17562
  admissions: new Admissions(opened),
@@ -16976,6 +17571,7 @@ function createPlugin(host, options = {}) {
16976
17571
  logins: new LoginLinks(opened),
16977
17572
  sessions: new Sessions(opened),
16978
17573
  apiTokens: new ApiTokens(opened),
17574
+ apiIdempotency: new ApiIdempotency(opened),
16979
17575
  roster: new Roster(opened),
16980
17576
  ceilings,
16981
17577
  budgetUnlimited,
@@ -16995,7 +17591,8 @@ function createPlugin(host, options = {}) {
16995
17591
  url: usrConfig.url,
16996
17592
  verifier: new UsrVerifier({ url: usrConfig.url, now: () => clock.now().getTime(), ...options.usrFetch ? { fetchImpl: options.usrFetch } : {} }),
16997
17593
  app: usrConfig.app,
16998
- links: new UsrLinks(opened)
17594
+ links: new UsrLinks(opened),
17595
+ delegates
16999
17596
  } : null
17000
17597
  };
17001
17598
  if (guildIds === null)