@rackbops/plugin-tracker 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +83 -10
  2. package/dist/plugin.js +2715 -244
  3. package/package.json +12 -4
package/dist/plugin.js CHANGED
@@ -67,6 +67,11 @@ async function visibleHistory(store, actor, taskId) {
67
67
  return null;
68
68
  return store.listTaskEvents(taskId);
69
69
  }
70
+ async function visibleSeries(store, actor, taskId, filter) {
71
+ if (!await visibleTask(store, actor, taskId))
72
+ return null;
73
+ return store.listSeries(taskId, filter);
74
+ }
70
75
 
71
76
  // node_modules/@rackbops/docket-core/dist/capabilities.js
72
77
  var CAPABILITIES = ["notify", "graph:write", "discord:post", "github:issue"];
@@ -1545,13 +1550,13 @@ var price = defineTaskType({
1545
1550
  ]
1546
1551
  },
1547
1552
  async run(ctx) {
1548
- const { fetch } = ctx.ports;
1549
- if (!fetch)
1553
+ const { fetch: fetch2 } = ctx.ports;
1554
+ if (!fetch2)
1550
1555
  throw new Error("the price type needs the Fetch port; the host configured none");
1551
1556
  const state = stateOf(ctx.state);
1552
1557
  let body;
1553
1558
  try {
1554
- const response = await fetch.get(ctx.config.url);
1559
+ const response = await fetch2.get(ctx.config.url);
1555
1560
  if (response.status !== 200)
1556
1561
  return miss(ctx, state, `HTTP ${response.status}`);
1557
1562
  body = response.body;
@@ -2063,6 +2068,319 @@ function resumedNotice(r) {
2063
2068
  return `I could not DM you ${PAUSE_AFTER} times in a row, so your reminders were paused. ${what} ` + "Check that you accept DMs from members of a server we share, or they will pause again.";
2064
2069
  }
2065
2070
 
2071
+ // plugins/tracker/src/fetch.ts
2072
+ import { lookup } from "dns/promises";
2073
+ import { isIP } from "net";
2074
+
2075
+ // plugins/tracker/src/page.ts
2076
+ var MAX_BLOCKS = 20;
2077
+ var MAX_METAS = 300;
2078
+ var MAX_TAG = 1000;
2079
+ var escapeLt = (s) => s.replaceAll("<", "\\u003c");
2080
+ function jsonBody(body) {
2081
+ const trimmed = body.trim();
2082
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("["))
2083
+ return null;
2084
+ try {
2085
+ return escapeLt(JSON.stringify(JSON.parse(trimmed)));
2086
+ } catch {
2087
+ return null;
2088
+ }
2089
+ }
2090
+ function pageForExtraction(body) {
2091
+ const json = jsonBody(body);
2092
+ if (json !== null)
2093
+ return json;
2094
+ const lower = body.toLowerCase();
2095
+ const blocks = [];
2096
+ const metas = [];
2097
+ const closes = new Map;
2098
+ const nextClose = (closing, from) => {
2099
+ const known = closes.get(closing);
2100
+ if (known !== undefined && (known < 0 || known >= from))
2101
+ return known;
2102
+ const found = lower.indexOf(closing, from);
2103
+ closes.set(closing, found);
2104
+ return found;
2105
+ };
2106
+ let at = 0;
2107
+ while (at < body.length) {
2108
+ let lt = body.indexOf("<", at);
2109
+ if (lt < 0)
2110
+ break;
2111
+ const gt = body.indexOf(">", lt);
2112
+ if (gt < 0)
2113
+ break;
2114
+ lt = body.lastIndexOf("<", gt);
2115
+ const tag = body.slice(lt, gt + 1);
2116
+ const tagLower = lower.slice(lt, gt + 1);
2117
+ at = gt + 1;
2118
+ if (tagLower.startsWith("<script") || tagLower.startsWith("<style")) {
2119
+ const closing = tagLower.startsWith("<script") ? "</script" : "</style";
2120
+ const end = nextClose(closing, at);
2121
+ if (end < 0)
2122
+ continue;
2123
+ if (closing === "</script" && blocks.length < MAX_BLOCKS && /type\s*=\s*["']?application\/ld\+json/.test(tagLower.slice(0, MAX_TAG))) {
2124
+ blocks.push(`<script type="application/ld+json">${escapeLt(body.slice(at, end))}</script>`);
2125
+ }
2126
+ at = end;
2127
+ } else if (tagLower.startsWith("<meta") && tag.length <= MAX_TAG && metas.length < MAX_METAS) {
2128
+ metas.push(tag);
2129
+ }
2130
+ }
2131
+ return [...blocks, ...metas, body.replaceAll("<", " ")].join(`
2132
+ `);
2133
+ }
2134
+
2135
+ // plugins/tracker/src/fetch.ts
2136
+ var TIMEOUT_MS = 15000;
2137
+ var MAX_BYTES = 3 * 1024 * 1024;
2138
+ var MAX_REDIRECTS = 5;
2139
+ var USER_AGENT = "Mozilla/5.0 (compatible; RackbopsClerk/1.0; price tracker)";
2140
+
2141
+ class FetchRefusedError extends Error {
2142
+ name = "FetchRefusedError";
2143
+ }
2144
+ var systemResolve = async (host) => (await lookup(host, { all: true, verbatim: true })).map((a) => a.address);
2145
+ function v4Parts(ip) {
2146
+ const parts = ip.split(".").map(Number);
2147
+ return parts.length === 4 && parts.every((p) => Number.isInteger(p) && p >= 0 && p <= 255) ? parts : null;
2148
+ }
2149
+ function isPublicV4(ip) {
2150
+ const p = v4Parts(ip);
2151
+ if (!p)
2152
+ return false;
2153
+ const [a = 0, b = 0, c = 0] = p;
2154
+ if (a === 0 || a === 10 || a === 127 || a >= 224)
2155
+ return false;
2156
+ if (a === 100 && b >= 64 && b <= 127)
2157
+ return false;
2158
+ if (a === 169 && b === 254)
2159
+ return false;
2160
+ if (a === 172 && b >= 16 && b <= 31)
2161
+ return false;
2162
+ if (a === 192 && b === 168)
2163
+ return false;
2164
+ if (a === 192 && b === 0 && (c === 0 || c === 2))
2165
+ return false;
2166
+ if (a === 198 && (b === 18 || b === 19))
2167
+ return false;
2168
+ if (a === 198 && b === 51 && c === 100)
2169
+ return false;
2170
+ if (a === 203 && b === 0 && c === 113)
2171
+ return false;
2172
+ return true;
2173
+ }
2174
+ function v6Groups(ip) {
2175
+ let text = ip.toLowerCase();
2176
+ const zone = text.indexOf("%");
2177
+ if (zone >= 0)
2178
+ text = text.slice(0, zone);
2179
+ const lastColon = text.lastIndexOf(":");
2180
+ if (text.slice(lastColon + 1).includes(".")) {
2181
+ const v4 = v4Parts(text.slice(lastColon + 1));
2182
+ if (!v4)
2183
+ return null;
2184
+ const hex = (hi, lo) => (hi << 8 | lo).toString(16);
2185
+ text = `${text.slice(0, lastColon + 1)}${hex(v4[0] ?? 0, v4[1] ?? 0)}:${hex(v4[2] ?? 0, v4[3] ?? 0)}`;
2186
+ }
2187
+ const halves = text.split("::");
2188
+ if (halves.length > 2)
2189
+ return null;
2190
+ const parse = (s) => s === "" ? [] : s.split(":").map((g) => /^[0-9a-f]{1,4}$/.test(g) ? Number.parseInt(g, 16) : Number.NaN);
2191
+ const head = parse(halves[0] ?? "");
2192
+ const rest = halves.length === 2 ? parse(halves[1] ?? "") : [];
2193
+ const known = head.length + rest.length;
2194
+ if (halves.length === 1 && known !== 8)
2195
+ return null;
2196
+ if (known > 8)
2197
+ return null;
2198
+ const groups = [...head, ...new Array(8 - known).fill(0), ...rest];
2199
+ return groups.every((g) => Number.isInteger(g) && g >= 0 && g <= 65535) ? groups : null;
2200
+ }
2201
+ function isPublicV6(ip) {
2202
+ const g = v6Groups(ip);
2203
+ if (!g)
2204
+ return false;
2205
+ const [g0 = 0, g1 = 0, g2 = 0, g3 = 0, g4 = 0, g5 = 0, g6 = 0, g7 = 0] = g;
2206
+ const embedded = `${g6 >> 8}.${g6 & 255}.${g7 >> 8}.${g7 & 255}`;
2207
+ if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0) {
2208
+ return g5 === 65535 ? isPublicV4(embedded) : false;
2209
+ }
2210
+ if (g0 === 100 && g1 === 65435 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0)
2211
+ return isPublicV4(embedded);
2212
+ if ((g0 & 65024) === 64512)
2213
+ return false;
2214
+ if ((g0 & 65472) === 65152)
2215
+ return false;
2216
+ if ((g0 & 65280) === 65280)
2217
+ return false;
2218
+ if (g0 === 8193 && g1 === 3512)
2219
+ return false;
2220
+ if (g0 === 8193 && g1 < 512)
2221
+ return false;
2222
+ if (g0 === 8194)
2223
+ return false;
2224
+ return (g0 & 57344) === 8192;
2225
+ }
2226
+ function isPublicAddress(ip) {
2227
+ const version = isIP(ip);
2228
+ if (version === 4)
2229
+ return isPublicV4(ip);
2230
+ if (version === 6)
2231
+ return isPublicV6(ip);
2232
+ return false;
2233
+ }
2234
+ function urlProblem(raw) {
2235
+ let url;
2236
+ try {
2237
+ url = new URL(raw);
2238
+ } catch {
2239
+ return "That is not a web address.";
2240
+ }
2241
+ if (url.protocol !== "https:" && url.protocol !== "http:")
2242
+ return "Only http and https pages can be tracked.";
2243
+ if (url.username !== "" || url.password !== "")
2244
+ return "A page address with a user name or password in it cannot be tracked.";
2245
+ if (url.port !== "")
2246
+ return "Only pages on the standard web ports can be tracked.";
2247
+ const host = url.hostname.replace(/^\[|\]$/g, "");
2248
+ if (host === "" || host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) {
2249
+ return "Only public web pages can be tracked.";
2250
+ }
2251
+ if (isIP(host) !== 0 && !isPublicAddress(host))
2252
+ return "Only public web pages can be tracked.";
2253
+ return null;
2254
+ }
2255
+ function orAbort(p, signal) {
2256
+ if (signal.aborted)
2257
+ return Promise.reject(signal.reason);
2258
+ return new Promise((resolve, reject) => {
2259
+ const onAbort = () => reject(signal.reason);
2260
+ signal.addEventListener("abort", onAbort, { once: true });
2261
+ p.then((v) => {
2262
+ signal.removeEventListener("abort", onAbort);
2263
+ resolve(v);
2264
+ }, (e) => {
2265
+ signal.removeEventListener("abort", onAbort);
2266
+ reject(e);
2267
+ });
2268
+ });
2269
+ }
2270
+ async function checkHost(url, resolve, signal) {
2271
+ const problem = urlProblem(url.href);
2272
+ if (problem)
2273
+ throw new FetchRefusedError(problem);
2274
+ const host = url.hostname.replace(/^\[|\]$/g, "");
2275
+ let addresses;
2276
+ if (isIP(host) !== 0)
2277
+ addresses = [host];
2278
+ else {
2279
+ try {
2280
+ addresses = await orAbort(resolve(host), signal);
2281
+ } catch {
2282
+ signal.throwIfAborted();
2283
+ throw new FetchRefusedError(`the name ${host} did not resolve`);
2284
+ }
2285
+ }
2286
+ if (addresses.length === 0)
2287
+ throw new FetchRefusedError(`the name ${host} did not resolve`);
2288
+ if (!addresses.every(isPublicAddress))
2289
+ throw new FetchRefusedError(`${host} is not on the public internet`);
2290
+ }
2291
+ async function readCapped(response, max) {
2292
+ if (!response.body)
2293
+ return "";
2294
+ const reader = response.body.getReader();
2295
+ const chunks = [];
2296
+ let size = 0;
2297
+ while (size < max) {
2298
+ const { done, value } = await reader.read();
2299
+ if (done)
2300
+ break;
2301
+ const room = max - size;
2302
+ chunks.push(value.byteLength > room ? value.subarray(0, room) : value);
2303
+ size += Math.min(value.byteLength, room);
2304
+ }
2305
+ await reader.cancel().catch(() => {});
2306
+ const all = new Uint8Array(size);
2307
+ let at = 0;
2308
+ for (const c of chunks) {
2309
+ all.set(c, at);
2310
+ at += c.byteLength;
2311
+ }
2312
+ return new TextDecoder().decode(all);
2313
+ }
2314
+ function createPageFetch(o = {}) {
2315
+ const resolve = o.resolve ?? systemResolve;
2316
+ const fetchImpl = o.fetchImpl ?? fetch;
2317
+ const timeoutMs = o.timeoutMs ?? TIMEOUT_MS;
2318
+ const maxBytes = o.maxBytes ?? MAX_BYTES;
2319
+ return {
2320
+ async get(raw, headers = {}) {
2321
+ const signal = o.signal ? AbortSignal.any([o.signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
2322
+ let url = new URL(raw);
2323
+ for (let hop = 0;; hop++) {
2324
+ await checkHost(url, resolve, signal);
2325
+ signal.throwIfAborted();
2326
+ const response = await fetchImpl(url.href, {
2327
+ method: "GET",
2328
+ redirect: "manual",
2329
+ signal,
2330
+ headers: { "user-agent": USER_AGENT, accept: "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8", ...headers }
2331
+ });
2332
+ const location = response.headers.get("location");
2333
+ if (response.status >= 300 && response.status < 400 && location) {
2334
+ await response.body?.cancel().catch(() => {});
2335
+ if (hop >= MAX_REDIRECTS)
2336
+ throw new FetchRefusedError(`more than ${MAX_REDIRECTS} redirects`);
2337
+ url = new URL(location, url);
2338
+ continue;
2339
+ }
2340
+ const out = {};
2341
+ response.headers.forEach((value, key) => {
2342
+ out[key] = value;
2343
+ });
2344
+ return { status: response.status, body: pageForExtraction(await readCapped(response, maxBytes)), headers: out };
2345
+ }
2346
+ }
2347
+ };
2348
+ }
2349
+
2350
+ // plugins/tracker/src/discord-common.ts
2351
+ import { MessageFlags } from "discord.js";
2352
+ var STARTING = "The tracker is starting up; try again in a minute.";
2353
+ var FAILED = "Something went wrong on my side; try again in a minute.";
2354
+ var EPHEMERAL = { flags: MessageFlags.Ephemeral };
2355
+ var NO_MENTIONS = { allowedMentions: { parse: [] } };
2356
+ var UNKNOWN_MEMBER = 10007;
2357
+ var UNKNOWN_USER = 10013;
2358
+ async function lookupMembership(interaction, guildId, discordId, log) {
2359
+ if (guildId === null)
2360
+ return "not-checked";
2361
+ if (interaction.guildId === guildId && interaction.user.id === discordId)
2362
+ return "member";
2363
+ try {
2364
+ const guild = await interaction.client.guilds.fetch(guildId);
2365
+ await guild.members.fetch({ user: discordId, force: true });
2366
+ return "member";
2367
+ } catch (err) {
2368
+ const code = err.code;
2369
+ if (code === UNKNOWN_MEMBER || code === UNKNOWN_USER)
2370
+ return "not-member";
2371
+ log.warn(`could not check membership of ${guildId}: ${err instanceof Error ? err.message : String(err)}`);
2372
+ return "unknown";
2373
+ }
2374
+ }
2375
+ function serial() {
2376
+ let chain = Promise.resolve();
2377
+ return (fn) => {
2378
+ const next = chain.then(fn, fn);
2379
+ chain = next.catch(() => {});
2380
+ return next;
2381
+ };
2382
+ }
2383
+
2066
2384
  // plugins/tracker/src/people.ts
2067
2385
  var DEFAULT_TIME_ZONE = "America/New_York";
2068
2386
  var DEFAULT_PREFERRED_HOUR = 9;
@@ -2190,6 +2508,20 @@ async function registerPerson(d, user, input) {
2190
2508
  await rescheduleOwned(d, updated, zoneChanged);
2191
2509
  return registeredText(updated, first);
2192
2510
  }
2511
+ async function saveSettings(d, user, input) {
2512
+ let updated;
2513
+ try {
2514
+ updated = await setPreferences(d.store, user.id, { preferredHour: input.hour, timeZone: input.zone });
2515
+ } catch (err) {
2516
+ if (err instanceof PeopleError)
2517
+ return err.message;
2518
+ throw err;
2519
+ }
2520
+ const zoneChanged = updated.timeZone !== user.timeZone;
2521
+ if (zoneChanged || updated.preferredHour !== user.preferredHour)
2522
+ await rescheduleOwned(d, updated, zoneChanged);
2523
+ return null;
2524
+ }
2193
2525
  async function setHour(d, user, hour) {
2194
2526
  if (hour === user.preferredHour)
2195
2527
  return `Your preferred hour is already ${String(hour).padStart(2, "0")}:00, ${user.timeZone} time.`;
@@ -2269,19 +2601,25 @@ async function remind(d, user, input) {
2269
2601
  return clip(`Reminder \`${task.id}\` set: ${text}
2270
2602
  Next: ${at}${cadence}.`);
2271
2603
  }
2272
- async function listTasks(d, user) {
2273
- const now = d.clock.now();
2274
- const lines = [formatTaskList(await taskList(d.store, { userId: user.id, admin: user.admin }), user, now)];
2604
+ async function loadTaskList(d, user) {
2605
+ const entries = await taskList(d.store, { userId: user.id, admin: user.admin });
2606
+ const paused = [];
2275
2607
  for (const task of await d.store.listTasks({ ownerId: user.id, status: "paused" })) {
2276
- const held = d.health.pausesFor(task.id).filter((u) => u !== user.id);
2277
- if (held.length === 0) {
2608
+ const held = [];
2609
+ for (const id of d.health.pausesFor(task.id).filter((u) => u !== user.id))
2610
+ held.push((await d.store.getUser(id))?.displayName ?? id);
2611
+ paused.push({ task, held });
2612
+ }
2613
+ return { entries, paused };
2614
+ }
2615
+ async function listTasks(d, user) {
2616
+ const { entries, paused } = await loadTaskList(d, user);
2617
+ const lines = [formatTaskList(entries, user, d.clock.now())];
2618
+ for (const { task, held } of paused) {
2619
+ if (held.length === 0)
2278
2620
  lines.push(`Paused: \`${task.id}\` ${task.title}`);
2279
- continue;
2280
- }
2281
- const who = [];
2282
- for (const id of held)
2283
- who.push((await d.store.getUser(id))?.displayName ?? id);
2284
- lines.push(`Paused: \`${task.id}\` ${task.title} -- I could not DM ${who.join(", ")}; \`/task resume ${task.id}\` goes on without them.`);
2621
+ else
2622
+ lines.push(`Paused: \`${task.id}\` ${task.title} -- I could not DM ${held.join(", ")}; \`/task resume ${task.id}\` goes on without them.`);
2285
2623
  }
2286
2624
  return clip(lines.join(`
2287
2625
  `));
@@ -2338,102 +2676,6 @@ async function resumeTask(d, user, taskId) {
2338
2676
  return `Resumed \`${task.id}\`${without}.`;
2339
2677
  }
2340
2678
 
2341
- // plugins/tracker/src/discord-common.ts
2342
- import { MessageFlags } from "discord.js";
2343
- var STARTING = "The tracker is starting up; try again in a minute.";
2344
- var FAILED = "Something went wrong on my side; try again in a minute.";
2345
- var EPHEMERAL = { flags: MessageFlags.Ephemeral };
2346
- var NO_MENTIONS = { allowedMentions: { parse: [] } };
2347
- var UNKNOWN_MEMBER = 10007;
2348
- var UNKNOWN_USER = 10013;
2349
- async function lookupMembership(interaction, guildId, discordId, log) {
2350
- if (guildId === null)
2351
- return "not-checked";
2352
- if (interaction.guildId === guildId && interaction.user.id === discordId)
2353
- return "member";
2354
- try {
2355
- const guild = await interaction.client.guilds.fetch(guildId);
2356
- await guild.members.fetch({ user: discordId });
2357
- return "member";
2358
- } catch (err) {
2359
- const code = err.code;
2360
- if (code === UNKNOWN_MEMBER || code === UNKNOWN_USER)
2361
- return "not-member";
2362
- log.warn(`could not check membership of ${guildId}: ${err instanceof Error ? err.message : String(err)}`);
2363
- return "unknown";
2364
- }
2365
- }
2366
- function serial() {
2367
- let chain = Promise.resolve();
2368
- return (fn) => {
2369
- const next = chain.then(fn, fn);
2370
- chain = next.catch(() => {});
2371
- return next;
2372
- };
2373
- }
2374
-
2375
- // plugins/tracker/src/history.ts
2376
- var HISTORY_RUNS = 10;
2377
- var HISTORY_CHANGES = 5;
2378
- function replyText(r) {
2379
- if (r.kind === "text") {
2380
- const text = typeof r.payload === "object" && r.payload !== null ? String(r.payload.text ?? "") : "";
2381
- return `replied "${clip(text, 80)}"`;
2382
- }
2383
- if (r.kind === "decision")
2384
- return `chose ${String(r.payload?.choice ?? "?")}`;
2385
- return r.kind === "opt_out" ? "opted out" : r.kind;
2386
- }
2387
- function formatRun(o, replies, people, viewer, now) {
2388
- const late = o.late ? " (late)" : "";
2389
- const error = o.status === "failed" && o.error ? `: ${clip(o.error, 120)}` : "";
2390
- const answers = replies.filter((r) => r.occurrenceId === o.id).map((r) => `${people.get(r.userId)?.displayName ?? r.userId} ${replyText(r)}`);
2391
- const answered = answers.length > 0 ? `; ${answers.join(", ")}` : "";
2392
- return `- ${formatInstant(o.dueAt, viewer.timeZone, now)} -- ${o.status}${late}${error}${answered}`;
2393
- }
2394
- function formatChange(e, viewer, now) {
2395
- const detail = e.kind === "paused" || e.kind === "resumed" ? `: ${e.detail}` : "";
2396
- return `- ${formatInstant(e.at, viewer.timeZone, now)} ${e.kind.replaceAll("_", " ")}${detail}`;
2397
- }
2398
- async function taskHistory(d, user, taskId) {
2399
- const actor = { userId: user.id, admin: user.admin };
2400
- const id = taskId.trim();
2401
- const task = await visibleTask(d.store, actor, id);
2402
- if (!task)
2403
- return NO_SUCH_TASK;
2404
- const now = d.clock.now();
2405
- const all = await visibleOccurrences(d.store, actor, task.id) ?? [];
2406
- const upcoming = (o) => o.status === "queued" && Date.parse(o.dueAt) > now.getTime();
2407
- const runs = all.filter((o) => !upcoming(o));
2408
- const next = all.find(upcoming);
2409
- const replies = await visibleReplies(d.store, actor, task.id) ?? [];
2410
- const changes = await visibleHistory(d.store, actor, task.id) ?? [];
2411
- const people = new Map;
2412
- for (const r of replies) {
2413
- if (!people.has(r.userId)) {
2414
- const u = await d.store.getUser(r.userId);
2415
- if (u)
2416
- people.set(u.id, u);
2417
- }
2418
- }
2419
- const owner = task.ownerId === user.id ? user : await d.store.getUser(task.ownerId);
2420
- const cadence = task.schedule && owner ? describeSchedule(task.schedule, owner, user.timeZone, now) : "no schedule";
2421
- const lines = [`\`${task.id}\` ${task.title} -- ${task.type}, ${task.status}, ${cadence}`];
2422
- lines.push(next ? `Next: ${formatInstant(next.dueAt, user.timeZone, now)}` : "Next: nothing scheduled");
2423
- lines.push(runs.length === 0 ? "Runs: none yet" : "Runs:");
2424
- if (runs.length > HISTORY_RUNS)
2425
- lines.push(`- (${runs.length - HISTORY_RUNS} earlier)`);
2426
- for (const o of runs.slice(-HISTORY_RUNS))
2427
- lines.push(formatRun(o, replies, people, user, now));
2428
- lines.push("Changes:");
2429
- if (changes.length > HISTORY_CHANGES)
2430
- lines.push(`- (${changes.length - HISTORY_CHANGES} earlier)`);
2431
- for (const e of changes.slice(-HISTORY_CHANGES))
2432
- lines.push(formatChange(e, user, now));
2433
- return clip(lines.join(`
2434
- `));
2435
- }
2436
-
2437
2679
  // plugins/tracker/src/interactions.ts
2438
2680
  import {
2439
2681
  ActionRowBuilder,
@@ -2815,61 +3057,462 @@ ${body}` : body;
2815
3057
  };
2816
3058
  }
2817
3059
 
2818
- // plugins/tracker/src/discord.ts
2819
- function displayName(user) {
2820
- return user.globalName ?? user.username;
3060
+ // plugins/tracker/src/history.ts
3061
+ var HISTORY_RUNS = 10;
3062
+ var HISTORY_CHANGES = 5;
3063
+ function replyText(r) {
3064
+ if (r.kind === "text") {
3065
+ const text = typeof r.payload === "object" && r.payload !== null ? String(r.payload.text ?? "") : "";
3066
+ return `replied "${clip(text, 80)}"`;
3067
+ }
3068
+ if (r.kind === "decision")
3069
+ return `chose ${String(r.payload?.choice ?? "?")}`;
3070
+ return r.kind === "opt_out" ? "opted out" : r.kind;
2821
3071
  }
2822
- function createSurface(w) {
2823
- const ctx = {
2824
- deps: w.deps,
2825
- queue: serial(),
2826
- membershipOf: (interaction, discordId) => w.membership ? w.membership(interaction, discordId) : lookupMembership(interaction, w.guildId, discordId, w.log),
2827
- log: w.log
3072
+ function runView(o, replies, people, viewer, now) {
3073
+ return {
3074
+ due: formatInstant(o.dueAt, viewer.timeZone, now),
3075
+ status: o.status,
3076
+ late: o.late,
3077
+ error: o.status === "failed" && o.error ? clip(o.error, 120) : null,
3078
+ answers: replies.filter((r) => r.occurrenceId === o.id).map((r) => `${people.get(r.userId)?.displayName ?? r.userId} ${replyText(r)}`)
2828
3079
  };
2829
- const membershipOf = (interaction, discordId) => ctx.membershipOf(interaction, discordId);
2830
- async function run(interaction, need, act, pre = async () => {
2831
- return;
2832
- }) {
2833
- await interaction.deferReply(EPHEMERAL);
2834
- let content;
2835
- try {
2836
- const d = w.deps();
2837
- if (!d)
2838
- content = STARTING;
2839
- else {
2840
- const membership = await membershipOf(interaction, interaction.user.id);
2841
- const before = await pre();
2842
- const first = await ctx.queue(async () => {
2843
- const entry = await enter(d, interaction.user.id, membership, need);
2844
- if (!entry.ok)
2845
- return { notice: null, step: entry.content };
2846
- return { notice: entry.notice, step: await act(d, entry.user, before) };
2847
- });
2848
- let body;
2849
- if (typeof first.step === "string")
2850
- body = first.step;
2851
- else {
2852
- const { outside, finish } = first.step;
2853
- const result = await outside();
2854
- body = await ctx.queue(() => finish(result));
2855
- }
2856
- content = clip(first.notice ? `${first.notice}
2857
-
2858
- ${body}` : body);
2859
- }
2860
- } catch (err) {
2861
- w.log.error(`/${interaction.commandName} failed`, err);
2862
- content = FAILED;
2863
- }
2864
- await interaction.editReply({ content, ...NO_MENTIONS });
2865
- }
2866
- const commands = [
2867
- {
2868
- name: "allow",
2869
- build: (b) => b.setDescription("Admin: let a person use the tracker").addUserOption((o) => o.setName("user").setDescription("Who to let in").setRequired(true)),
2870
- handle: (interaction) => {
2871
- const target = interaction.options.getUser("user", true);
2872
- return run(interaction, "admin", (d, admin, membership) => allowPerson(d, admin, { discordId: target.id, bot: target.bot, membership }), () => membershipOf(interaction, target.id));
3080
+ }
3081
+ function changeView(e, viewer, now) {
3082
+ return {
3083
+ at: formatInstant(e.at, viewer.timeZone, now),
3084
+ kind: e.kind.replaceAll("_", " "),
3085
+ detail: e.kind === "paused" || e.kind === "resumed" ? e.detail : null
3086
+ };
3087
+ }
3088
+ function runLine(v) {
3089
+ const late = v.late ? " (late)" : "";
3090
+ const error = v.error ? `: ${v.error}` : "";
3091
+ const answered = v.answers.length > 0 ? `; ${v.answers.join(", ")}` : "";
3092
+ return `- ${v.due} -- ${v.status}${late}${error}${answered}`;
3093
+ }
3094
+ function changeLine(v) {
3095
+ return `- ${v.at} ${v.kind}${v.detail !== null ? `: ${v.detail}` : ""}`;
3096
+ }
3097
+ async function loadHistory(d, user, taskId) {
3098
+ const actor = { userId: user.id, admin: user.admin };
3099
+ const task = await visibleTask(d.store, actor, taskId.trim());
3100
+ if (!task)
3101
+ return null;
3102
+ const now = d.clock.now();
3103
+ const all = await visibleOccurrences(d.store, actor, task.id) ?? [];
3104
+ const upcoming = (o) => o.status === "queued" && Date.parse(o.dueAt) > now.getTime();
3105
+ const runs = all.filter((o) => !upcoming(o));
3106
+ const next = all.find(upcoming);
3107
+ const replies = await visibleReplies(d.store, actor, task.id) ?? [];
3108
+ const changes = await visibleHistory(d.store, actor, task.id) ?? [];
3109
+ const people = new Map;
3110
+ for (const r of replies) {
3111
+ if (!people.has(r.userId)) {
3112
+ const u = await d.store.getUser(r.userId);
3113
+ if (u)
3114
+ people.set(u.id, u);
3115
+ }
3116
+ }
3117
+ const owner = task.ownerId === user.id ? user : await d.store.getUser(task.ownerId);
3118
+ return {
3119
+ task,
3120
+ cadence: task.schedule && owner ? describeSchedule(task.schedule, owner, user.timeZone, now) : "no schedule",
3121
+ next: next ? formatInstant(next.dueAt, user.timeZone, now) : null,
3122
+ runs: runs.slice(-HISTORY_RUNS).map((o) => runView(o, replies, people, user, now)),
3123
+ earlierRuns: Math.max(0, runs.length - HISTORY_RUNS),
3124
+ changes: changes.slice(-HISTORY_CHANGES).map((e) => changeView(e, user, now)),
3125
+ earlierChanges: Math.max(0, changes.length - HISTORY_CHANGES)
3126
+ };
3127
+ }
3128
+ async function taskHistory(d, user, taskId) {
3129
+ const h = await loadHistory(d, user, taskId);
3130
+ if (!h)
3131
+ return NO_SUCH_TASK;
3132
+ const { task } = h;
3133
+ const lines = [`\`${task.id}\` ${task.title} -- ${task.type}, ${task.status}, ${h.cadence}`];
3134
+ lines.push(h.next ? `Next: ${h.next}` : "Next: nothing scheduled");
3135
+ lines.push(h.runs.length === 0 ? "Runs: none yet" : "Runs:");
3136
+ if (h.earlierRuns > 0)
3137
+ lines.push(`- (${h.earlierRuns} earlier)`);
3138
+ for (const v of h.runs)
3139
+ lines.push(runLine(v));
3140
+ lines.push("Changes:");
3141
+ if (h.earlierChanges > 0)
3142
+ lines.push(`- (${h.earlierChanges} earlier)`);
3143
+ for (const c of h.changes)
3144
+ lines.push(changeLine(c));
3145
+ return clip(lines.join(`
3146
+ `));
3147
+ }
3148
+
3149
+ // plugins/tracker/src/series.ts
3150
+ var SERIES_SHOWN = 8;
3151
+ function pointLine(p, viewer, now) {
3152
+ const note = p.note && p.note !== "observed" ? ` (${p.note})` : "";
3153
+ return `- ${formatInstant(p.at, viewer.timeZone, now)} ${money(p.value, p.unit ?? "")}${note}`;
3154
+ }
3155
+ async function seriesLines(d, user, taskId) {
3156
+ const actor = { userId: user.id, admin: user.admin };
3157
+ const task = await visibleTask(d.store, actor, taskId.trim());
3158
+ if (!task || task.type !== "renewal" && task.type !== "price")
3159
+ return [];
3160
+ const points = await visibleSeries(d.store, actor, task.id) ?? [];
3161
+ const now = d.clock.now();
3162
+ const lines = [];
3163
+ if (task.type === "renewal") {
3164
+ const paid = points.filter((p) => p.note === "keep" || p.note === "renewed");
3165
+ if (paid.length === 0)
3166
+ return ["Paid: nothing recorded yet"];
3167
+ const unit = paid.at(-1)?.unit ?? "";
3168
+ const total = paid.reduce((sum, p) => sum + p.value, 0);
3169
+ lines.push(`Paid: ${paid.length} period${paid.length === 1 ? "" : "s"}, ${money(total, unit)} in all`);
3170
+ for (const p of paid.slice(-SERIES_SHOWN))
3171
+ lines.push(pointLine(p, user, now));
3172
+ return lines;
3173
+ }
3174
+ const runs = await visibleOccurrences(d.store, actor, task.id) ?? [];
3175
+ const checked = runs.filter((o) => o.status !== "queued" && o.summary).at(-1);
3176
+ if (checked?.summary)
3177
+ lines.push(`Last check: ${clip(checked.summary, 160)}`);
3178
+ if (points.length === 0) {
3179
+ lines.push("Prices: none read yet");
3180
+ return lines;
3181
+ }
3182
+ const unit = points.at(-1)?.unit ?? "";
3183
+ const values = points.map((p) => p.value);
3184
+ lines.push(`Prices: ${points.length} read, low ${money(Math.min(...values), unit)}, high ${money(Math.max(...values), unit)}`);
3185
+ for (const p of points.slice(-SERIES_SHOWN))
3186
+ lines.push(pointLine(p, user, now));
3187
+ return lines;
3188
+ }
3189
+ async function historyWithSeries(d, user, taskId) {
3190
+ const history = await taskHistory(d, user, taskId);
3191
+ const series = await seriesLines(d, user, taskId);
3192
+ if (series.length === 0)
3193
+ return history;
3194
+ const lines = history.split(`
3195
+ `);
3196
+ const block = series.join(`
3197
+ `);
3198
+ const rest = lines.slice(2).join(`
3199
+ `);
3200
+ const head = lines.slice(0, 2).join(`
3201
+ `);
3202
+ return clip(`${head}
3203
+ ${block}
3204
+ ${clip(rest, Math.max(3, MAX_ANSWER - head.length - block.length - 2))}`);
3205
+ }
3206
+
3207
+ // plugins/tracker/src/tracked.ts
3208
+ var MAX_TITLE2 = 100;
3209
+ var MAX_NOTE = 300;
3210
+ var MAX_NEAR = 100;
3211
+ var MAX_PRICE_TASKS = 20;
3212
+ var DEFAULT_POLL_HOURS = 12;
3213
+ var MAX_POLL_HOURS = 168;
3214
+ var DEFAULT_LEAD_DAYS = 7;
3215
+ var MAX_LEAD_DAYS = 365;
3216
+ var MAX_EVERY = 100;
3217
+ function plural2(n, word) {
3218
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
3219
+ }
3220
+ async function addRenewal(d, user, input) {
3221
+ const type = d.types.renewal;
3222
+ if (!type)
3223
+ return "Renewals are not available on this bot.";
3224
+ const name = input.name.trim();
3225
+ if (name.length === 0)
3226
+ return "Say what renews: a subscription, a domain, a warranty.";
3227
+ if (name.length > MAX_TITLE2)
3228
+ return `That name is longer than ${MAX_TITLE2} characters.`;
3229
+ if (!Number.isFinite(input.amount) || input.amount < 0)
3230
+ return "The amount has to be zero or more.";
3231
+ const currency = input.currency.trim().toUpperCase();
3232
+ if (!/^[A-Z]{3}$/.test(currency))
3233
+ return "The currency is a three-letter code, such as USD or EUR.";
3234
+ const note = input.note?.trim() ?? "";
3235
+ if (note.length > MAX_NOTE)
3236
+ return `The note is longer than ${MAX_NOTE} characters.`;
3237
+ const every = input.every ?? 1;
3238
+ if (!Number.isInteger(every) || every < 1 || every > MAX_EVERY)
3239
+ return `\`every\` is a whole number from 1 to ${MAX_EVERY}.`;
3240
+ const lead = input.lead ?? DEFAULT_LEAD_DAYS;
3241
+ if (!Number.isInteger(lead) || lead < 0 || lead > MAX_LEAD_DAYS)
3242
+ return `\`lead\` is a whole number of days from 0 to ${MAX_LEAD_DAYS}.`;
3243
+ const renews = input.renews.trim();
3244
+ const now = d.clock.now();
3245
+ const days = /^\d{4}-\d{2}-\d{2}$/.test(renews) ? daysUntil(renews, now, user.timeZone) : null;
3246
+ if (days === null)
3247
+ return "Give the date as YYYY-MM-DD, for example 2026-12-01.";
3248
+ if (days < 0)
3249
+ return "That date has passed: give the next renewal or expiry date.";
3250
+ const schedule = { kind: "period", every, unit: input.unit ?? "year", anchor: renews, leadDays: lead };
3251
+ const config = { amount: input.amount, currency, ...note ? { note } : {} };
3252
+ const actor = { userId: user.id, admin: user.admin };
3253
+ let created;
3254
+ try {
3255
+ created = await createTask(d.store, actor, user, { type, title: name, config, schedule }, now);
3256
+ } catch (err) {
3257
+ if (err instanceof ScheduleError)
3258
+ return `That schedule does not work: ${err.message}.`;
3259
+ throw err;
3260
+ }
3261
+ const { task } = created;
3262
+ let next = created.next;
3263
+ const first = nextDue(schedule, new Date(0), { zone: user.timeZone, preferredHour: user.preferredHour });
3264
+ if (first && first.getTime() <= now.getTime()) {
3265
+ next = await d.store.createOccurrence({
3266
+ taskId: task.id,
3267
+ lane: task.lane,
3268
+ dueAt: first.toISOString(),
3269
+ dedupeKey: scheduledKey(task.id, first),
3270
+ at: now.toISOString()
3271
+ });
3272
+ }
3273
+ const cadence = describeSchedule(schedule, user, user.timeZone, now);
3274
+ const when = days === 0 ? "today" : `in ${plural2(days, "day")}`;
3275
+ const ask = !next ? "never (that schedule has no next date)" : Date.parse(next.dueAt) <= now.getTime() ? `in the next minute (it renews ${when})` : formatInstant(next.dueAt, user.timeZone, now);
3276
+ return clip([
3277
+ `Renewal \`${task.id}\` set: ${name}, ${money(input.amount, currency)}, ${cadence}.`,
3278
+ `First ask: ${ask}. Each ask has Keep, Cancel and Renewed buttons; paid a different amount? Answer with \`/task decide\` instead of a button.`
3279
+ ].join(`
3280
+ `));
3281
+ }
3282
+ function nearPattern(near) {
3283
+ const literal = near.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3284
+ return `${literal}[^0-9]{0,40}([0-9][0-9.,]{0,15})`;
3285
+ }
3286
+ async function preview(d, v) {
3287
+ if (!d.fetch)
3288
+ return { ok: false, error: "Price tracking is not available on this bot." };
3289
+ try {
3290
+ const response = await d.fetch.get(v.url);
3291
+ if (response.status !== 200)
3292
+ return { ok: false, error: `That page answered HTTP ${response.status}, so there is no price to track.` };
3293
+ const found = extractPrice(response.body, v.config.pattern);
3294
+ if (!found) {
3295
+ return {
3296
+ ok: false,
3297
+ error: v.config.pattern ? "I found no price after that `near` text on the page. Check the words as the page shows them." : "I found no price on that page. Some shops show it only to a browser; `near` (the words just before the price) can help."
3298
+ };
3299
+ }
3300
+ return { ok: true, text: money(found.value, found.currency ?? "") };
3301
+ } catch (err) {
3302
+ if (err instanceof FetchRefusedError)
3303
+ return { ok: false, error: `I cannot read that page: ${err.message}.` };
3304
+ return { ok: false, error: "I could not read that page just now; try again later." };
3305
+ }
3306
+ }
3307
+ async function ownedPriceTasks(d, user) {
3308
+ const active = await d.store.listTasks({ ownerId: user.id, status: "active" });
3309
+ const paused = await d.store.listTasks({ ownerId: user.id, status: "paused" });
3310
+ return [...active, ...paused].filter((t) => t.type === "price").length;
3311
+ }
3312
+ function titleFor(url) {
3313
+ const u = new URL(url);
3314
+ return clip(`${u.hostname}${u.pathname === "/" ? "" : u.pathname}`, MAX_TITLE2);
3315
+ }
3316
+ async function trackPrice(d, user, input) {
3317
+ if (!d.types.price || !d.fetch)
3318
+ return "Price tracking is not available on this bot.";
3319
+ const url = input.url.trim();
3320
+ const problem = urlProblem(url);
3321
+ if (problem)
3322
+ return problem;
3323
+ const hours = input.hours ?? DEFAULT_POLL_HOURS;
3324
+ if (!Number.isInteger(hours) || hours < 1 || hours > MAX_POLL_HOURS)
3325
+ return `\`hours\` is a whole number from 1 to ${MAX_POLL_HOURS}.`;
3326
+ const drop = input.drop ?? DEFAULT_DROP_PERCENT;
3327
+ if (!Number.isFinite(drop) || drop < 1 || drop > 90)
3328
+ return "`drop` is a percentage from 1 to 90.";
3329
+ const baseline = input.baseline ?? DEFAULT_BASELINE;
3330
+ if (!BASELINE_RULES.includes(baseline))
3331
+ return "`baseline` is first, last or peak.";
3332
+ const near = input.near?.trim() ?? "";
3333
+ if (near.length > MAX_NEAR)
3334
+ return `\`near\` is longer than ${MAX_NEAR} characters.`;
3335
+ const name = input.name?.trim() ?? "";
3336
+ if (name.length > MAX_TITLE2)
3337
+ return `That name is longer than ${MAX_TITLE2} characters.`;
3338
+ if (await ownedPriceTasks(d, user) >= MAX_PRICE_TASKS) {
3339
+ return `You already track ${MAX_PRICE_TASKS} prices, the most one person may. Stop one with \`/task done\` first.`;
3340
+ }
3341
+ const v = {
3342
+ url,
3343
+ title: name || titleFor(url),
3344
+ hours,
3345
+ config: { url, dropPercent: drop, baseline, ...near ? { pattern: nearPattern(near) } : {} }
3346
+ };
3347
+ return {
3348
+ outside: () => preview(d, v),
3349
+ finish: (result) => finishPrice(d, user, v, result)
3350
+ };
3351
+ }
3352
+ async function finishPrice(d, user, v, seen) {
3353
+ if (!seen.ok)
3354
+ return seen.error;
3355
+ const type = d.types.price;
3356
+ if (!type)
3357
+ return "Price tracking is not available on this bot.";
3358
+ if (await ownedPriceTasks(d, user) >= MAX_PRICE_TASKS) {
3359
+ return `You already track ${MAX_PRICE_TASKS} prices, the most one person may. Stop one with \`/task done\` first.`;
3360
+ }
3361
+ const now = d.clock.now();
3362
+ const schedule = { kind: "poll", every: v.hours, unit: "hour", start: now.toISOString() };
3363
+ const actor = { userId: user.id, admin: user.admin };
3364
+ const { task } = await createTask(d.store, actor, user, { type, title: v.title, config: v.config, schedule }, now);
3365
+ const rule = v.config.baseline ?? DEFAULT_BASELINE;
3366
+ return clip([
3367
+ `Tracking \`${task.id}\`: ${v.title}. I read ${seen.text} just now.`,
3368
+ `I check every ${plural2(v.hours, "hour")} and DM you when it drops ${v.config.dropPercent}% or more from the ${rule} price seen; the first check, within a minute, DMs the starting price. \`/task done ${task.id}\` stops it.`
3369
+ ].join(`
3370
+ `));
3371
+ }
3372
+ async function decideRenewal(d, user, input) {
3373
+ const task = await visibleTask(d.store, { userId: user.id, admin: false }, input.taskId.trim());
3374
+ if (!task || task.ownerId !== user.id)
3375
+ return NO_SUCH_TASK;
3376
+ if (task.type !== "renewal")
3377
+ return `\`/task decide\` answers a renewal; \`${task.id}\` is a ${task.type}.`;
3378
+ if (input.amount !== undefined && (!Number.isFinite(input.amount) || input.amount < 0))
3379
+ return "The amount has to be zero or more.";
3380
+ const fired = (await d.store.listOccurrences({ taskId: task.id })).filter((o) => o.status === "running" || o.status === "done" || o.status === "failed");
3381
+ const latest = fired.at(-1);
3382
+ if (!latest)
3383
+ return "That renewal has no ask waiting for an answer yet.";
3384
+ const payload = input.amount === undefined ? input.choice : { choice: input.choice, amount: input.amount };
3385
+ try {
3386
+ await replyLanes(d).reply({ taskId: task.id, occurrenceId: latest.id, userId: user.id, kind: "decision", payload });
3387
+ } catch (err) {
3388
+ if (err instanceof ReplyRefusedError)
3389
+ return err.message;
3390
+ throw err;
3391
+ }
3392
+ if (input.choice === "cancel")
3393
+ return `Cancelled: \`${task.id}\` ${task.title} will not ask again.`;
3394
+ const currency = task.config.currency ?? "";
3395
+ const paid = input.amount !== undefined ? ` at ${money(input.amount, currency)}` : "";
3396
+ return `Recorded: ${input.choice}${paid}.`;
3397
+ }
3398
+
3399
+ // plugins/tracker/src/web/secrets.ts
3400
+ import { createHash, randomBytes, timingSafeEqual } from "crypto";
3401
+ function randomToken() {
3402
+ return randomBytes(32).toString("base64url");
3403
+ }
3404
+ function sha256(value) {
3405
+ return createHash("sha256").update(value).digest("hex");
3406
+ }
3407
+ function safeEqual(a, b) {
3408
+ const x = Buffer.from(a);
3409
+ const y = Buffer.from(b);
3410
+ return x.length === y.length && timingSafeEqual(x, y);
3411
+ }
3412
+ function isTokenShape(value) {
3413
+ return /^[A-Za-z0-9_-]{43}$/.test(value);
3414
+ }
3415
+
3416
+ // plugins/tracker/src/web/signin-link.ts
3417
+ var LINK_TTL_MS = 10 * 60 * 1000;
3418
+
3419
+ class LoginLinks {
3420
+ db;
3421
+ constructor(db) {
3422
+ this.db = db;
3423
+ }
3424
+ issue(userId, now, memberCheckedAt = null) {
3425
+ const token = randomToken();
3426
+ const at = now.toISOString();
3427
+ this.db.query("DELETE FROM web_login_tokens WHERE expires_at <= ?").run(at);
3428
+ this.db.query("INSERT INTO web_login_tokens (token_hash, user_id, created_at, expires_at, member_checked_at) VALUES (?, ?, ?, ?, ?)").run(sha256(token), userId, at, new Date(now.getTime() + LINK_TTL_MS).toISOString(), memberCheckedAt);
3429
+ return token;
3430
+ }
3431
+ peek(token, now) {
3432
+ if (!isTokenShape(token))
3433
+ return false;
3434
+ const row = this.db.query("SELECT 1 AS ok FROM web_login_tokens WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?").get(sha256(token), now.toISOString());
3435
+ return row !== null;
3436
+ }
3437
+ consume(token, now) {
3438
+ if (!isTokenShape(token))
3439
+ return null;
3440
+ const at = now.toISOString();
3441
+ const row = this.db.query("UPDATE web_login_tokens SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ? RETURNING user_id, member_checked_at").get(at, sha256(token), at);
3442
+ return row ? { userId: row.user_id, memberCheckedAt: row.member_checked_at } : null;
3443
+ }
3444
+ }
3445
+
3446
+ // plugins/tracker/src/web/command.ts
3447
+ var WEB_NOT_CONFIGURED = "The web area is not set up on this bot.";
3448
+ function webLink(d, user, web) {
3449
+ if (!web)
3450
+ return WEB_NOT_CONFIGURED;
3451
+ const now = d.clock.now();
3452
+ const token = d.logins.issue(user.id, now, web.gated ? now.toISOString() : null);
3453
+ const minutes = LINK_TTL_MS / 60000;
3454
+ return [
3455
+ `Your sign-in link for the tracker's web area. It works once, within ${minutes} minutes; do not share it.`,
3456
+ `<${web.origin}/${web.name}/login?t=${token}>`
3457
+ ].join(`
3458
+ `);
3459
+ }
3460
+
3461
+ // plugins/tracker/src/discord.ts
3462
+ function displayName(user) {
3463
+ return user.globalName ?? user.username;
3464
+ }
3465
+ function createSurface(w) {
3466
+ const ctx = {
3467
+ deps: w.deps,
3468
+ queue: w.queue ?? serial(),
3469
+ membershipOf: (interaction, discordId) => w.membership ? w.membership(interaction, discordId) : lookupMembership(interaction, w.guildId, discordId, w.log),
3470
+ log: w.log
3471
+ };
3472
+ const membershipOf = (interaction, discordId) => ctx.membershipOf(interaction, discordId);
3473
+ async function run(interaction, need, act, pre = async () => {
3474
+ return;
3475
+ }) {
3476
+ await interaction.deferReply(EPHEMERAL);
3477
+ let content;
3478
+ try {
3479
+ const d = w.deps();
3480
+ if (!d)
3481
+ content = STARTING;
3482
+ else {
3483
+ const membership = await membershipOf(interaction, interaction.user.id);
3484
+ const before = await pre();
3485
+ const first = await ctx.queue(async () => {
3486
+ const entry = await enter(d, interaction.user.id, membership, need);
3487
+ if (!entry.ok)
3488
+ return { notice: null, step: entry.content };
3489
+ return { notice: entry.notice, step: await act(d, entry.user, before) };
3490
+ });
3491
+ let body;
3492
+ if (typeof first.step === "string")
3493
+ body = first.step;
3494
+ else {
3495
+ const { outside, finish } = first.step;
3496
+ const result = await outside();
3497
+ body = await ctx.queue(() => finish(result));
3498
+ }
3499
+ content = clip(first.notice ? `${first.notice}
3500
+
3501
+ ${body}` : body);
3502
+ }
3503
+ } catch (err) {
3504
+ w.log.error(`/${interaction.commandName} failed`, err);
3505
+ content = FAILED;
3506
+ }
3507
+ await interaction.editReply({ content, ...NO_MENTIONS });
3508
+ }
3509
+ const commands = [
3510
+ {
3511
+ name: "allow",
3512
+ build: (b) => b.setDescription("Admin: let a person use the tracker").addUserOption((o) => o.setName("user").setDescription("Who to let in").setRequired(true)),
3513
+ handle: (interaction) => {
3514
+ const target = interaction.options.getUser("user", true);
3515
+ return run(interaction, "admin", (d, admin, membership) => allowPerson(d, admin, { discordId: target.id, bot: target.bot, membership }), () => membershipOf(interaction, target.id));
2873
3516
  }
2874
3517
  },
2875
3518
  {
@@ -2897,6 +3540,45 @@ ${body}` : body);
2897
3540
  });
2898
3541
  })
2899
3542
  },
3543
+ {
3544
+ name: "renewal",
3545
+ build: (b) => b.setDescription("Track a subscription, domain or warranty: I ask before each renewal").addStringOption((o) => o.setName("name").setDescription("What renews, e.g. Netflix").setRequired(true).setMaxLength(100)).addNumberOption((o) => o.setName("amount").setDescription("What one period costs").setRequired(true).setMinValue(0)).addStringOption((o) => o.setName("currency").setDescription("Currency code, e.g. USD").setRequired(true).setMinLength(3).setMaxLength(3)).addStringOption((o) => o.setName("renews").setDescription("The next renewal or expiry date, YYYY-MM-DD").setRequired(true).setMaxLength(10)).addStringOption((o) => o.setName("unit").setDescription("How it renews (default: yearly)").addChoices({ name: "yearly", value: "year" }, { name: "monthly", value: "month" }, { name: "weekly", value: "week" }, { name: "daily", value: "day" })).addIntegerOption((o) => o.setName("every").setDescription("Every how many of those, e.g. 2 for every two years (default 1)").setMinValue(1).setMaxValue(MAX_EVERY)).addIntegerOption((o) => o.setName("lead").setDescription(`Days before the date to ask (default ${DEFAULT_LEAD_DAYS})`).setMinValue(0).setMaxValue(MAX_LEAD_DAYS)).addStringOption((o) => o.setName("note").setDescription("Carried on every ask: where to cancel, which account").setMaxLength(MAX_NOTE)),
3546
+ handle: (interaction) => run(interaction, "registered", (d, user) => {
3547
+ const every = interaction.options.getInteger("every");
3548
+ const lead = interaction.options.getInteger("lead");
3549
+ const unit = interaction.options.getString("unit");
3550
+ const note = interaction.options.getString("note");
3551
+ return addRenewal(d, user, {
3552
+ name: interaction.options.getString("name", true),
3553
+ amount: interaction.options.getNumber("amount", true),
3554
+ currency: interaction.options.getString("currency", true),
3555
+ renews: interaction.options.getString("renews", true),
3556
+ ...every !== null ? { every } : {},
3557
+ ...lead !== null ? { lead } : {},
3558
+ ...unit !== null ? { unit } : {},
3559
+ ...note !== null ? { note } : {}
3560
+ });
3561
+ })
3562
+ },
3563
+ {
3564
+ name: "price",
3565
+ build: (b) => b.setDescription("Track a product's price: I DM you when it drops").addStringOption((o) => o.setName("url").setDescription("The product page").setRequired(true).setMaxLength(1000)).addStringOption((o) => o.setName("name").setDescription("What to call it (default: the page address)").setMaxLength(100)).addIntegerOption((o) => o.setName("hours").setDescription(`Hours between checks (default ${DEFAULT_POLL_HOURS})`).setMinValue(1).setMaxValue(MAX_POLL_HOURS)).addNumberOption((o) => o.setName("drop").setDescription("Alert on a drop of at least this percent (default 10)").setMinValue(1).setMaxValue(90)).addStringOption((o) => o.setName("baseline").setDescription("Measure the drop from which price seen (default: the last)").addChoices({ name: "the last", value: "last" }, { name: "the first", value: "first" }, { name: "the highest", value: "peak" })).addStringOption((o) => o.setName("near").setDescription("The words just before the price, if I cannot find it on my own").setMaxLength(MAX_NEAR)),
3566
+ handle: (interaction) => run(interaction, "registered", (d, user) => {
3567
+ const name = interaction.options.getString("name");
3568
+ const hours = interaction.options.getInteger("hours");
3569
+ const drop = interaction.options.getNumber("drop");
3570
+ const baseline = interaction.options.getString("baseline");
3571
+ const near = interaction.options.getString("near");
3572
+ return trackPrice(d, user, {
3573
+ url: interaction.options.getString("url", true),
3574
+ ...name !== null ? { name } : {},
3575
+ ...hours !== null ? { hours } : {},
3576
+ ...drop !== null ? { drop } : {},
3577
+ ...baseline !== null ? { baseline } : {},
3578
+ ...near !== null ? { near } : {}
3579
+ });
3580
+ })
3581
+ },
2900
3582
  {
2901
3583
  name: "tasks",
2902
3584
  build: (b) => b.setDescription("List your tasks and when each is next due"),
@@ -2904,7 +3586,7 @@ ${body}` : body);
2904
3586
  },
2905
3587
  {
2906
3588
  name: "task",
2907
- build: (b) => b.setDescription("Answer, look back at, or share one of your tasks").addSubcommand((s) => s.setName("done").setDescription("Mark the task's latest reminder done").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24))).addSubcommand((s) => s.setName("snooze").setDescription("Snooze the task's latest reminder (default: an hour)").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24)).addStringOption((o) => o.setName("until").setDescription('Until when: "in 2h", "tomorrow 9am"').setMaxLength(100))).addSubcommand((s) => s.setName("history").setDescription("Every run of the task, how it was answered, and its changes").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24))).addSubcommand((s) => s.setName("resume").setDescription("Resume a task paused because someone could not be DMed, without them").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24))).addSubcommand((s) => s.setName("share").setDescription("Ask someone to receive this task's messages too").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24)).addUserOption((o) => o.setName("user").setDescription("Who to invite").setRequired(true))),
3589
+ build: (b) => b.setDescription("Answer, look back at, or share one of your tasks").addSubcommand((s) => s.setName("done").setDescription("Mark the task's latest reminder done").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24))).addSubcommand((s) => s.setName("snooze").setDescription("Snooze the task's latest reminder (default: an hour)").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24)).addStringOption((o) => o.setName("until").setDescription('Until when: "in 2h", "tomorrow 9am"').setMaxLength(100))).addSubcommand((s) => s.setName("decide").setDescription("Answer a renewal's latest ask, with what you paid if it changed").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24)).addStringOption((o) => o.setName("choice").setDescription("Keep it, cancel it, or it has renewed").setRequired(true).addChoices({ name: "keep", value: "keep" }, { name: "cancel", value: "cancel" }, { name: "renewed", value: "renewed" })).addNumberOption((o) => o.setName("amount").setDescription("What you paid this time, if not the usual").setMinValue(0))).addSubcommand((s) => s.setName("history").setDescription("Every run of the task, how it was answered, and its changes").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24))).addSubcommand((s) => s.setName("resume").setDescription("Resume a task paused because someone could not be DMed, without them").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24))).addSubcommand((s) => s.setName("share").setDescription("Ask someone to receive this task's messages too").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24)).addUserOption((o) => o.setName("user").setDescription("Who to invite").setRequired(true))),
2908
3590
  handle: (interaction) => {
2909
3591
  const sub = interaction.options.getSubcommand();
2910
3592
  const taskId = interaction.options.getString("task", true);
@@ -2917,8 +3599,16 @@ ${body}` : body);
2917
3599
  const until = interaction.options.getString("until");
2918
3600
  return answerLatest(d, user, { taskId, kind: "snooze", ...until !== null ? { until } : {} });
2919
3601
  }
3602
+ case "decide": {
3603
+ const amount = interaction.options.getNumber("amount");
3604
+ return decideRenewal(d, user, {
3605
+ taskId,
3606
+ choice: interaction.options.getString("choice", true),
3607
+ ...amount !== null ? { amount } : {}
3608
+ });
3609
+ }
2920
3610
  case "history":
2921
- return taskHistory(d, user, taskId);
3611
+ return historyWithSeries(d, user, taskId);
2922
3612
  case "resume":
2923
3613
  return resumeTask(d, user, taskId);
2924
3614
  case "share": {
@@ -2937,6 +3627,11 @@ ${body}` : body);
2937
3627
  name: "settings",
