@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.
- package/README.md +58 -7
- package/dist/plugin.js +667 -70
- 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
|
|
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
|
|
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) =>
|
|
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 }) =>
|
|
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
|
|
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:
|
|
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
|
|
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>${
|
|
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
|
-
|
|
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
|
-
|
|
13625
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 *
|
|
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 &&
|
|
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
|
|
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 &&
|
|
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 (
|
|
15304
|
-
|
|
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
|
-
|
|
15308
|
-
|
|
15309
|
-
|
|
15310
|
-
|
|
15311
|
-
|
|
15312
|
-
|
|
15313
|
-
if (
|
|
15314
|
-
return json2(problem(400, "
|
|
15315
|
-
|
|
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 (
|
|
15318
|
-
return json2(await
|
|
15319
|
-
|
|
15320
|
-
|
|
15321
|
-
|
|
15322
|
-
|
|
15323
|
-
|
|
15324
|
-
return json2(
|
|
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
|
-
|
|
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
|
|
16160
|
+
return "invalid";
|
|
15569
16161
|
const [h, p, s] = parts;
|
|
15570
16162
|
const header = decode(h);
|
|
15571
|
-
if (!header || header.alg !== "ES256" ||
|
|
15572
|
-
return
|
|
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
|
|
16167
|
+
return this.loadFailed ? "unavailable" : "invalid";
|
|
15576
16168
|
const signature = Buffer.from(s, "base64url");
|
|
15577
16169
|
if (signature.length !== 64)
|
|
15578
|
-
return
|
|
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
|
|
16173
|
+
return "invalid";
|
|
15582
16174
|
const claims = decode(p);
|
|
15583
|
-
if (
|
|
15584
|
-
return
|
|
15585
|
-
|
|
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
|
|
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-${
|
|
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)
|