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