2938
3628
  build: (b) => b.setDescription("Your tracker settings").addSubcommand((s) => s.setName("hour").setDescription("The hour (0-23) a reminder without a time reaches you").addIntegerOption((o) => o.setName("hour").setDescription("0-23, in your time zone").setRequired(true).setMinValue(0).setMaxValue(23))),
2939
3629
  handle: (interaction) => run(interaction, "registered", (d, user) => setHour(d, user, interaction.options.getInteger("hour", true)))
3630
+ },
3631
+ {
3632
+ name: "web",
3633
+ build: (b) => b.setDescription("Get a one-time link to sign in to the tracker's web area"),
3634
+ handle: (interaction) => run(interaction, "registered", async (d, user) => webLink(d, user, w.web))
2940
3635
  }
2941
3636
  ];
2942
3637
  return { commands, interactions: createInteractionHandler(ctx) };
@@ -2964,7 +3659,28 @@ function healthResponse(health) {
2964
3659
  }
2965
3660
 
2966
3661
  // plugins/tracker/src/notify-lane.ts
2967
- function laneStore(store) {
3662
+ var FETCH_TYPES = new Set(["price"]);
3663
+ function takesType(kind, type) {
3664
+ return kind === "poll" ? FETCH_TYPES.has(type) : !FETCH_TYPES.has(type);
3665
+ }
3666
+ function untilAborted(type, signal) {
3667
+ const run = type.run;
3668
+ if (!run)
3669
+ return type;
3670
+ const stop = () => new ExecutorUnavailableError("the tick was aborted; the run waits for the next one");
3671
+ return {
3672
+ ...type,
3673
+ async run(ctx) {
3674
+ if (signal.aborted)
3675
+ throw stop();
3676
+ const outcome = await run.call(type, ctx);
3677
+ if (signal.aborted)
3678
+ throw stop();
3679
+ return outcome;
3680
+ }
3681
+ };
3682
+ }
3683
+ function laneStore(store, keep = () => true) {
2968
3684
  const view = Object.create(store);
2969
3685
  view.listOccurrences = async (filter = {}) => {
2970
3686
  const found = await store.listOccurrences(filter);
@@ -2972,7 +3688,8 @@ function laneStore(store) {
2972
3688
  return found;
2973
3689
  const kept = [];
2974
3690
  for (const o of found) {
2975
- if ((await store.getTask(o.taskId))?.status === "active")
3691
+ const task = await store.getTask(o.taskId);
3692
+ if (task?.status === "active" && keep(task))
2976
3693
  kept.push(o);
2977
3694
  }
2978
3695
  return kept;
@@ -2984,7 +3701,8 @@ async function runNotifyTick(d, signal) {
2984
3701
  return { kind: "no-dm" };
2985
3702
  if (signal?.aborted)
2986
3703
  return { kind: "aborted" };
2987
- const store = laneStore(d.store);
3704
+ const kind = d.kind ?? "notify";
3705
+ const store = laneStore(d.store, (task) => takesType(kind, task.type));
2988
3706
  const notifier = createDmNotifier({
2989
3707
  store,
2990
3708
  claims: d.claims,
@@ -2994,7 +3712,11 @@ async function runNotifyTick(d, signal) {
2994
3712
  ...signal ? { signal } : {},
2995
3713
  ...d.health ? { health: d.health } : {}
2996
3714
  });
2997
- const lanes = new Lanes({ store, clock: d.clock, types: d.types, notifier });
3715
+ let types = d.types;
3716
+ if (kind === "poll" && signal) {
3717
+ types = Object.fromEntries(Object.entries(d.types).map(([id, t]) => [id, FETCH_TYPES.has(id) ? untilAborted(t, signal) : t]));
3718
+ }
3719
+ const lanes = new Lanes({ store, clock: d.clock, types, notifier, ...kind === "poll" && d.fetch ? { fetch: d.fetch } : {} });
2998
3720
  const result = await lanes.tickNotify();
2999
3721
  return signal?.aborted ? { kind: "aborted" } : { kind: "ran", result };
3000
3722
  }
@@ -3150,6 +3872,28 @@ var MIGRATIONS = [
3150
3872
  at TEXT NOT NULL,
3151
3873
  PRIMARY KEY (task_id, user_id)
3152
3874
  );
3875
+ `,
3876
+ `
3877
+ CREATE TABLE web_login_tokens (
3878
+ token_hash TEXT PRIMARY KEY,
3879
+ user_id TEXT NOT NULL,
3880
+ created_at TEXT NOT NULL,
3881
+ expires_at TEXT NOT NULL,
3882
+ used_at TEXT,
3883
+ member_checked_at TEXT
3884
+ );
3885
+ CREATE INDEX web_login_tokens_expiry ON web_login_tokens (expires_at);
3886
+
3887
+ CREATE TABLE web_sessions (
3888
+ id_hash TEXT PRIMARY KEY,
3889
+ user_id TEXT NOT NULL,
3890
+ csrf TEXT NOT NULL,
3891
+ created_at TEXT NOT NULL,
3892
+ expires_at TEXT NOT NULL,
3893
+ member_checked_at TEXT
3894
+ );
3895
+ CREATE INDEX web_sessions_user ON web_sessions (user_id);
3896
+ CREATE INDEX web_sessions_expiry ON web_sessions (expires_at);
3153
3897
  `
3154
3898
  ];
3155
3899
  function migrate(db) {
@@ -3493,63 +4237,1799 @@ class SqliteStore {
3493
4237
  }
3494
4238
  }
3495
4239
 
3496
- // plugins/tracker/src/index.ts
3497
- var DB_DIR = "tracker";
3498
- var DB_FILE = "tracker.sqlite";
3499
- var TRACKER_TYPES = Object.freeze({
3500
- reminder,
3501
- renewal
3502
- });
3503
- var NO_DM = {
3504
- async sendDm() {
3505
- throw new Error("this bot has no host.dm (rackbops-discord-bot#736)");
4240
+ // node_modules/@rackbops/styles/rackbops-noir/bundle.css
4241
+ 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 {
4242
+ box-sizing: border-box;
4243
+ }
4244
+
4245
+ :where(html[data-rb-style]) body {
4246
+ min-height: 100vh;
4247
+ margin: 0;
4248
+ }
4249
+
4250
+ :where([data-rb-style="rackbops-noir"]) {
4251
+ color-scheme: dark;
4252
+ --rb-bg: #0d141d;
4253
+ --rb-surface: #141f2a;
4254
+ --rb-surface-2: #19242f;
4255
+ --rb-surface-sunken: #0f1720;
4256
+ --rb-text: #e9eff4;
4257
+ --rb-text-soft: #a4b4c3;
4258
+ --rb-text-faint: #697a8c;
4259
+ --rb-border: #243140;
4260
+ --rb-border-strong: #31404f;
4261
+ --rb-accent: #ff5d3d;
4262
+ --rb-accent-fg: #0d141d;
4263
+ --rb-accent-wash: #ff5d3d21;
4264
+ --rb-accent-grad: linear-gradient(180deg, #ff5d3d, #ff7a5f);
4265
+ --rb-info: #4c9df0;
4266
+ --rb-success: #43c97e;
4267
+ --rb-warning: #f2a93b;
4268
+ --rb-danger: #f0616d;
4269
+ --rb-font-display: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
4270
+ --rb-font-body: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
4271
+ --rb-font-mono: ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, Consolas, monospace;
4272
+ --rb-font-weight: 400;
4273
+ --rb-font-weight-medium: 600;
4274
+ --rb-font-weight-bold: 800;
4275
+ --rb-heading-tracking: -.02em;
4276
+ --rb-label-tracking: .18em;
4277
+ --rb-text-sm: 13.5px;
4278
+ --rb-radius: .6875rem;
4279
+ --rb-radius-lg: .9375rem;
4280
+ --rb-radius-pill: 99px;
4281
+ --rb-shadow-sm: 0 1px 2px #00000059;
4282
+ --rb-shadow-lg: 0 2px 6px #0006, 0 24px 50px -24px #000000b3;
4283
+ --rb-blur: 10px;
4284
+ --rb-transition: .15s;
4285
+ --rb-focus-ring: 2.5px solid var(--rb-accent);
4286
+ --rb-ease: ease;
4287
+ --rb-space-1: .5rem;
4288
+ --rb-space-2: .75rem;
4289
+ --rb-space-3: 1rem;
4290
+ --rb-space-4: 1.5rem;
4291
+ --rb-space-5: 2.25rem;
4292
+ }
4293
+
4294
+ @media (prefers-reduced-motion: reduce) {
4295
+ :where([data-rb-style="rackbops-noir"]) {
4296
+ --rb-transition: 0s;
3506
4297
  }
3507
- };
3508
- function createPlugin(host, options = {}) {
3509
- const adminIds = parseAdminIds(host.env.TRACKER_ADMIN_DISCORD_IDS);
3510
- const guildId = parseGuildId(host.env.TRACKER_GUILD_ID);
3511
- const clock = options.clock ?? { now: () => new Date };
3512
- const types = options.types ?? TRACKER_TYPES;
3513
- const health = { activatedAt: null, lastTickAt: null, blocked: null };
3514
- let db = null;
3515
- let store = null;
3516
- let claims = null;
3517
- let delivery = null;
3518
- let deps = null;
3519
- let warnedNoDm = false;
3520
- const dm = host.dm?.bind(host);
3521
- const surface = createSurface({
3522
- deps: () => deps,
3523
- guildId,
3524
- log: host.log,
3525
- ...options.membership ? { membership: options.membership } : {}
3526
- });
3527
- return {
3528
- commands: surface.commands,
3529
- interactions: surface.interactions,
3530
- async activate() {
3531
- let path = options.dbPath;
3532
- if (path === undefined) {
3533
- const dir = join(host.dataDir, DB_DIR);
3534
- mkdirSync(dir, { recursive: true });
3535
- path = join(dir, DB_FILE);
3536
- }
3537
- const opened = openDatabase(path);
3538
- const openedStore = new SqliteStore(opened);
3539
- const openedClaims = new ClaimStore(opened);
3540
- try {
3541
- const granted = await seedAdmins(openedStore, adminIds, clock.now());
3542
- const admissions = new Admissions(opened);
3543
- for (const id of adminIds) {
3544
- const admin = await openedStore.findUserByDiscordId(id);
3545
- if (admin)
3546
- admissions.record(admin.id, null, clock.now().toISOString());
3547
- }
3548
- if (granted.length > 0)
3549
- host.log.info(`made ${granted.length} admin(s) from TRACKER_ADMIN_DISCORD_IDS`);
3550
- const requeued = await openedStore.requeueRunning();
3551
- if (requeued.length > 0)
3552
- host.log.warn(`requeued ${requeued.length} occurrence(s) left running: ${requeued.join(", ")}`);
4298
+ }
4299
+
4300
+ :where([data-rb-style="rackbops-noir"]) {
4301
+ background-color: var(--rb-bg);
4302
+ color: var(--rb-text);
4303
+ font-family: var(--rb-font-body);
4304
+ font-weight: var(--rb-font-weight);
4305
+ font-size: 17px;
4306
+ line-height: 1.6;
4307
+ }
4308
+
4309
+ :where(html[data-rb-style="rackbops-noir"]) body {
4310
+ background-color: var(--rb-bg);
4311
+ color: var(--rb-text);
4312
+ font-family: var(--rb-font-body);
4313
+ font-weight: var(--rb-font-weight);
4314
+ }
4315
+
4316
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *):where(h1, h2, h3, h4, h5, h6) {
4317
+ margin: 0 0 var(--rb-space-3);
4318
+ font-family: var(--rb-font-display);
4319
+ font-weight: var(--rb-font-weight-bold);
4320
+ letter-spacing: var(--rb-heading-tracking);
4321
+ text-wrap: balance;
4322
+ line-height: 1.08;
4323
+ }
4324
+
4325
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *):where(p) {
4326
+ margin: 0 0 var(--rb-space-3);
4327
+ }
4328
+
4329
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *):where(ul, ol) {
4330
+ margin: 0 0 var(--rb-space-3);
4331
+ padding-inline-start: var(--rb-space-4);
4332
+ }
4333
+
4334
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *):where(a) {
4335
+ color: var(--rb-accent);
4336
+ }
4337
+
4338
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *):where(:focus-visible) {
4339
+ outline: var(--rb-focus-ring);
4340
+ outline-offset: 3px;
4341
+ border-radius: 3px;
4342
+ }
4343
+
4344
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *)::selection {
4345
+ background: var(--rb-accent);
4346
+ color: #fff;
4347
+ }
4348
+
4349
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-btn {
4350
+ align-items: center;
4351
+ gap: var(--rb-space-2);
4352
+ border-radius: var(--rb-radius);
4353
+ font-weight: var(--rb-font-weight-medium);
4354
+ cursor: pointer;
4355
+ color: var(--rb-text);
4356
+ transition: transform var(--rb-transition) var(--rb-ease),
4357
+ background var(--rb-transition) var(--rb-ease),
4358
+ border-color var(--rb-transition) var(--rb-ease),
4359
+ color var(--rb-transition) var(--rb-ease);
4360
+ background: none;
4361
+ border: 1px solid #0000;
4362
+ padding: 13px 22px;
4363
+ font-size: 16px;
4364
+ text-decoration: none;
4365
+ display: inline-flex;
4366
+ }
4367
+
4368
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-btn--sm {
4369
+ font-size: var(--rb-text-sm);
4370
+ padding: 7px 14px;
4371
+ }
4372
+
4373
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-icon-btn {
4374
+ padding: var(--rb-space-2);
4375
+ justify-content: center;
4376
+ gap: 0;
4377
+ min-width: 2rem;
4378
+ min-height: 2rem;
4379
+ line-height: 1;
4380
+ }
4381
+
4382
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-btn:active {
4383
+ transform: translateY(1px);
4384
+ }
4385
+
4386
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-btn--primary {
4387
+ background: var(--rb-text);
4388
+ color: var(--rb-bg);
4389
+ }
4390
+
4391
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-btn--primary:not(:disabled):hover, :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-btn--primary:focus-visible, :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-btn--accent {
4392
+ background: var(--rb-accent);
4393
+ color: var(--rb-accent-fg);
4394
+ }
4395
+
4396
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-btn--accent:not(:disabled):hover, :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-btn--accent:focus-visible {
4397
+ background: color-mix(in srgb, var(--rb-accent), var(--rb-text) 15%);
4398
+ }
4399
+
4400
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-btn--ghost {
4401
+ color: var(--rb-text);
4402
+ border-color: var(--rb-border-strong);
4403
+ background: none;
4404
+ }
4405
+
4406
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-btn--ghost:not(:disabled):hover, :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-btn--ghost:focus-visible {
4407
+ border-color: var(--rb-text);
4408
+ background: var(--rb-surface-2);
4409
+ }
4410
+
4411
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-btn--danger {
4412
+ color: var(--rb-danger);
4413
+ border-color: color-mix(in srgb, var(--rb-danger) 45%, var(--rb-border));
4414
+ background: none;
4415
+ }
4416
+
4417
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-btn--danger:not(:disabled):hover, :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-btn--danger:focus-visible {
4418
+ border-color: var(--rb-danger);
4419
+ }
4420
+
4421
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-btn:disabled {
4422
+ opacity: .5;
4423
+ cursor: not-allowed;
4424
+ }
4425
+
4426
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-btn__arrow {
4427
+ transition: transform var(--rb-transition) var(--rb-ease);
4428
+ }
4429
+
4430
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-btn:not(:disabled):hover .rb-btn__arrow {
4431
+ transform: translateX(3px);
4432
+ }
4433
+
4434
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-card {
4435
+ background: var(--rb-surface);
4436
+ border: 1px solid var(--rb-border);
4437
+ border-radius: var(--rb-radius-lg);
4438
+ box-shadow: var(--rb-shadow-sm);
4439
+ transition: transform var(--rb-transition) var(--rb-ease),
4440
+ border-color var(--rb-transition) var(--rb-ease),
4441
+ box-shadow var(--rb-transition) var(--rb-ease);
4442
+ flex-direction: column;
4443
+ padding: 26px 24px 28px;
4444
+ display: flex;
4445
+ }
4446
+
4447
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-card:hover, :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-card--raised {
4448
+ border-color: var(--rb-border-strong);
4449
+ box-shadow: var(--rb-shadow-lg);
4450
+ transform: translateY(-4px);
4451
+ }
4452
+
4453
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-card--raised:hover {
4454
+ transform: translateY(-6px);
4455
+ }
4456
+
4457
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-card__tag {
4458
+ font-family: var(--rb-font-mono);
4459
+ letter-spacing: .12em;
4460
+ text-transform: uppercase;
4461
+ color: var(--rb-accent);
4462
+ justify-content: space-between;
4463
+ align-items: center;
4464
+ font-size: 12px;
4465
+ display: flex;
4466
+ }
4467
+
4468
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-link {
4469
+ align-items: center;
4470
+ gap: var(--rb-space-1);
4471
+ font-family: var(--rb-font-mono);
4472
+ font-size: var(--rb-text-sm);
4473
+ color: var(--rb-text-soft);
4474
+ cursor: pointer;
4475
+ transition: color var(--rb-transition) var(--rb-ease),
4476
+ background var(--rb-transition) var(--rb-ease);
4477
+ border-radius: 8px;
4478
+ padding: 8px 12px;
4479
+ text-decoration: none;
4480
+ display: inline-flex;
4481
+ }
4482
+
4483
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-link:hover {
4484
+ color: var(--rb-text);
4485
+ background: var(--rb-surface-2);
4486
+ }
4487
+
4488
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-link.rb-link--active, :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-link[aria-current="page"] {
4489
+ color: var(--rb-accent);
4490
+ }
4491
+
4492
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-nav-rail {
4493
+ gap: var(--rb-space-1);
4494
+ width: 14rem;
4495
+ padding: var(--rb-space-3);
4496
+ background: var(--rb-surface);
4497
+ border-right: 1px solid var(--rb-border);
4498
+ flex-direction: column;
4499
+ flex-shrink: 0;
4500
+ display: flex;
4501
+ }
4502
+
4503
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-field {
4504
+ gap: var(--rb-space-1);
4505
+ flex-direction: column;
4506
+ display: flex;
4507
+ }
4508
+
4509
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-label {
4510
+ font-size: var(--rb-text-sm);
4511
+ font-weight: var(--rb-font-weight-medium);
4512
+ color: var(--rb-text-soft);
4513
+ }
4514
+
4515
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-label__required {
4516
+ color: var(--rb-danger);
4517
+ margin-left: var(--rb-space-1);
4518
+ }
4519
+
4520
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-field__help {
4521
+ color: var(--rb-text-soft);
4522
+ font-size: var(--rb-text-sm);
4523
+ margin-top: var(--rb-space-1);
4524
+ }
4525
+
4526
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-field__error {
4527
+ color: var(--rb-text);
4528
+ font-size: var(--rb-text-sm);
4529
+ padding-left: var(--rb-space-2);
4530
+ border-left: 3px solid var(--rb-danger);
4531
+ margin-top: var(--rb-space-1);
4532
+ }
4533
+
4534
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-input, :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-textarea, :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-select {
4535
+ background: var(--rb-surface);
4536
+ border: 1px solid var(--rb-border-strong);
4537
+ border-radius: var(--rb-radius);
4538
+ width: 100%;
4539
+ color: var(--rb-text);
4540
+ font-family: var(--rb-font-body);
4541
+ transition: border-color var(--rb-transition) var(--rb-ease),
4542
+ box-shadow var(--rb-transition) var(--rb-ease);
4543
+ padding: 12px 14px;
4544
+ font-size: 16px;
4545
+ }
4546
+
4547
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-textarea {
4548
+ resize: vertical;
4549
+ min-height: 6rem;
4550
+ }
4551
+
4552
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-input:focus-visible, :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-textarea:focus-visible, :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-select:focus-visible {
4553
+ border-color: var(--rb-accent);
4554
+ box-shadow: 0 0 0 3px var(--rb-accent-wash);
4555
+ outline: none;
4556
+ }
4557
+
4558
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-input[aria-invalid="true"], :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-textarea[aria-invalid="true"], :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-select[aria-invalid="true"] {
4559
+ border-color: var(--rb-danger);
4560
+ }
4561
+
4562
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-input::placeholder, :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-textarea::placeholder {
4563
+ color: var(--rb-text-faint);
4564
+ }
4565
+
4566
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-choice {
4567
+ align-items: center;
4568
+ gap: var(--rb-space-1);
4569
+ color: var(--rb-text);
4570
+ cursor: pointer;
4571
+ font-size: 16px;
4572
+ display: inline-flex;
4573
+ }
4574
+
4575
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-checkbox, :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-radio {
4576
+ accent-color: var(--rb-accent);
4577
+ cursor: pointer;
4578
+ width: 1.1rem;
4579
+ height: 1.1rem;
4580
+ }
4581
+
4582
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-switch {
4583
+ appearance: none;
4584
+ vertical-align: text-bottom;
4585
+ cursor: pointer;
4586
+ background: var(--rb-surface-sunken);
4587
+ border: 1px solid var(--rb-border-strong);
4588
+ border-radius: var(--rb-radius-pill);
4589
+ width: 2.25rem;
4590
+ height: 1.25rem;
4591
+ transition: background var(--rb-transition) var(--rb-ease), border-color var(--rb-transition) var(--rb-ease);
4592
+ margin: 0;
4593
+ position: relative;
4594
+ }
4595
+
4596
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-switch:after {
4597
+ content: "";
4598
+ background: var(--rb-text-soft);
4599
+ width: calc(1.25rem - 6px);
4600
+ height: calc(1.25rem - 6px);
4601
+ transition: transform var(--rb-transition) var(--rb-ease), background var(--rb-transition) var(--rb-ease);
4602
+ border-radius: 50%;
4603
+ position: absolute;
4604
+ top: 2px;
4605
+ left: 2px;
4606
+ }
4607
+
4608
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-switch:checked {
4609
+ background: var(--rb-accent);
4610
+ border-color: var(--rb-accent);
4611
+ }
4612
+
4613
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-switch:checked:after {
4614
+ background: var(--rb-accent-fg);
4615
+ transform: translateX(1rem);
4616
+ }
4617
+
4618
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-switch:disabled {
4619
+ cursor: not-allowed;
4620
+ opacity: .5;
4621
+ }
4622
+
4623
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-badge {
4624
+ align-items: center;
4625
+ gap: var(--rb-space-1);
4626
+ border: 1px solid var(--rb-border);
4627
+ border-radius: var(--rb-radius-pill);
4628
+ font-family: var(--rb-font-mono);
4629
+ letter-spacing: .02em;
4630
+ color: var(--rb-text-soft);
4631
+ background: var(--rb-surface);
4632
+ padding: 4px 10px;
4633
+ font-size: 11.5px;
4634
+ display: inline-flex;
4635
+ }
4636
+
4637
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-badge--md {
4638
+ font-size: var(--rb-text-sm);
4639
+ padding: var(--rb-space-1) var(--rb-space-3);
4640
+ }
4641
+
4642
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-badge--info {
4643
+ background: color-mix(in srgb, var(--rb-info) 14%, var(--rb-surface));
4644
+ border-color: color-mix(in srgb, var(--rb-info) 45%, var(--rb-border));
4645
+ color: var(--rb-text);
4646
+ }
4647
+
4648
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-badge--success {
4649
+ background: color-mix(in srgb, var(--rb-success) 14%, var(--rb-surface));
4650
+ border-color: color-mix(in srgb, var(--rb-success) 45%, var(--rb-border));
4651
+ color: var(--rb-text);
4652
+ }
4653
+
4654
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-badge--warning {
4655
+ background: color-mix(in srgb, var(--rb-warning) 16%, var(--rb-surface));
4656
+ border-color: color-mix(in srgb, var(--rb-warning) 45%, var(--rb-border));
4657
+ color: var(--rb-text);
4658
+ }
4659
+
4660
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-badge--danger {
4661
+ background: color-mix(in srgb, var(--rb-danger) 14%, var(--rb-surface));
4662
+ border-color: color-mix(in srgb, var(--rb-danger) 45%, var(--rb-border));
4663
+ color: var(--rb-text);
4664
+ }
4665
+
4666
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-alert {
4667
+ gap: var(--rb-space-1);
4668
+ padding: var(--rb-space-3) var(--rb-space-4);
4669
+ background: var(--rb-surface);
4670
+ border: 1px solid var(--rb-border);
4671
+ border-left: 4px solid var(--rb-accent);
4672
+ border-radius: var(--rb-radius);
4673
+ box-shadow: var(--rb-shadow-sm);
4674
+ color: var(--rb-text);
4675
+ flex-direction: column;
4676
+ display: flex;
4677
+ }
4678
+
4679
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-alert__title {
4680
+ font-weight: var(--rb-font-weight-medium);
4681
+ }
4682
+
4683
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-alert--info {
4684
+ border-left-color: var(--rb-info);
4685
+ }
4686
+
4687
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-alert--success {
4688
+ border-left-color: var(--rb-success);
4689
+ }
4690
+
4691
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-alert--warning {
4692
+ border-left-color: var(--rb-warning);
4693
+ }
4694
+
4695
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-alert--danger {
4696
+ border-left-color: var(--rb-danger);
4697
+ }
4698
+
4699
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-toast-region {
4700
+ top: var(--rb-space-4);
4701
+ right: var(--rb-space-4);
4702
+ z-index: 1000;
4703
+ gap: var(--rb-space-2);
4704
+ width: fit-content;
4705
+ max-width: min(28rem, calc(100vw - 2 * var(--rb-space-4)));
4706
+ pointer-events: none;
4707
+ flex-direction: column;
4708
+ display: flex;
4709
+ position: fixed;
4710
+ }
4711
+
4712
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-toast {
4713
+ pointer-events: auto;
4714
+ align-items: flex-start;
4715
+ gap: var(--rb-space-3);
4716
+ padding: var(--rb-space-3) var(--rb-space-4);
4717
+ background: var(--rb-surface);
4718
+ border: 1px solid var(--rb-border);
4719
+ border-left: 4px solid var(--rb-accent);
4720
+ border-radius: var(--rb-radius);
4721
+ box-shadow: var(--rb-shadow-lg);
4722
+ color: var(--rb-text);
4723
+ transition: opacity var(--rb-transition) var(--rb-ease),
4724
+ transform var(--rb-transition) var(--rb-ease);
4725
+ display: flex;
4726
+ }
4727
+
4728
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-toast[data-rb-enter] {
4729
+ opacity: 0;
4730
+ transform: translateY(calc(-1 * var(--rb-space-2)));
4731
+ }
4732
+
4733
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-toast--info {
4734
+ border-left-color: var(--rb-info);
4735
+ }
4736
+
4737
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-toast--success {
4738
+ border-left-color: var(--rb-success);
4739
+ }
4740
+
4741
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-toast--warning {
4742
+ border-left-color: var(--rb-warning);
4743
+ }
4744
+
4745
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-toast--danger {
4746
+ border-left-color: var(--rb-danger);
4747
+ }
4748
+
4749
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-toast__close {
4750
+ border-radius: var(--rb-radius);
4751
+ width: 1.25rem;
4752
+ height: 1.25rem;
4753
+ color: var(--rb-text-soft);
4754
+ cursor: pointer;
4755
+ transition: color var(--rb-transition) var(--rb-ease);
4756
+ background: none;
4757
+ border: none;
4758
+ flex: none;
4759
+ justify-content: center;
4760
+ align-items: center;
4761
+ margin-inline-start: auto;
4762
+ padding: 0;
4763
+ font-size: 1.125rem;
4764
+ line-height: 1;
4765
+ display: inline-flex;
4766
+ }
4767
+
4768
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-toast__close:hover {
4769
+ color: var(--rb-text);
4770
+ }
4771
+
4772
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-toast__close:focus-visible {
4773
+ outline: var(--rb-focus-ring);
4774
+ outline-offset: 2px;
4775
+ }
4776
+
4777
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-dialog {
4778
+ border: 1px solid var(--rb-border);
4779
+ border-radius: var(--rb-radius-lg);
4780
+ background: var(--rb-surface);
4781
+ color: var(--rb-text);
4782
+ box-shadow: var(--rb-shadow-lg);
4783
+ max-width: min(92vw, 34rem);
4784
+ padding: 0;
4785
+ }
4786
+
4787
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-dialog::backdrop {
4788
+ -webkit-backdrop-filter: blur(var(--rb-blur));
4789
+ background: #131e2966;
4790
+ }
4791
+
4792
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-dialog__body {
4793
+ padding: var(--rb-space-5);
4794
+ }
4795
+
4796
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-dialog__title {
4797
+ margin: 0 0 var(--rb-space-3);
4798
+ font-family: var(--rb-font-display);
4799
+ font-weight: var(--rb-font-weight-bold);
4800
+ letter-spacing: var(--rb-heading-tracking);
4801
+ font-size: 1.4rem;
4802
+ }
4803
+
4804
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-dialog__actions {
4805
+ justify-content: flex-end;
4806
+ gap: var(--rb-space-2);
4807
+ padding: var(--rb-space-3) var(--rb-space-5);
4808
+ border-top: 1px solid var(--rb-border);
4809
+ display: flex;
4810
+ }
4811
+
4812
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-tabs {
4813
+ gap: var(--rb-space-4);
4814
+ border-bottom: 1px solid var(--rb-border);
4815
+ display: flex;
4816
+ }
4817
+
4818
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-tab {
4819
+ padding: var(--rb-space-2) 0;
4820
+ color: var(--rb-text-soft);
4821
+ font-family: var(--rb-font-body);
4822
+ font-weight: var(--rb-font-weight-medium);
4823
+ cursor: pointer;
4824
+ transition: color var(--rb-transition) var(--rb-ease);
4825
+ background: none;
4826
+ border: 0;
4827
+ font-size: 15px;
4828
+ position: relative;
4829
+ }
4830
+
4831
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-tab:not(:disabled):hover {
4832
+ color: var(--rb-text);
4833
+ }
4834
+
4835
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-tab:disabled {
4836
+ opacity: .5;
4837
+ cursor: not-allowed;
4838
+ }
4839
+
4840
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-tab.rb-tab--active, :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-tab[aria-selected="true"] {
4841
+ color: var(--rb-text);
4842
+ }
4843
+
4844
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-tab.rb-tab--active:after, :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-tab[aria-selected="true"]:after {
4845
+ content: "";
4846
+ background: var(--rb-accent);
4847
+ border-radius: 2px 2px 0 0;
4848
+ height: 2px;
4849
+ position: absolute;
4850
+ bottom: -1px;
4851
+ left: 0;
4852
+ right: 0;
4853
+ }
4854
+
4855
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-tabpanel {
4856
+ padding: var(--rb-space-4) 0;
4857
+ }
4858
+
4859
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-tabstrip {
4860
+ gap: var(--rb-space-2);
4861
+ padding: var(--rb-space-2) 0;
4862
+ display: flex;
4863
+ }
4864
+
4865
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-tabstrip__tab {
4866
+ padding: var(--rb-space-2) var(--rb-space-4);
4867
+ background: var(--rb-surface-2);
4868
+ border: 1px solid var(--rb-border);
4869
+ border-radius: var(--rb-radius);
4870
+ color: var(--rb-text);
4871
+ font-size: var(--rb-text-sm);
4872
+ font-weight: var(--rb-font-weight-medium);
4873
+ cursor: pointer;
4874
+ transition: border-color var(--rb-transition) var(--rb-ease);
4875
+ position: relative;
4876
+ }
4877
+
4878
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-tabstrip__tab:not(:disabled):hover {
4879
+ border-color: var(--rb-accent);
4880
+ }
4881
+
4882
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-tabstrip__tab:disabled {
4883
+ opacity: .5;
4884
+ cursor: not-allowed;
4885
+ }
4886
+
4887
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-tabstrip__tab.rb-tabstrip__tab--active {
4888
+ color: var(--rb-accent);
4889
+ border-color: color-mix(in srgb, var(--rb-accent) 40%, var(--rb-border));
4890
+ background: var(--rb-accent-wash);
4891
+ }
4892
+
4893
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-tabstrip__badge {
4894
+ color: var(--rb-text-faint);
4895
+ font-size: var(--rb-text-sm);
4896
+ font-variant-numeric: tabular-nums;
4897
+ }
4898
+
4899
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-wordmark {
4900
+ width: fit-content;
4901
+ font-family: var(--rb-font-display);
4902
+ font-weight: var(--rb-font-weight-medium);
4903
+ letter-spacing: var(--rb-heading-tracking);
4904
+ color: var(--rb-text);
4905
+ align-items: baseline;
4906
+ gap: .4em;
4907
+ margin: 0;
4908
+ font-size: 1rem;
4909
+ display: inline-flex;
4910
+ }
4911
+
4912
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-wordmark__spark {
4913
+ color: var(--rb-accent);
4914
+ }
4915
+
4916
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-table {
4917
+ border-collapse: collapse;
4918
+ width: 100%;
4919
+ font-size: 15px;
4920
+ }
4921
+
4922
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-table th {
4923
+ text-align: left;
4924
+ padding: var(--rb-space-2) var(--rb-space-3);
4925
+ border-bottom: 2px solid var(--rb-border-strong);
4926
+ color: var(--rb-text-soft);
4927
+ font-family: var(--rb-font-mono);
4928
+ letter-spacing: .06em;
4929
+ text-transform: uppercase;
4930
+ font-size: 12px;
4931
+ font-weight: var(--rb-font-weight-medium);
4932
+ }
4933
+
4934
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-table td {
4935
+ padding: var(--rb-space-2) var(--rb-space-3);
4936
+ border-bottom: 1px solid var(--rb-border);
4937
+ color: var(--rb-text);
4938
+ }
4939
+
4940
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-table tbody tr:hover td {
4941
+ background: var(--rb-surface-2);
4942
+ }
4943
+
4944
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-table .rb-num {
4945
+ text-align: right;
4946
+ font-variant-numeric: tabular-nums;
4947
+ }
4948
+
4949
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-table__group-row td {
4950
+ padding-top: var(--rb-space-3);
4951
+ padding-bottom: var(--rb-space-1);
4952
+ color: var(--rb-text-faint);
4953
+ font-size: var(--rb-text-sm);
4954
+ font-weight: var(--rb-font-weight-medium);
4955
+ text-transform: uppercase;
4956
+ letter-spacing: .04em;
4957
+ border-bottom: none;
4958
+ }
4959
+
4960
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-table__sort {
4961
+ align-items: center;
4962
+ gap: var(--rb-space-1);
4963
+ font: inherit;
4964
+ color: inherit;
4965
+ cursor: pointer;
4966
+ background: none;
4967
+ border: none;
4968
+ margin: 0;
4969
+ padding: 0;
4970
+ display: inline-flex;
4971
+ }
4972
+
4973
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-table__sort:hover {
4974
+ color: var(--rb-text);
4975
+ }
4976
+
4977
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-table__sort-icon {
4978
+ color: var(--rb-text-faint);
4979
+ }
4980
+
4981
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-table__sort:hover .rb-table__sort-icon {
4982
+ color: var(--rb-text);
4983
+ }
4984
+
4985
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-table-scroll {
4986
+ max-height: 70vh;
4987
+ overflow: auto;
4988
+ }
4989
+
4990
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-table-scroll thead th {
4991
+ background: var(--rb-surface);
4992
+ z-index: 1;
4993
+ position: sticky;
4994
+ top: 0;
4995
+ }
4996
+
4997
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-table--interactive tbody tr {
4998
+ cursor: pointer;
4999
+ }
5000
+
5001
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-table--interactive tbody tr:focus-visible, :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-table--interactive tbody tr:has(:focus-visible) {
5002
+ outline: var(--rb-focus-ring);
5003
+ outline-offset: -3px;
5004
+ border-radius: 0;
5005
+ }
5006
+
5007
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-table--interactive tbody tr:focus-visible td, :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-table--interactive tbody tr:has(:focus-visible) td {
5008
+ background: var(--rb-surface-2);
5009
+ }
5010
+
5011
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-progress {
5012
+ appearance: none;
5013
+ border-radius: var(--rb-radius-pill);
5014
+ background: var(--rb-surface-sunken);
5015
+ border: 0;
5016
+ width: 100%;
5017
+ height: 8px;
5018
+ overflow: hidden;
5019
+ }
5020
+
5021
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-progress::-webkit-progress-bar {
5022
+ background: var(--rb-surface-sunken);
5023
+ }
5024
+
5025
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-progress::-webkit-progress-value {
5026
+ background: var(--rb-accent);
5027
+ }
5028
+
5029
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-progress::-moz-progress-bar {
5030
+ background: var(--rb-accent);
5031
+ }
5032
+
5033
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-progress:indeterminate::-webkit-progress-bar {
5034
+ background-color: var(--rb-surface-sunken);
5035
+ background-image: linear-gradient(90deg, transparent 0%, var(--rb-accent) 50%, transparent 100%);
5036
+ background-repeat: no-repeat;
5037
+ background-size: 50% 100%;
5038
+ animation: 1.4s ease-in-out infinite rb-noir-indeterminate;
5039
+ }
5040
+
5041
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-progress:indeterminate::-moz-progress-bar {
5042
+ background-color: var(--rb-surface-sunken);
5043
+ background-image: linear-gradient(90deg, transparent 0%, var(--rb-accent) 50%, transparent 100%);
5044
+ background-repeat: no-repeat;
5045
+ background-size: 50% 100%;
5046
+ animation: 1.4s ease-in-out infinite rb-noir-indeterminate;
5047
+ }
5048
+
5049
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-spinner {
5050
+ border: 2px solid var(--rb-border-strong);
5051
+ border-top-color: var(--rb-accent);
5052
+ border-radius: 50%;
5053
+ width: 1.25rem;
5054
+ height: 1.25rem;
5055
+ animation: .7s linear infinite rb-noir-spin;
5056
+ display: inline-block;
5057
+ }
5058
+
5059
+ @keyframes rb-noir-spin {
5060
+ to {
5061
+ transform: rotate(360deg);
5062
+ }
5063
+ }
5064
+
5065
+ @keyframes rb-noir-indeterminate {
5066
+ 0% {
5067
+ background-position: -50% 0;
5068
+ }
5069
+
5070
+ 100% {
5071
+ background-position: 150% 0;
5072
+ }
5073
+ }
5074
+
5075
+ @media (prefers-reduced-motion: reduce) {
5076
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-progress:indeterminate::-webkit-progress-bar {
5077
+ animation-duration: 2s;
5078
+ }
5079
+
5080
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-progress:indeterminate::-moz-progress-bar {
5081
+ animation-duration: 2s;
5082
+ }
5083
+
5084
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-spinner {
5085
+ animation-duration: 2s;
5086
+ }
5087
+ }
5088
+
5089
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-stepper {
5090
+ isolation: isolate;
5091
+ margin: 0;
5092
+ padding: 0;
5093
+ list-style: none;
5094
+ display: flex;
5095
+ }
5096
+
5097
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-stepper__step {
5098
+ align-items: center;
5099
+ gap: var(--rb-space-2);
5100
+ text-align: center;
5101
+ flex-direction: column;
5102
+ flex: 1;
5103
+ min-width: 0;
5104
+ display: flex;
5105
+ position: relative;
5106
+ }
5107
+
5108
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-stepper__step:not(:first-child):before {
5109
+ content: "";
5110
+ background: var(--rb-surface-sunken);
5111
+ z-index: -1;
5112
+ width: 100%;
5113
+ height: 2px;
5114
+ transition: background-color var(--rb-transition) var(--rb-ease);
5115
+ position: absolute;
5116
+ top: 1rem;
5117
+ right: 50%;
5118
+ }
5119
+
5120
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-stepper__step:not(:first-child).rb-stepper--complete:before, :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-stepper__step:not(:first-child).rb-stepper--current:before, :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-stepper__step[aria-current="step"]:before {
5121
+ background: var(--rb-accent);
5122
+ }
5123
+
5124
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-stepper__node {
5125
+ border-radius: var(--rb-radius-pill);
5126
+ background: var(--rb-surface-2);
5127
+ width: 2rem;
5128
+ height: 2rem;
5129
+ color: var(--rb-text-faint);
5130
+ font-size: var(--rb-text-sm);
5131
+ font-weight: var(--rb-font-weight-medium);
5132
+ border: 2px solid #0000;
5133
+ justify-content: center;
5134
+ align-items: center;
5135
+ display: flex;
5136
+ }
5137
+
5138
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-stepper__label {
5139
+ font-size: var(--rb-text-sm);
5140
+ color: var(--rb-text-faint);
5141
+ }
5142
+
5143
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-stepper--complete .rb-stepper__node {
5144
+ background: var(--rb-accent);
5145
+ color: var(--rb-accent-fg);
5146
+ }
5147
+
5148
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-stepper--complete .rb-stepper__label {
5149
+ color: var(--rb-text);
5150
+ }
5151
+
5152
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-stepper--current .rb-stepper__node, :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-stepper__step[aria-current="step"] .rb-stepper__node {
5153
+ background: var(--rb-surface);
5154
+ border-color: var(--rb-accent);
5155
+ color: var(--rb-accent);
5156
+ box-shadow: var(--rb-shadow-lg);
5157
+ }
5158
+
5159
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-stepper--current .rb-stepper__label, :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-stepper__step[aria-current="step"] .rb-stepper__label {
5160
+ color: var(--rb-accent);
5161
+ font-weight: var(--rb-font-weight-medium);
5162
+ }
5163
+
5164
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-muted {
5165
+ color: var(--rb-text-faint);
5166
+ font-style: italic;
5167
+ }
5168
+
5169
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-pre {
5170
+ padding: var(--rb-space-3) var(--rb-space-4);
5171
+ background: var(--rb-surface-sunken);
5172
+ border: 1px solid var(--rb-border);
5173
+ border-radius: var(--rb-radius);
5174
+ font-family: var(--rb-font-mono);
5175
+ font-size: var(--rb-text-sm);
5176
+ margin: 0;
5177
+ overflow-x: auto;
5178
+ }
5179
+
5180
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-log {
5181
+ white-space: pre-wrap;
5182
+ overflow-wrap: anywhere;
5183
+ max-height: 16rem;
5184
+ line-height: 1.5;
5185
+ overflow-y: auto;
5186
+ }
5187
+
5188
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-pre.rb-log {
5189
+ overflow-x: hidden;
5190
+ }
5191
+
5192
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-eyebrow {
5193
+ font-family: var(--rb-font-mono);
5194
+ font-size: 12.5px;
5195
+ font-weight: var(--rb-font-weight-medium);
5196
+ letter-spacing: var(--rb-label-tracking);
5197
+ text-transform: uppercase;
5198
+ color: var(--rb-accent);
5199
+ align-items: center;
5200
+ gap: 10px;
5201
+ display: inline-flex;
5202
+ }
5203
+
5204
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-eyebrow:before {
5205
+ content: "";
5206
+ background: var(--rb-accent);
5207
+ width: 22px;
5208
+ height: 1.5px;
5209
+ display: inline-block;
5210
+ }
5211
+
5212
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-rack {
5213
+ --rb-rack-panel: #0a121a;
5214
+ --rb-rack-ink: #e7eef4;
5215
+ --rb-rack-faint: #7d90a2;
5216
+ --rb-rack-line: #1e2b37;
5217
+ background: var(--rb-rack-panel);
5218
+ border: 1px solid var(--rb-rack-line);
5219
+ box-shadow: var(--rb-shadow-lg);
5220
+ color: var(--rb-rack-ink);
5221
+ border-radius: 16px;
5222
+ padding: 24px;
5223
+ overflow: hidden;
5224
+ }
5225
+
5226
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-rack__top {
5227
+ font-family: var(--rb-font-mono);
5228
+ letter-spacing: .14em;
5229
+ text-transform: uppercase;
5230
+ color: var(--rb-rack-faint);
5231
+ justify-content: space-between;
5232
+ align-items: center;
5233
+ font-size: 11.5px;
5234
+ display: flex;
5235
+ }
5236
+
5237
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-rack__live {
5238
+ color: var(--rb-rack-ink);
5239
+ align-items: center;
5240
+ gap: 7px;
5241
+ display: inline-flex;
5242
+ }
5243
+
5244
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-rack__live:before {
5245
+ content: "";
5246
+ background: var(--rb-accent);
5247
+ border-radius: 50%;
5248
+ width: 7px;
5249
+ height: 7px;
5250
+ animation: 1.8s ease-in-out infinite rb-noir-pulse;
5251
+ }
5252
+
5253
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-rack__bars {
5254
+ align-items: flex-end;
5255
+ gap: 7px;
5256
+ height: 132px;
5257
+ margin: 22px 2px 16px;
5258
+ display: flex;
5259
+ }
5260
+
5261
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-rack__bars > i {
5262
+ background: var(--rb-accent-grad);
5263
+ transform-origin: bottom;
5264
+ opacity: .92;
5265
+ border-radius: 4px 4px 2px 2px;
5266
+ flex: 1;
5267
+ animation: 1.4s ease-in-out infinite rb-noir-bop;
5268
+ }
5269
+
5270
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-rack__bars > i:first-child {
5271
+ height: 52%;
5272
+ animation-delay: -.2s;
5273
+ }
5274
+
5275
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-rack__bars > i:nth-child(2) {
5276
+ height: 88%;
5277
+ animation-delay: -1.1s;
5278
+ }
5279
+
5280
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-rack__bars > i:nth-child(3) {
5281
+ height: 34%;
5282
+ animation-delay: -.6s;
5283
+ }
5284
+
5285
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-rack__bars > i:nth-child(4) {
5286
+ height: 70%;
5287
+ animation-delay: -.9s;
5288
+ }
5289
+
5290
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-rack__bars > i:nth-child(5) {
5291
+ height: 100%;
5292
+ animation-delay: -.35s;
5293
+ }
5294
+
5295
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-rack__bars > i:nth-child(6) {
5296
+ height: 46%;
5297
+ animation-delay: -1.3s;
5298
+ }
5299
+
5300
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-rack__foot {
5301
+ border-top: 1px solid var(--rb-rack-line);
5302
+ font-family: var(--rb-font-mono);
5303
+ color: var(--rb-rack-faint);
5304
+ letter-spacing: .04em;
5305
+ justify-content: space-between;
5306
+ gap: 12px;
5307
+ padding-top: 14px;
5308
+ font-size: 11.5px;
5309
+ display: flex;
5310
+ }
5311
+
5312
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-rack__foot b {
5313
+ color: var(--rb-rack-ink);
5314
+ font-weight: var(--rb-font-weight-medium);
5315
+ }
5316
+
5317
+ @keyframes rb-noir-bop {
5318
+ 0%, 100% {
5319
+ transform: scaleY(.5);
5320
+ }
5321
+
5322
+ 50% {
5323
+ transform: scaleY(1);
5324
+ }
5325
+ }
5326
+
5327
+ @keyframes rb-noir-pulse {
5328
+ 0%, 100% {
5329
+ opacity: 1;
5330
+ }
5331
+
5332
+ 50% {
5333
+ opacity: .35;
5334
+ }
5335
+ }
5336
+
5337
+ @media (prefers-reduced-motion: reduce) {
5338
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-rack__bars > i, :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-rack__live:before {
5339
+ animation: none;
5340
+ }
5341
+ }
5342
+
5343
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-principles {
5344
+ flex-direction: column;
5345
+ display: flex;
5346
+ }
5347
+
5348
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-principle {
5349
+ border-top: 1px solid var(--rb-border);
5350
+ grid-template-columns: 56px 1fr;
5351
+ gap: 18px;
5352
+ padding: 22px 0;
5353
+ display: grid;
5354
+ }
5355
+
5356
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-principles > .rb-principle:first-child {
5357
+ border-top: 0;
5358
+ }
5359
+
5360
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-principle__n {
5361
+ font-family: var(--rb-font-mono);
5362
+ font-size: 13px;
5363
+ font-weight: var(--rb-font-weight-medium);
5364
+ color: var(--rb-accent);
5365
+ padding-top: 3px;
5366
+ }
5367
+
5368
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-principle__body h3 {
5369
+ margin: 0;
5370
+ font-size: 1.16rem;
5371
+ }
5372
+
5373
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-principle__body p {
5374
+ color: var(--rb-text-soft);
5375
+ margin: 7px 0 0;
5376
+ }
5377
+
5378
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-tags {
5379
+ flex-wrap: wrap;
5380
+ gap: 8px;
5381
+ margin: 0;
5382
+ padding: 0;
5383
+ list-style: none;
5384
+ display: flex;
5385
+ }
5386
+
5387
+ :where([data-rb-style="rackbops-noir"], [data-rb-style="rackbops-noir"] *).rb-tag {
5388
+ font-family: var(--rb-font-mono);
5389
+ color: var(--rb-text-soft);
5390
+ border: 1px solid var(--rb-border);
5391
+ border-radius: var(--rb-radius-pill);
5392
+ letter-spacing: .02em;
5393
+ padding: 4px 10px;
5394
+ font-size: 11.5px;
5395
+ }
5396
+ `;
5397
+
5398
+ // plugins/tracker/src/web/theme.ts
5399
+ var THEME_CSS = bundle_default;
5400
+ var LAYOUT_CSS = `
5401
+ /* tracker web area layout (not part of the theme) */
5402
+ .tr-top { display: flex; flex-wrap: wrap; align-items: center; gap: var(--rb-space-3); padding: var(--rb-space-3) var(--rb-space-4); border-bottom: 1px solid var(--rb-border); }
5403
+ .tr-top nav { display: flex; flex-wrap: wrap; align-items: center; gap: var(--rb-space-2); margin-left: auto; }
5404
+ .tr-top form { margin: 0; }
5405
+ .tr-main { max-width: 60rem; margin: 0 auto; padding: var(--rb-space-4); }
5406
+ .tr-main > section { margin-bottom: var(--rb-space-5); }
5407
+ .tr-stack { display: grid; gap: var(--rb-space-3); max-width: 28rem; }
5408
+ .tr-foot { margin-top: var(--rb-space-5); }
5409
+ `;
5410
+ var STYLESHEET = `${THEME_CSS}
5411
+ ${LAYOUT_CSS}`;
5412
+ var STYLESHEET_PATH = `/assets/theme.${sha256(STYLESHEET).slice(0, 16)}.css`;
5413
+
5414
+ // plugins/tracker/src/web/html.ts
5415
+ function esc(value) {
5416
+ return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
5417
+ }
5418
+
5419
+ class Html {
5420
+ value;
5421
+ constructor(value) {
5422
+ this.value = value;
5423
+ }
5424
+ toString() {
5425
+ return this.value;
5426
+ }
5427
+ }
5428
+ function render(part) {
5429
+ if (part === null || part === undefined || part === false)
5430
+ return "";
5431
+ if (part instanceof Html)
5432
+ return part.value;
5433
+ if (Array.isArray(part))
5434
+ return part.map(render).join("");
5435
+ return esc(part);
5436
+ }
5437
+ function html(strings, ...values) {
5438
+ let out = strings[0] ?? "";
5439
+ values.forEach((v, i) => {
5440
+ out += render(v) + (strings[i + 1] ?? "");
5441
+ });
5442
+ return new Html(out);
5443
+ }
5444
+ var CSP = "default-src 'none'; style-src 'self'; img-src 'none'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
5445
+ var REFERRER_POLICY = "same-origin";
5446
+ function pageHeaders(extra = {}) {
5447
+ const h = new Headers({
5448
+ "Content-Type": "text/html; charset=utf-8",
5449
+ "Content-Security-Policy": CSP,
5450
+ "X-Content-Type-Options": "nosniff",
5451
+ "X-Frame-Options": "DENY",
5452
+ "Referrer-Policy": REFERRER_POLICY,
5453
+ "Cache-Control": "no-store"
5454
+ });
5455
+ for (const [k, v] of Object.entries(extra))
5456
+ h.set(k, v);
5457
+ return h;
5458
+ }
5459
+ function page(f) {
5460
+ const nav = f.signedIn ? html`<nav aria-label="Tracker">
5461
+ <span class="rb-muted">${f.signedIn.name}</span>
5462
+ <a class="rb-link" href="${f.base}/">My tasks</a>
5463
+ <a class="rb-link" href="${f.base}/settings">Settings</a>
5464
+ <form method="post" action="${f.base}/logout"><input type="hidden" name="csrf" value="${f.signedIn.csrf}"><button class="rb-btn rb-btn--ghost rb-btn--sm" type="submit">Sign out</button></form>
5465
+ </nav>` : null;
5466
+ return html`<!doctype html>
5467
+ <html lang="en" data-rb-style="rackbops-noir">
5468
+ <head>
5469
+ <meta charset="utf-8">
5470
+ <meta name="viewport" content="width=device-width, initial-scale=1">
5471
+ <meta name="referrer" content="${REFERRER_POLICY}">
5472
+ <title>${f.title} -- Tracker</title>
5473
+ <link rel="stylesheet" href="${f.base}${STYLESHEET_PATH}">
5474
+ </head>
5475
+ <body>
5476
+ <header class="tr-top"><span class="rb-wordmark">Tracker<span class="rb-wordmark__spark">.</span></span>${nav}</header>
5477
+ <main class="tr-main">
5478
+ ${f.body}
5479
+ </main>
5480
+ </body>
5481
+ </html>
5482
+ `.value;
5483
+ }
5484
+ function htmlResponse(body, status = 200, extra = {}) {
5485
+ return new Response(body, { status, headers: pageHeaders(extra) });
5486
+ }
5487
+ function redirect(location, cookies = []) {
5488
+ const h = new Headers({ Location: location, "Cache-Control": "no-store", "Referrer-Policy": REFERRER_POLICY });
5489
+ for (const c of cookies)
5490
+ h.append("Set-Cookie", c);
5491
+ return new Response(null, { status: 303, headers: h });
5492
+ }
5493
+ function readCookie(request, name) {
5494
+ const header = request.headers.get("cookie");
5495
+ if (!header)
5496
+ return null;
5497
+ for (const part of header.split(";")) {
5498
+ const eq = part.indexOf("=");
5499
+ if (eq < 0)
5500
+ continue;
5501
+ if (part.slice(0, eq).trim() === name)
5502
+ return part.slice(eq + 1).trim();
5503
+ }
5504
+ return null;
5505
+ }
5506
+ function cookie(name, value, o) {
5507
+ return `${name}=${value}; Path=${o.base}/; Max-Age=${o.maxAgeSeconds}; HttpOnly; Secure; SameSite=${o.sameSite}`;
5508
+ }
5509
+
5510
+ // plugins/tracker/src/web/pages.ts
5511
+ function name3(u) {
5512
+ return u.displayName ?? "you";
5513
+ }
5514
+ function framed(v, title, body) {
5515
+ return page({ base: v.base, title, signedIn: { name: name3(v.user), csrf: v.csrf }, body });
5516
+ }
5517
+ function signInHelpPage(base, note) {
5518
+ return page({
5519
+ base,
5520
+ title: "Sign in",
5521
+ body: html`<section class="rb-card">
5522
+ <h1>Sign in</h1>
5523
+ ${note ? html`<p class="rb-muted">${note}</p>` : null}
5524
+ <p>Run <code>/web</code> in Discord: the bot answers you alone with a sign-in link, good for ten minutes and one use.</p>
5525
+ </section>`
5526
+ });
5527
+ }
5528
+ function loginPage(base, input) {
5529
+ if (!input.valid)
5530
+ return signInHelpPage(base, "That sign-in link has expired or was already used.");
5531
+ return page({
5532
+ base,
5533
+ title: "Sign in",
5534
+ body: html`<section class="rb-card">
5535
+ <h1>Sign in to the tracker</h1>
5536
+ <p>This link works once. Press the button to sign in on this browser.</p>
5537
+ <form method="post" action="${base}/login">
5538
+ <input type="hidden" name="t" value="${input.token}">
5539
+ <input type="hidden" name="csrf" value="${input.loginCsrf}">
5540
+ <button class="rb-btn rb-btn--primary" type="submit">Sign in</button>
5541
+ </form>
5542
+ </section>`
5543
+ });
5544
+ }
5545
+ function when2(entry, viewer, now) {
5546
+ return entry.next ? formatInstant(entry.next.dueAt, viewer.timeZone, now) : "nothing due";
5547
+ }
5548
+ function cadence(entry, viewer, now) {
5549
+ const s = entry.task.schedule;
5550
+ if (!s || s.kind === "once")
5551
+ return "once";
5552
+ return describeSchedule(s, entry.from ?? viewer, viewer.timeZone, now);
5553
+ }
5554
+ function tasksPage(v, data, now) {
5555
+ const rows = data.entries.map((e) => html`<tr>
5556
+ <td><a class="rb-link" href="${v.base}/tasks/${e.task.id}">${e.task.title}</a></td>
5557
+ <td>${e.from ? name3(e.from) : "you"}</td>
5558
+ <td>${when2(e, v.user, now)}</td>
5559
+ <td>${cadence(e, v.user, now)}</td>
5560
+ </tr>`);
5561
+ const active = data.entries.length === 0 ? html`<p class="rb-muted">You have no active tasks. <code>/remind</code> in Discord sets one.</p>` : html`<div class="rb-table-scroll"><table class="rb-table">
5562
+ <thead><tr><th scope="col">Task</th><th scope="col">From</th><th scope="col">Next</th><th scope="col">Repeats</th></tr></thead>
5563
+ <tbody>${rows}</tbody>
5564
+ </table></div>`;
5565
+ const paused = data.paused.length === 0 ? null : html`<section>
5566
+ <h2>Paused</h2>
5567
+ <ul>${data.paused.map((p) => html`<li><a class="rb-link" href="${v.base}/tasks/${p.task.id}">${p.task.title}</a>${p.held.length > 0 ? html` -- I could not DM ${p.held.join(", ")}; <code>/task resume ${p.task.id}</code> goes on without them.` : null}</li>`)}</ul>
5568
+ </section>`;
5569
+ return framed(v, "My tasks", html`<section>
5570
+ <h1>My tasks</h1>
5571
+ <p class="rb-muted">Times are in ${v.user.timeZone}.</p>
5572
+ ${active}
5573
+ </section>
5574
+ ${paused}
5575
+ <p class="rb-muted tr-foot">${ADMIN_DISCLOSURE}</p>`);
5576
+ }
5577
+ function historyPage(v, h) {
5578
+ const runs = h.runs.length === 0 ? html`<p class="rb-muted">No runs yet.</p>` : html`<div class="rb-table-scroll"><table class="rb-table">
5579
+ <thead><tr><th scope="col">Due</th><th scope="col">Status</th><th scope="col">Answers</th></tr></thead>
5580
+ <tbody>${h.runs.map((r) => html`<tr>
5581
+ <td>${r.due}</td>
5582
+ <td>${r.status}${r.late ? " (late)" : ""}${r.error ? html`<br><span class="rb-muted">${r.error}</span>` : null}</td>
5583
+ <td>${r.answers.join("; ")}</td>
5584
+ </tr>`)}</tbody>
5585
+ </table></div>`;
5586
+ return framed(v, h.task.title, html`<section>
5587
+ <p><a class="rb-link" href="${v.base}/">My tasks</a></p>
5588
+ <h1>${h.task.title}</h1>
5589
+ <p>${h.task.type}, ${h.task.status}, ${h.cadence}. Next: ${h.next ?? "nothing scheduled"}.</p>
5590
+ </section>
5591
+ <section>
5592
+ <h2>Runs</h2>
5593
+ ${h.earlierRuns > 0 ? html`<p class="rb-muted">${h.earlierRuns} earlier run(s) not shown.</p>` : null}
5594
+ ${runs}
5595
+ </section>
5596
+ <section>
5597
+ <h2>Changes</h2>
5598
+ ${h.earlierChanges > 0 ? html`<p class="rb-muted">${h.earlierChanges} earlier change(s) not shown.</p>` : null}
5599
+ <ul>${h.changes.map((c) => html`<li>${c.at} ${c.kind}${c.detail !== null ? `: ${c.detail}` : ""}</li>`)}</ul>
5600
+ </section>`);
5601
+ }
5602
+ function settingsPage(v, form) {
5603
+ const hours = Array.from({ length: 24 }, (_, h) => h);
5604
+ return framed(v, "Settings", html`<section>
5605
+ <h1>Settings</h1>
5606
+ ${form.saved ? html`<div class="rb-alert rb-alert--success" role="status"><p class="rb-alert__title">Saved.</p></div>` : null}
5607
+ ${form.error ? html`<div class="rb-alert rb-alert--danger" role="alert"><p class="rb-alert__title">Not saved</p><p>${form.error}</p></div>` : null}
5608
+ <form method="post" action="${v.base}/settings" class="tr-stack">
5609
+ <input type="hidden" name="csrf" value="${v.csrf}">
5610
+ <div class="rb-field">
5611
+ <label class="rb-label" for="hour">Preferred hour</label>
5612
+ <select class="rb-select" id="hour" name="hour">${hours.map((h) => html`<option value="${h}"${String(h) === form.hour ? html` selected` : null}>${String(h).padStart(2, "0")}:00</option>`)}</select>
5613
+ <p class="rb-field__help">A reminder without a time of day arrives at this hour.</p>
5614
+ </div>
5615
+ <div class="rb-field">
5616
+ <label class="rb-label" for="zone">Time zone</label>
5617
+ <input class="rb-input" id="zone" name="zone" value="${form.zone}" maxlength="64" required${form.error ? html` aria-invalid="true"` : null}>
5618
+ <p class="rb-field__help">An IANA name, such as America/New_York or Europe/London.</p>
5619
+ </div>
5620
+ <div><button class="rb-btn rb-btn--primary" type="submit">Save</button></div>
5621
+ </form>
5622
+ </section>`);
5623
+ }
5624
+ function notFoundPage(base, v) {
5625
+ const body = html`<section><h1>Not found</h1><p>There is nothing here, or nothing you can see.</p><p><a class="rb-link" href="${base}/">My tasks</a></p></section>`;
5626
+ return v ? framed(v, "Not found", body) : page({ base, title: "Not found", body });
5627
+ }
5628
+ function errorPage(base, title, message) {
5629
+ return page({ base, title, body: html`<section class="rb-card"><h1>${title}</h1><p>${message}</p></section>` });
5630
+ }
5631
+
5632
+ // plugins/tracker/src/web/sessions.ts
5633
+ var SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
5634
+
5635
+ class Sessions {
5636
+ db;
5637
+ constructor(db) {
5638
+ this.db = db;
5639
+ }
5640
+ create(userId, now, memberCheckedAt = null) {
5641
+ const id = randomToken();
5642
+ const at = now.toISOString();
5643
+ const session = { userId, csrf: randomToken(), expiresAt: new Date(now.getTime() + SESSION_TTL_MS).toISOString(), memberCheckedAt };
5644
+ this.db.query("DELETE FROM web_sessions WHERE expires_at <= ?").run(at);
5645
+ this.db.query("INSERT INTO web_sessions (id_hash, user_id, csrf, created_at, expires_at, member_checked_at) VALUES (?, ?, ?, ?, ?, ?)").run(sha256(id), userId, session.csrf, at, session.expiresAt, memberCheckedAt);
5646
+ return { id, session };
5647
+ }
5648
+ find(id, now) {
5649
+ if (!isTokenShape(id))
5650
+ return null;
5651
+ const row = this.db.query("SELECT user_id, csrf, expires_at, member_checked_at FROM web_sessions WHERE id_hash = ? AND expires_at > ?").get(sha256(id), now.toISOString());
5652
+ return row ? { userId: row.user_id, csrf: row.csrf, expiresAt: row.expires_at, memberCheckedAt: row.member_checked_at } : null;
5653
+ }
5654
+ delete(id) {
5655
+ if (isTokenShape(id))
5656
+ this.db.query("DELETE FROM web_sessions WHERE id_hash = ?").run(sha256(id));
5657
+ }
5658
+ confirmMember(userId, at) {
5659
+ this.db.query("UPDATE web_sessions SET member_checked_at = ? WHERE user_id = ?").run(at, userId);
5660
+ }
5661
+ deleteForUser(userId) {
5662
+ this.db.query("DELETE FROM web_sessions WHERE user_id = ?").run(userId);
5663
+ }
5664
+ }
5665
+
5666
+ // plugins/tracker/src/web/app.ts
5667
+ var MEMBER_RECHECK_MS = 15 * 60 * 1000;
5668
+ var MEMBER_GRACE_MS = 24 * 60 * 60 * 1000;
5669
+ var MEMBER_CHECK_TIMEOUT_MS = 3000;
5670
+ var MEMBER_RETRY_MS = 60 * 1000;
5671
+ var LEFT_SERVER = `You are signed out. ${NOT_MEMBER}`;
5672
+ var RECHECK_FAILED = "You are signed out: I could not check that you are still a member of this tracker's server.";
5673
+ var MAX_FORM_BYTES = 8 * 1024;
5674
+ var TASK_PATH = /^\/tasks\/([^/]+)$/;
5675
+ function plain(status, text, extra = {}) {
5676
+ return new Response(text, {
5677
+ status,
5678
+ headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", ...extra }
5679
+ });
5680
+ }
5681
+ async function lookupWithin(lookup, ms) {
5682
+ let timer;
5683
+ const late = new Promise((resolve) => {
5684
+ timer = setTimeout(() => resolve("unknown"), ms);
5685
+ });
5686
+ try {
5687
+ return await Promise.race([lookup().catch(() => "unknown"), late]);
5688
+ } finally {
5689
+ clearTimeout(timer);
5690
+ }
5691
+ }
5692
+ function createWebHandler(w) {
5693
+ const base = `/${w.name}`;
5694
+ const SESSION_COOKIE = `__Secure-${w.name}-session`;
5695
+ const LOGIN_COOKIE = `__Secure-${w.name}-login`;
5696
+ const clear = (name) => cookie(name, "", { base, maxAgeSeconds: 0, sameSite: "Lax" });
5697
+ const inFlight = new Map;
5698
+ const failedAt = new Map;
5699
+ async function authenticate(d, request) {
5700
+ const id = readCookie(request, SESSION_COOKIE);
5701
+ if (!id)
5702
+ return { kind: "none" };
5703
+ const session = d.sessions.find(id, d.clock.now());
5704
+ if (!session)
5705
+ return { kind: "stale" };
5706
+ const user = await d.store.getUser(session.userId);
5707
+ if (!user || !d.admissions.isRegistered(user.id)) {
5708
+ d.sessions.deleteForUser(session.userId);
5709
+ return { kind: "stale" };
5710
+ }
5711
+ const refused = await recheckMembership(d, session, user);
5712
+ if (refused)
5713
+ return refused;
5714
+ const current = d.sessions.find(id, d.clock.now());
5715
+ if (!current)
5716
+ return { kind: "stale" };
5717
+ return { kind: "ok", id, session: current, user };
5718
+ }
5719
+ function lookupShared(userId, discordId) {
5720
+ let pending = inFlight.get(userId);
5721
+ if (!pending) {
5722
+ const started = w.membership(discordId).catch(() => "unknown");
5723
+ pending = started;
5724
+ inFlight.set(userId, started);
5725
+ started.finally(() => {
5726
+ if (inFlight.get(userId) === started)
5727
+ inFlight.delete(userId);
5728
+ });
5729
+ }
5730
+ const shared = pending;
5731
+ return lookupWithin(() => shared, MEMBER_CHECK_TIMEOUT_MS);
5732
+ }
5733
+ async function recheckMembership(d, session, user) {
5734
+ if (w.guildId === null)
5735
+ return null;
5736
+ const now = d.clock.now().getTime();
5737
+ const age = session.memberCheckedAt ? now - Date.parse(session.memberCheckedAt) : Number.POSITIVE_INFINITY;
5738
+ const fresh = (limit) => age >= 0 && age < limit;
5739
+ if (fresh(MEMBER_RECHECK_MS))
5740
+ return null;
5741
+ const lastFailed = failedAt.get(user.id);
5742
+ const backingOff = lastFailed !== undefined && now - lastFailed >= 0 && now - lastFailed < MEMBER_RETRY_MS;
5743
+ let found = "unknown";
5744
+ if (!backingOff && user.discordId) {
5745
+ found = await lookupShared(user.id, user.discordId);
5746
+ if (found === "member" || found === "not-member")
5747
+ failedAt.delete(user.id);
5748
+ else
5749
+ failedAt.set(user.id, d.clock.now().getTime());
5750
+ }
5751
+ if (found === "not-member") {
5752
+ d.sessions.deleteForUser(user.id);
5753
+ return { kind: "signed-out", note: LEFT_SERVER };
5754
+ }
5755
+ if (found === "member") {
5756
+ d.sessions.confirmMember(user.id, d.clock.now().toISOString());
5757
+ return null;
5758
+ }
5759
+ if (fresh(MEMBER_GRACE_MS))
5760
+ return null;
5761
+ d.sessions.deleteForUser(user.id);
5762
+ return { kind: "signed-out", note: RECHECK_FAILED };
5763
+ }
5764
+ async function readForm(request) {
5765
+ const type = request.headers.get("content-type") ?? "";
5766
+ if (!type.toLowerCase().startsWith("application/x-www-form-urlencoded"))
5767
+ return null;
5768
+ const body = await request.text();
5769
+ if (Buffer.byteLength(body) > MAX_FORM_BYTES)
5770
+ return null;
5771
+ return new URLSearchParams(body);
5772
+ }
5773
+ function forbidden() {
5774
+ return htmlResponse(errorPage(base, "Refused", "That form was stale or came from somewhere else. Go back, reload the page and try again."), 403);
5775
+ }
5776
+ async function loginGet(d, url) {
5777
+ const token = url.searchParams.get("t") ?? "";
5778
+ const valid = d.logins.peek(token, d.clock.now());
5779
+ if (!valid)
5780
+ return htmlResponse(loginPage(base, { token: "", loginCsrf: "", valid: false }));
5781
+ const loginCsrf = randomToken();
5782
+ return htmlResponse(loginPage(base, { token, loginCsrf, valid: true }), 200, {
5783
+ "Set-Cookie": cookie(LOGIN_COOKIE, loginCsrf, { base, maxAgeSeconds: LINK_TTL_MS / 1000, sameSite: "Strict" })
5784
+ });
5785
+ }
5786
+ async function loginPost(d, request) {
5787
+ const form = await readForm(request);
5788
+ if (!form)
5789
+ return plain(400, "Bad request");
5790
+ const expected = readCookie(request, LOGIN_COOKIE);
5791
+ const sent = form.get("csrf") ?? "";
5792
+ if (!expected || !safeEqual(expected, sent))
5793
+ return forbidden();
5794
+ const now = d.clock.now();
5795
+ const link = d.logins.consume(form.get("t") ?? "", now);
5796
+ const user = link ? await d.store.getUser(link.userId) : null;
5797
+ if (!user || !d.admissions.isRegistered(user.id)) {
5798
+ return htmlResponse(signInHelpPage(base, "That sign-in link has expired or was already used."), 400, { "Set-Cookie": clear(LOGIN_COOKIE) });
5799
+ }
5800
+ const { id } = d.sessions.create(user.id, now, link?.memberCheckedAt ?? null);
5801
+ return redirect(`${base}/`, [cookie(SESSION_COOKIE, id, { base, maxAgeSeconds: SESSION_TTL_MS / 1000, sameSite: "Lax" }), clear(LOGIN_COOKIE)]);
5802
+ }
5803
+ async function settingsPost(d, v, form) {
5804
+ const hour = (form.get("hour") ?? "").trim();
5805
+ const zone = (form.get("zone") ?? "").trim();
5806
+ let error = null;
5807
+ if (!/^([0-9]|1[0-9]|2[0-3])$/.test(hour))
5808
+ error = "The preferred hour must be a whole hour from 0 to 23.";
5809
+ else if (zone === "" || zone.length > 64)
5810
+ error = "Give a time zone, such as America/New_York.";
5811
+ else {
5812
+ error = await w.queue(async () => {
5813
+ const fresh = await d.store.getUser(v.user.id);
5814
+ return fresh ? saveSettings(d, fresh, { hour: Number(hour), zone }) : "You are no longer on this tracker's list.";
5815
+ });
5816
+ }
5817
+ if (error !== null)
5818
+ return htmlResponse(settingsPage(v, { hour, zone, error }), 400);
5819
+ return redirect(`${base}/settings?saved=1`);
5820
+ }
5821
+ return async (request, info) => {
5822
+ const method = request.method;
5823
+ if (method !== "GET" && method !== "POST")
5824
+ return plain(405, "Method not allowed", { Allow: "GET, POST" });
5825
+ if (w.origin === null)
5826
+ return plain(404, "Not found");
5827
+ const d = w.deps();
5828
+ if (!d)
5829
+ return plain(503, "The tracker is starting up; try again in a minute.");
5830
+ if (method === "POST") {
5831
+ const origin = request.headers.get("origin");
5832
+ if (origin !== null && origin !== w.origin)
5833
+ return forbidden();
5834
+ }
5835
+ const path = info.path;
5836
+ const url = new URL(request.url);
5837
+ const only = (allowed) => method === allowed ? null : plain(405, "Method not allowed", { Allow: allowed });
5838
+ if (path === STYLESHEET_PATH) {
5839
+ return only("GET") ?? new Response(STYLESHEET, {
5840
+ headers: { "Content-Type": "text/css; charset=utf-8", "Cache-Control": "public, max-age=31536000, immutable", "X-Content-Type-Options": "nosniff" }
5841
+ });
5842
+ }
5843
+ if (path === "/signin")
5844
+ return only("GET") ?? htmlResponse(signInHelpPage(base, url.searchParams.get("out") === "1" ? "You are signed out." : undefined));
5845
+ if (path === "/login")
5846
+ return method === "GET" ? loginGet(d, url) : loginPost(d, request);
5847
+ const task = TASK_PATH.exec(path);
5848
+ const route = path === "/" ? "tasks" : path === "/settings" ? "settings" : path === "/logout" ? "logout" : task ? "history" : null;
5849
+ if (route === null)
5850
+ return htmlResponse(notFoundPage(base), 404);
5851
+ const wrong = route === "logout" ? only("POST") : route === "settings" ? null : only("GET");
5852
+ if (wrong)
5853
+ return wrong;
5854
+ const auth = await authenticate(d, request);
5855
+ if (auth.kind === "signed-out")
5856
+ return htmlResponse(signInHelpPage(base, auth.note), 403, { "Set-Cookie": clear(SESSION_COOKIE) });
5857
+ if (auth.kind !== "ok")
5858
+ return redirect(`${base}/signin`, auth.kind === "stale" ? [clear(SESSION_COOKIE)] : []);
5859
+ const v = { base, user: auth.user, csrf: auth.session.csrf };
5860
+ if (method === "POST") {
5861
+ const form = await readForm(request);
5862
+ if (!form)
5863
+ return plain(400, "Bad request");
5864
+ if (!safeEqual(auth.session.csrf, form.get("csrf") ?? ""))
5865
+ return forbidden();
5866
+ if (route === "logout") {
5867
+ d.sessions.delete(auth.id);
5868
+ return redirect(`${base}/signin?out=1`, [clear(SESSION_COOKIE)]);
5869
+ }
5870
+ return settingsPost(d, v, form);
5871
+ }
5872
+ const now = d.clock.now();
5873
+ switch (route) {
5874
+ case "tasks":
5875
+ return htmlResponse(tasksPage(v, await loadTaskList(d, auth.user), now));
5876
+ case "history": {
5877
+ let id;
5878
+ try {
5879
+ id = decodeURIComponent(task?.[1] ?? "");
5880
+ } catch {
5881
+ return htmlResponse(notFoundPage(base, v), 404);
5882
+ }
5883
+ const view = await loadHistory(d, auth.user, id);
5884
+ return view ? htmlResponse(historyPage(v, view)) : htmlResponse(notFoundPage(base, v), 404);
5885
+ }
5886
+ default:
5887
+ return htmlResponse(settingsPage(v, { hour: String(auth.user.preferredHour), zone: auth.user.timeZone, saved: url.searchParams.get("saved") === "1" }));
5888
+ }
5889
+ };
5890
+ }
5891
+
5892
+ // plugins/tracker/src/web/config.ts
5893
+ var WEB_URL_FORMAT = "^https://[A-Za-z0-9.-]+(:[0-9]{1,5})?/?$";
5894
+ function parseWebUrl(raw) {
5895
+ if (raw === undefined || raw.trim() === "")
5896
+ return null;
5897
+ const value = raw.trim();
5898
+ if (!new RegExp(WEB_URL_FORMAT).test(value)) {
5899
+ throw new Error(`TRACKER_WEB_URL: "${value}" is not an https origin such as https://clerk.example.com (no path, query or credentials)`);
5900
+ }
5901
+ let url;
5902
+ try {
5903
+ url = new URL(value);
5904
+ } catch {
5905
+ throw new Error(`TRACKER_WEB_URL: "${value}" is not a URL`);
5906
+ }
5907
+ if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "") {
5908
+ throw new Error(`TRACKER_WEB_URL: "${value}" must be a bare https origin`);
5909
+ }
5910
+ return url.origin;
5911
+ }
5912
+
5913
+ // plugins/tracker/src/index.ts
5914
+ var DB_DIR = "tracker";
5915
+ var DB_FILE = "tracker.sqlite";
5916
+ var TRACKER_TYPES = Object.freeze({
5917
+ reminder,
5918
+ renewal,
5919
+ price
5920
+ });
5921
+ var NO_DM = {
5922
+ async sendDm() {
5923
+ throw new Error("this bot has no host.dm (rackbops-discord-bot#736)");
5924
+ }
5925
+ };
5926
+ function createPlugin(host, options = {}) {
5927
+ const adminIds = parseAdminIds(host.env.TRACKER_ADMIN_DISCORD_IDS);
5928
+ const guildId = parseGuildId(host.env.TRACKER_GUILD_ID);
5929
+ const webOrigin = parseWebUrl(host.env.TRACKER_WEB_URL);
5930
+ const clock = options.clock ?? { now: () => new Date };
5931
+ const types = options.types ?? TRACKER_TYPES;
5932
+ const pageFetch = options.fetch ?? ((signal) => createPageFetch(signal ? { signal } : {}));
5933
+ const health = { activatedAt: null, lastTickAt: null, blocked: null };
5934
+ let db = null;
5935
+ let store = null;
5936
+ let claims = null;
5937
+ let delivery = null;
5938
+ let deps = null;
5939
+ let warnedNoDm = false;
5940
+ const dm = host.dm?.bind(host);
5941
+ const queue = serial();
5942
+ const surface = createSurface({
5943
+ deps: () => deps,
5944
+ guildId,
5945
+ log: host.log,
5946
+ queue,
5947
+ web: webOrigin ? { origin: webOrigin, name: host.name, gated: guildId !== null } : null,
5948
+ ...options.membership ? { membership: options.membership } : {}
5949
+ });
5950
+ async function tick(kind, signal) {
5951
+ if (!store || !claims)
5952
+ return;
5953
+ const outcome = await runNotifyTick({
5954
+ store,
5955
+ claims,
5956
+ clock,
5957
+ types,
5958
+ dm,
5959
+ log: host.log,
5960
+ kind,
5961
+ ...kind === "poll" ? { fetch: pageFetch(signal) } : {},
5962
+ ...delivery ? { health: delivery } : {}
5963
+ }, signal);
5964
+ if (outcome.kind === "no-dm") {
5965
+ if (!warnedNoDm)
5966
+ host.log.warn("notify lane is off: this bot has no host.dm (rackbops-discord-bot#736)");
5967
+ warnedNoDm = true;
5968
+ return;
5969
+ }
5970
+ if (outcome.kind === "aborted")
5971
+ return;
5972
+ if (kind === "notify")
5973
+ health.lastTickAt = clock.now();
5974
+ const { ran, failed } = outcome.result;
5975
+ if (failed > 0)
5976
+ host.log.warn(`${kind === "poll" ? "poll" : "notify"} lane: ${ran} ran, ${failed} failed`);
5977
+ }
5978
+ let discordClient = null;
5979
+ const capture = (interaction) => {
5980
+ const client = interaction.client;
5981
+ if (client)
5982
+ discordClient = client;
5983
+ };
5984
+ const clientMembership = async (discordId) => {
5985
+ if (!discordClient)
5986
+ return null;
5987
+ return lookupMembership({ guildId: null, user: { id: discordId }, client: discordClient }, guildId, discordId, host.log);
5988
+ };
5989
+ const web = createWebHandler({
5990
+ name: host.name,
5991
+ origin: webOrigin,
5992
+ deps: () => deps,
5993
+ queue,
5994
+ guildId,
5995
+ membership: options.webMembership ?? clientMembership
5996
+ });
5997
+ const interactions = surface.interactions;
5998
+ return {
5999
+ commands: surface.commands.map((c) => ({
6000
+ ...c,
6001
+ handle: (interaction) => {
6002
+ capture(interaction);
6003
+ return c.handle(interaction);
6004
+ }
6005
+ })),
6006
+ interactions: (interaction) => {
6007
+ capture(interaction);
6008
+ return interactions(interaction);
6009
+ },
6010
+ async activate() {
6011
+ let path = options.dbPath;
6012
+ if (path === undefined) {
6013
+ const dir = join(host.dataDir, DB_DIR);
6014
+ mkdirSync(dir, { recursive: true });
6015
+ path = join(dir, DB_FILE);
6016
+ }
6017
+ const opened = openDatabase(path);
6018
+ const openedStore = new SqliteStore(opened);
6019
+ const openedClaims = new ClaimStore(opened);
6020
+ try {
6021
+ const granted = await seedAdmins(openedStore, adminIds, clock.now());
6022
+ const admissions = new Admissions(opened);
6023
+ for (const id of adminIds) {
6024
+ const admin = await openedStore.findUserByDiscordId(id);
6025
+ if (admin)
6026
+ admissions.record(admin.id, null, clock.now().toISOString());
6027
+ }
6028
+ if (granted.length > 0)
6029
+ host.log.info(`made ${granted.length} admin(s) from TRACKER_ADMIN_DISCORD_IDS`);
6030
+ const requeued = await openedStore.requeueRunning();
6031
+ if (requeued.length > 0)
6032
+ host.log.warn(`requeued ${requeued.length} occurrence(s) left running: ${requeued.join(", ")}`);
3553
6033
  for (const c of openedClaims.listUnsettled())
3554
6034
  reportUnconfirmed(openedClaims, host.log, c, clock.now().toISOString());
3555
6035
  } catch (err) {
@@ -3574,9 +6054,12 @@ function createPlugin(host, options = {}) {
3574
6054
  health: delivery,
3575
6055
  clock,
3576
6056
  types,
6057
+ fetch: pageFetch(),
3577
6058
  dm,
3578
6059
  log: host.log,
3579
- notifier: dm ? createDmNotifier({ store: openedStore, claims: openedClaims, dm, clock, log: host.log, health: delivery }) : NO_DM
6060
+ notifier: dm ? createDmNotifier({ store: openedStore, claims: openedClaims, dm, clock, log: host.log, health: delivery }) : NO_DM,
6061
+ logins: new LoginLinks(opened),
6062
+ sessions: new Sessions(opened)
3580
6063
  };
3581
6064
  if (guildId === null)
3582
6065
  host.log.warn("TRACKER_GUILD_ID is unset: no membership gate, only the admission list");
@@ -3596,30 +6079,18 @@ function createPlugin(host, options = {}) {
3596
6079
  ticks: [
3597
6080
  {
3598
6081
  name: "notify",
3599
- async run(signal) {
3600
- if (!store || !claims)
3601
- return;
3602
- const outcome = await runNotifyTick({ store, claims, clock, types, dm, log: host.log, ...delivery ? { health: delivery } : {} }, signal);
3603
- if (outcome.kind === "no-dm") {
3604
- if (!warnedNoDm)
3605
- host.log.warn("notify lane is off: this bot has no host.dm (rackbops-discord-bot#736)");
3606
- warnedNoDm = true;
3607
- return;
3608
- }
3609
- if (outcome.kind === "aborted")
3610
- return;
3611
- health.lastTickAt = clock.now();
3612
- const { ran, failed } = outcome.result;
3613
- if (failed > 0)
3614
- host.log.warn(`notify lane: ${ran} ran, ${failed} failed`);
3615
- }
6082
+ run: (signal) => tick("notify", signal)
6083
+ },
6084
+ {
6085
+ name: "poll",
6086
+ run: (signal) => tick("poll", signal)
3616
6087
  }
3617
6088
  ],
3618
6089
  async http(request, info) {
3619
6090
  if (info.path === "/healthz" && (request.method === "GET" || request.method === "HEAD")) {
3620
6091
  return healthResponse(decideHealth(health, clock.now()));
3621
6092
  }
3622
- return new Response("Not found", { status: 404 });
6093
+ return web(request, info);
3623
6094
  }
3624
6095
  };
3625
6096
  }