@rackbops/plugin-tracker 0.5.0 → 0.7.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 +229 -8
  2. package/dist/plugin.js +1647 -290
  3. package/package.json +2 -2
package/dist/plugin.js CHANGED
@@ -191,6 +191,14 @@ async function optOut(store, task, recipientId, now, occurrenceId = null) {
191
191
  at
192
192
  });
193
193
  }
194
+ async function liftBlock(store, actor, blockId, now) {
195
+ if (!actor.admin)
196
+ throw new ConsentError("only an admin lifts a block");
197
+ const block = await store.getBlock(blockId);
198
+ if (!block)
199
+ throw new ConsentError(`no block ${blockId}`);
200
+ return store.liftBlock(blockId, actor.userId, now.toISOString());
201
+ }
194
202
 
195
203
  // node_modules/@rackbops/docket-core/dist/lanes.js
196
204
  var LANES = ["notify", "execute"];
@@ -1916,6 +1924,10 @@ class ClaimStore {
1916
1924
  // plugins/tracker/src/delivery-health.ts
1917
1925
  var PAUSE_AFTER = 3;
1918
1926
  var PAUSE_DETAIL = `delivery paused: ${PAUSE_AFTER} DMs in a row could not be delivered (DMs closed, or the bot is blocked)`;
1927
+ function recipientPauseDetail(userId) {
1928
+ return `delivery to {${userId}} paused: ${PAUSE_AFTER} DMs in a row could not be delivered`;
1929
+ }
1930
+ var PERSON_REF = /\{(u[1-9][0-9]*)\}/g;
1919
1931
  function decidePause(failures, alreadyPaused) {
1920
1932
  return !alreadyPaused && failures >= PAUSE_AFTER;
1921
1933
  }
@@ -1941,6 +1953,9 @@ class DeliveryHealth {
1941
1953
  this.chain = next.catch(() => {});
1942
1954
  return next;
1943
1955
  }
1956
+ exclusive(fn) {
1957
+ return this.locked(fn);
1958
+ }
1944
1959
  get(userId) {
1945
1960
  const r = this.db.query("SELECT * FROM delivery_health WHERE user_id = ?").get(userId);
1946
1961
  if (!r)
@@ -1959,6 +1974,8 @@ class DeliveryHealth {
1959
1974
  async recordFailure(userId, error, at) {
1960
1975
  const notices = [];
1961
1976
  const result = await this.locked(async () => {
1977
+ if (!await this.store.getUser(userId))
1978
+ return { failures: 0, paused: false };
1962
1979
  this.db.query(`INSERT INTO delivery_health (user_id, failures, last_error, last_failed_at) VALUES (?, 1, ?, ?)
1963
1980
  ON CONFLICT (user_id) DO UPDATE SET failures = delivery_health.failures + 1,
1964
1981
  last_error = excluded.last_error, last_failed_at = excluded.last_failed_at`).run(userId, error, at);
@@ -1995,7 +2012,7 @@ class DeliveryHealth {
1995
2012
  if (changes === 0 || task.status !== "active")
1996
2013
  continue;
1997
2014
  await this.store.updateTask(task.id, { status: "paused", at });
1998
- const detail = asRecipient ? `delivery to ${name2(recipient, userId)} paused: ${PAUSE_AFTER} DMs in a row could not be delivered` : PAUSE_DETAIL;
2015
+ const detail = asRecipient ? recipientPauseDetail(userId) : PAUSE_DETAIL;
1999
2016
  await this.store.addTaskEvent({ taskId: task.id, actorId: null, kind: "paused", detail, at });
2000
2017
  if (asRecipient) {
2001
2018
  const owner = await this.store.getUser(task.ownerId);
@@ -2502,6 +2519,7 @@ async function restoreHeldRun(store, task, held, before, after, now) {
2502
2519
 
2503
2520
  // plugins/tracker/src/actions.ts
2504
2521
  var MAX_ANSWER = 2000;
2522
+ var NO_LONGER_LISTED = "You are no longer on this tracker's list.";
2505
2523
  var NO_SUCH_TASK = "You have no task with that id. `/tasks` lists yours.";
2506
2524
  function clip(text, max = MAX_ANSWER) {
2507
2525
  return text.length <= max ? text : `${text.slice(0, max - 3)}...`;
@@ -3031,6 +3049,8 @@ async function sendShare(d, p) {
3031
3049
  async function finishShare(d, p, err) {
3032
3050
  if (err === null)
3033
3051
  return `Invited <@${p.targetDiscordId}> to \`${p.taskId}\`. They get a DM to accept or decline.`;
3052
+ if (!await d.store.getUser(p.targetId) || !await d.store.getTask(p.taskId))
3053
+ return `<@${p.targetDiscordId}> ${CANNOT_SHARE}`;
3034
3054
  await d.store.removeRecipient(p.taskId, p.targetId);
3035
3055
  await d.store.addTaskEvent({
3036
3056
  taskId: p.taskId,
@@ -3222,12 +3242,9 @@ function runView(o, replies, people, viewer, now) {
3222
3242
  answers: replies.filter((r) => r.occurrenceId === o.id).map((r) => `${people.get(r.userId)?.displayName ?? r.userId} ${replyText(r)}`)
3223
3243
  };
3224
3244
  }
3225
- function changeView(e, viewer, now) {
3226
- return {
3227
- at: formatInstant(e.at, viewer.timeZone, now),
3228
- kind: e.kind.replaceAll("_", " "),
3229
- detail: e.kind === "paused" || e.kind === "resumed" ? e.detail : null
3230
- };
3245
+ function changeView(e, viewer, now, names = new Map) {
3246
+ const detail = e.kind === "paused" || e.kind === "resumed" ? e.detail.replace(PERSON_REF, (_, id) => names.get(id) ?? "someone no longer on the tracker") : null;
3247
+ return { at: formatInstant(e.at, viewer.timeZone, now), kind: e.kind.replaceAll("_", " "), detail };
3231
3248
  }
3232
3249
  function runLine(v) {
3233
3250
  const late = v.late ? " (late)" : "";
@@ -3258,6 +3275,16 @@ async function loadHistory(d, user, taskId) {
3258
3275
  people.set(u.id, u);
3259
3276
  }
3260
3277
  }
3278
+ const names = new Map;
3279
+ for (const e of changes) {
3280
+ for (const [, id] of e.detail.matchAll(PERSON_REF)) {
3281
+ if (id === undefined || names.has(id))
3282
+ continue;
3283
+ const u = await d.store.getUser(id);
3284
+ if (u)
3285
+ names.set(id, u.displayName ?? id);
3286
+ }
3287
+ }
3261
3288
  const owner = task.ownerId === user.id ? user : await d.store.getUser(task.ownerId);
3262
3289
  return {
3263
3290
  task,
@@ -3265,7 +3292,7 @@ async function loadHistory(d, user, taskId) {
3265
3292
  next: next ? formatInstant(next.dueAt, user.timeZone, now) : null,
3266
3293
  runs: runs.slice(-HISTORY_RUNS).map((o) => runView(o, replies, people, user, now)),
3267
3294
  earlierRuns: Math.max(0, runs.length - HISTORY_RUNS),
3268
- changes: changes.slice(-HISTORY_CHANGES).map((e) => changeView(e, user, now)),
3295
+ changes: changes.slice(-HISTORY_CHANGES).map((e) => changeView(e, user, now, names)),
3269
3296
  earlierChanges: Math.max(0, changes.length - HISTORY_CHANGES)
3270
3297
  };
3271
3298
  }
@@ -3442,7 +3469,10 @@ async function trackPrice(d, user, input) {
3442
3469
  finish: async (result) => said(await finishPrice(d, user, pending, result))
3443
3470
  };
3444
3471
  }
3445
- async function finishPrice(d, user, v, seen) {
3472
+ async function finishPrice(d, asked, v, seen) {
3473
+ const user = await d.store.getUser(asked.id);
3474
+ if (!user || !d.admissions.isRegistered(user.id))
3475
+ return { ok: false, error: NO_LONGER_LISTED };
3446
3476
  if (!seen.ok)
3447
3477
  return seen;
3448
3478
  const type = d.types.price;
@@ -4085,8 +4115,22 @@ var MIGRATIONS = [
4085
4115
  );
4086
4116
  CREATE INDEX web_sessions_user ON web_sessions (user_id);
4087
4117
  CREATE INDEX web_sessions_expiry ON web_sessions (expires_at);
4118
+ `,
4119
+ `
4120
+ CREATE TABLE api_tokens (
4121
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
4122
+ token_hash TEXT NOT NULL UNIQUE,
4123
+ user_id TEXT NOT NULL,
4124
+ name TEXT NOT NULL,
4125
+ created_at TEXT NOT NULL,
4126
+ expires_at TEXT,
4127
+ last_used_at TEXT,
4128
+ member_checked_at TEXT
4129
+ );
4130
+ CREATE INDEX api_tokens_user ON api_tokens (user_id);
4088
4131
  `
4089
4132
  ];
4133
+ var BUSY_TIMEOUT_MS = 5000;
4090
4134
  function migrate(db) {
4091
4135
  const current = db.query("PRAGMA user_version").get().user_version;
4092
4136
  if (current > MIGRATIONS.length) {
@@ -4098,14 +4142,224 @@ function migrate(db) {
4098
4142
  db.exec(`PRAGMA user_version = ${version + 1}`);
4099
4143
  })();
4100
4144
  }
4145
+ return current;
4101
4146
  }
4102
4147
  function openDatabase(path) {
4103
4148
  const db = new Database(path, { create: true });
4104
- if (path !== ":memory:")
4105
- db.exec("PRAGMA journal_mode = WAL");
4106
- db.exec("PRAGMA busy_timeout = 5000");
4107
- migrate(db);
4108
- return db;
4149
+ try {
4150
+ if (path !== ":memory:")
4151
+ db.exec("PRAGMA journal_mode = WAL");
4152
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
4153
+ db.exec("PRAGMA secure_delete = ON");
4154
+ migrate(db);
4155
+ return db;
4156
+ } catch (err) {
4157
+ db.close();
4158
+ throw err;
4159
+ }
4160
+ }
4161
+ var META_TABLE = "tracker_meta";
4162
+ var VACUUMED = "vacuumed_for_secure_delete";
4163
+ function vacuumOnce(db, path) {
4164
+ if (path === ":memory:")
4165
+ return { ran: false };
4166
+ try {
4167
+ db.exec(`CREATE TABLE IF NOT EXISTS ${META_TABLE} (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
4168
+ if (db.query(`SELECT 1 FROM ${META_TABLE} WHERE key = ?`).get(VACUUMED) !== null)
4169
+ return { ran: false };
4170
+ db.exec("PRAGMA busy_timeout = 0");
4171
+ try {
4172
+ db.exec("VACUUM");
4173
+ const r = db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
4174
+ if (r && Number(r.busy) !== 0)
4175
+ throw new Error("the write-ahead log is busy");
4176
+ } finally {
4177
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
4178
+ }
4179
+ db.query(`INSERT INTO ${META_TABLE} (key, value) VALUES (?, ?)`).run(VACUUMED, new Date().toISOString());
4180
+ return { ran: true };
4181
+ } catch (err) {
4182
+ return { ran: false, error: err instanceof Error ? err.message : String(err) };
4183
+ }
4184
+ }
4185
+
4186
+ // plugins/tracker/src/roster.ts
4187
+ var FORGOTTEN = "forgotten";
4188
+ function historyNames(kind, detail, id) {
4189
+ switch (kind) {
4190
+ case "recipient_invited":
4191
+ case "recipient_accepted":
4192
+ case "recipient_declined":
4193
+ case "recipient_opted_out":
4194
+ return detail === id;
4195
+ case "blocked":
4196
+ return detail === `${id} 24h` || detail === `${id} permanent`;
4197
+ case "recipient_removed":
4198
+ return detail.startsWith(`${id}: `);
4199
+ case "paused":
4200
+ return detail.startsWith(`delivery to {${id}} paused:`);
4201
+ default:
4202
+ return false;
4203
+ }
4204
+ }
4205
+ function eventNames(type, text, id) {
4206
+ return type === "delivered" && text.startsWith(`${id} `);
4207
+ }
4208
+ function errorForms(id) {
4209
+ return new RegExp(`(recipient |user |delivery to )${id}(?![0-9A-Za-z_])`, "g");
4210
+ }
4211
+ function errorNames(text, id) {
4212
+ return text !== null && errorForms(id).test(text);
4213
+ }
4214
+ function redactError(text, id) {
4215
+ return text.replace(errorForms(id), `$1(${FORGOTTEN})`);
4216
+ }
4217
+
4218
+ class Roster {
4219
+ db;
4220
+ constructor(db) {
4221
+ this.db = db;
4222
+ }
4223
+ people() {
4224
+ const rows = this.db.query(`SELECT u.seq, u.discord_id, u.display_name, u.time_zone, u.preferred_hour, u.admin, u.created_at,
4225
+ a.admitted_by, a.registered_at, h.paused_at, h.failures
4226
+ FROM users u
4227
+ LEFT JOIN admissions a ON a.user_id = 'u' || u.seq
4228
+ LEFT JOIN delivery_health h ON h.user_id = 'u' || u.seq
4229
+ ORDER BY u.seq`).all();
4230
+ const owned = this.db.query("SELECT owner_id, status, COUNT(*) AS n FROM tasks GROUP BY owner_id, status").all();
4231
+ const receiving = this.db.query(`SELECT r.user_id, COUNT(*) AS n FROM task_recipients r JOIN tasks t ON r.task_id = 't' || t.seq
4232
+ WHERE r.state = 'accepted' AND t.status IN ('active', 'paused') GROUP BY r.user_id`).all();
4233
+ return rows.map((r) => {
4234
+ const id = `u${Number(r.seq)}`;
4235
+ const counts = { active: 0, paused: 0, done: 0, archived: 0 };
4236
+ for (const o of owned)
4237
+ if (o.owner_id === id && o.status in counts)
4238
+ counts[o.status] = Number(o.n);
4239
+ const str = (v) => v === null || v === undefined ? null : String(v);
4240
+ return {
4241
+ id,
4242
+ discordId: str(r.discord_id),
4243
+ displayName: str(r.display_name),
4244
+ timeZone: String(r.time_zone),
4245
+ preferredHour: Number(r.preferred_hour),
4246
+ admin: Number(r.admin) === 1,
4247
+ createdAt: String(r.created_at),
4248
+ admittedBy: str(r.admitted_by),
4249
+ registeredAt: str(r.registered_at),
4250
+ deliveryPausedAt: str(r.paused_at),
4251
+ failures: Number(r.failures ?? 0),
4252
+ owned: counts,
4253
+ receiving: Number(receiving.find((x) => x.user_id === id)?.n ?? 0)
4254
+ };
4255
+ });
4256
+ }
4257
+ checkpoint() {
4258
+ this.db.exec("PRAGMA busy_timeout = 0");
4259
+ try {
4260
+ const r = this.db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
4261
+ return !r || Number(r.busy) === 0;
4262
+ } finally {
4263
+ this.db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
4264
+ }
4265
+ }
4266
+ othersNamed(taskId, userId, name) {
4267
+ const ids = new Set;
4268
+ for (const r of this.db.query("SELECT user_id FROM task_recipients WHERE task_id = ?").all(taskId))
4269
+ ids.add(r.user_id);
4270
+ for (const r of this.db.query("SELECT detail FROM task_events WHERE task_id = ? AND kind LIKE 'recipient_%'").all(taskId)) {
4271
+ const id = /^(u[1-9][0-9]*)(:|$)/.exec(r.detail)?.[1];
4272
+ if (id)
4273
+ ids.add(id);
4274
+ }
4275
+ ids.delete(userId);
4276
+ const named = this.db.query("SELECT 1 FROM users WHERE seq = ? AND display_name = ?");
4277
+ return [...ids].some((id) => named.get(Number(id.slice(1)), name) !== null);
4278
+ }
4279
+ admins() {
4280
+ return this.db.query("SELECT COUNT(*) AS n FROM users WHERE admin = 1").get().n;
4281
+ }
4282
+ activeBlocks(now) {
4283
+ const rows = this.db.query(`SELECT seq, owner_id, recipient_id, expires_at, created_at FROM invite_blocks
4284
+ WHERE lifted_at IS NULL AND (expires_at IS NULL OR expires_at > ?) ORDER BY seq DESC`).all(now.toISOString());
4285
+ return rows.map((r) => ({
4286
+ id: `b${Number(r.seq)}`,
4287
+ ownerId: String(r.owner_id),
4288
+ recipientId: String(r.recipient_id),
4289
+ expiresAt: r.expires_at === null ? null : String(r.expires_at),
4290
+ createdAt: String(r.created_at)
4291
+ }));
4292
+ }
4293
+ erase(userId) {
4294
+ const seq = /^u([1-9][0-9]*)$/.exec(userId)?.[1];
4295
+ if (seq === undefined)
4296
+ throw new Error(`no user ${userId}`);
4297
+ return this.db.transaction(() => {
4298
+ const person = this.db.query("SELECT display_name FROM users WHERE seq = ?").get(Number(seq));
4299
+ if (!person)
4300
+ throw new Error(`no user ${userId}`);
4301
+ const rows = {};
4302
+ const del = (table, where, ...params) => {
4303
+ rows[table] = (rows[table] ?? 0) + this.db.query(`DELETE FROM ${table} WHERE ${where}`).run(...params).changes;
4304
+ };
4305
+ const THEIR_TASKS = "SELECT 't' || seq FROM tasks WHERE owner_id = ?";
4306
+ const THEIR_RUNS = `SELECT 'o' || seq FROM occurrences WHERE task_id IN (${THEIR_TASKS})`;
4307
+ const pausedFor = this.db.query(`SELECT task_id FROM delivery_pauses WHERE user_id = ? AND task_id NOT IN (${THEIR_TASKS})`).all(userId, userId).map((r) => r.task_id);
4308
+ const taskIds = (sql) => this.db.query(sql).all(userId).map((r) => r.t);
4309
+ const recipientOf = new Set([
4310
+ ...pausedFor,
4311
+ ...taskIds("SELECT task_id AS t FROM task_recipients WHERE user_id = ?"),
4312
+ ...this.db.query("SELECT task_id AS t, kind, detail FROM task_events WHERE instr(detail, ?) > 0").all(userId).filter((r) => historyNames(r.kind, r.detail, userId)).map((r) => r.t)
4313
+ ]);
4314
+ del("events", `occurrence_id IN (${THEIR_RUNS})`, userId);
4315
+ del("delivery_claims", `occurrence_id IN (${THEIR_RUNS}) OR user_id = ?`, userId, userId);
4316
+ del("replies", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
4317
+ del("task_events", `task_id IN (${THEIR_TASKS}) OR actor_id = ?`, userId, userId);
4318
+ del("series", `task_id IN (${THEIR_TASKS})`, userId);
4319
+ del("task_recipients", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
4320
+ del("delivery_pauses", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
4321
+ del("occurrences", `task_id IN (${THEIR_TASKS})`, userId);
4322
+ del("tasks", "owner_id = ?", userId);
4323
+ const containing = (table, columns, column) => this.db.query(`SELECT seq, ${columns} FROM ${table} WHERE instr(${column}, ?) > 0`).all(userId);
4324
+ for (const r of containing("task_events", "kind, detail", "detail")) {
4325
+ if (historyNames(String(r.kind), String(r.detail), userId))
4326
+ del("task_events", "seq = ?", Number(r.seq));
4327
+ }
4328
+ for (const r of containing("events", "type, text", "text")) {
4329
+ if (eventNames(String(r.type), String(r.text), userId))
4330
+ del("events", "seq = ?", Number(r.seq));
4331
+ else if (errorNames(String(r.text), userId))
4332
+ this.db.query("UPDATE events SET text = ? WHERE seq = ?").run(redactError(String(r.text), userId), r.seq);
4333
+ }
4334
+ for (const r of containing("occurrences", "error", "error")) {
4335
+ if (errorNames(String(r.error), userId))
4336
+ this.db.query("UPDATE occurrences SET error = ? WHERE seq = ?").run(redactError(String(r.error), userId), r.seq);
4337
+ }
4338
+ if (person.display_name !== null) {
4339
+ const legacy = `delivery to ${person.display_name} paused:`;
4340
+ const rowsNaming = this.db.query("SELECT seq, task_id, detail FROM task_events WHERE kind = 'paused' AND actor_id IS NULL").all();
4341
+ for (const r of rowsNaming) {
4342
+ if (!recipientOf.has(r.task_id) || !r.detail.startsWith(legacy))
4343
+ continue;
4344
+ if (this.othersNamed(r.task_id, userId, person.display_name))
4345
+ continue;
4346
+ del("task_events", "seq = ?", r.seq);
4347
+ }
4348
+ }
4349
+ del("invite_blocks", "owner_id = ? OR recipient_id = ?", userId, userId);
4350
+ this.db.query("UPDATE invite_blocks SET lifted_by = ? WHERE lifted_by = ?").run(FORGOTTEN, userId);
4351
+ del("admissions", "user_id = ?", userId);
4352
+ this.db.query("UPDATE admissions SET admitted_by = ? WHERE admitted_by = ?").run(FORGOTTEN, userId);
4353
+ del("delivery_health", "user_id = ?", userId);
4354
+ del("web_sessions", "user_id = ?", userId);
4355
+ del("web_login_tokens", "user_id = ?", userId);
4356
+ del("api_tokens", "user_id = ?", userId);
4357
+ del("users", "seq = ?", Number(seq));
4358
+ const held = this.db.query("SELECT 1 FROM delivery_pauses WHERE task_id = ? LIMIT 1");
4359
+ const freed = [...new Set(pausedFor)].filter((t) => held.get(t) === null);
4360
+ return { rows, freed };
4361
+ })();
4362
+ }
4109
4363
  }
4110
4364
 
4111
4365
  // plugins/tracker/src/store.ts
@@ -4428,160 +4682,97 @@ class SqliteStore {
4428
4682
  }
4429
4683
  }
4430
4684
 
4431
- // plugins/tracker/src/edit.ts
4432
- async function owned(d, user, taskId, type) {
4433
- const task = await ownLiveTask(d, user, taskId);
4434
- if (!task || task.type !== type)
4435
- return NO_SUCH_TASK;
4436
- if (!editable(task))
4437
- return FINISHED;
4438
- return task;
4439
- }
4440
- function same(a, b) {
4441
- return JSON.stringify(a) === JSON.stringify(b);
4442
- }
4443
- async function applyEdit(d, task, user, patch) {
4444
- const fields = Object.keys(patch);
4445
- if (fields.length === 0)
4446
- return task;
4447
- const at = d.clock.now().toISOString();
4448
- const updated = await d.store.updateTask(task.id, { ...patch, at });
4449
- await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "edited", detail: fields.join(", "), at });
4450
- return updated;
4451
- }
4452
- async function nextRun(d, task) {
4453
- const queued = await d.store.listOccurrences({ taskId: task.id, status: "queued" });
4454
- return queued[0] ?? null;
4455
- }
4456
- function savedText(task, user, next, now, what) {
4457
- const cadence = task.schedule ? describeSchedule(task.schedule, user, user.timeZone, now) : "no schedule";
4458
- const when = task.status === "paused" ? "paused" : next ? formatInstant(next.dueAt, user.timeZone, now) : "nothing scheduled";
4459
- return clip(`Saved \`${task.id}\` ${what}: ${cadence}. Next: ${when}.`);
4685
+ // plugins/tracker/src/admin.ts
4686
+ var NO_SUCH_PERSON = "There is no such person on this tracker's list.";
4687
+ var NO_SUCH_BLOCK = "There is no such block in force.";
4688
+ var LAST_ADMIN = "That is the tracker's only admin: make someone else an admin first.";
4689
+ var BUSY = "The tracker is busy sending reminders; nothing was deleted. Try again in a minute.";
4690
+ var TICK_WAIT_MS = 20000;
4691
+ var CONFIGURED_ADMIN = "That admin is named in TRACKER_ADMIN_DISCORD_IDS, which makes them an admin again at every start: remove them from the configuration first.";
4692
+ var CONFIGURED_SELF = "You are named in TRACKER_ADMIN_DISCORD_IDS: this erases you now, but the next start makes you an admin again, as a new person, unless you are removed from the configuration first.";
4693
+ function isConfiguredAdmin(d, u) {
4694
+ return u.discordId !== null && d.configuredAdmins.has(u.discordId);
4460
4695
  }
4461
- var blank = (v) => v === undefined || v.trim() === "";
4462
- async function editReminder(d, user, taskId, input) {
4463
- const task = await owned(d, user, taskId, "reminder");
4464
- if (typeof task === "string")
4465
- return { ok: false, error: task };
4466
- const config = task.config ?? {};
4467
- const text = blank(input.text) ? String(config.text ?? "") : input.text;
4468
- const repeat = input.repeat ?? repeatOf(task.schedule);
4469
- const plan = reminderPlan(d, user, { text, repeat, ...input.when !== undefined ? { when: input.when } : {} }, task.schedule);
4470
- if (!plan.ok)
4471
- return plan;
4472
- let updated = config.text === plan.text ? task : await applyEdit(d, task, user, { title: clip(plan.text, MAX_TITLE), config: { ...config, text: plan.text } });
4473
- if (!same(plan.schedule, task.schedule))
4474
- updated = await rescheduleKeepingSnoozes(d, updated, user, plan.schedule, user.id);
4475
- return { ok: true, task: updated, text: savedText(updated, user, await nextRun(d, updated), d.clock.now(), "(reminder)") };
4696
+ function ticksSettled(d) {
4697
+ return d.lanes.idle(TICK_WAIT_MS);
4476
4698
  }
4477
- var DAY_MS3 = 24 * 60 * 60 * 1000;
4478
- function periodDateFrom(s, date, owner) {
4479
- const ctx = { zone: owner.timeZone, preferredHour: owner.preferredHour };
4480
- let after = new Date(Date.parse(`${date}T00:00:00Z`) - ((s.leadDays ?? 0) + 2) * DAY_MS3);
4481
- for (let i = 0;i < 1000; i++) {
4482
- const due = nextDue(s, after, ctx);
4483
- if (!due)
4484
- return null;
4485
- const on = periodDate(s, due, owner.timeZone);
4486
- if (on >= date)
4487
- return on;
4488
- after = due;
4489
- }
4490
- return null;
4699
+ function isPersonId(id) {
4700
+ return /^u[1-9][0-9]{0,15}$/.test(id);
4491
4701
  }
4492
- function renewalDate(task, owner, now) {
4493
- const s = task.schedule;
4494
- if (s?.kind !== "period")
4495
- return "";
4496
- const w = wallClock(now, owner.timeZone);
4497
- const today = `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
4498
- if (s.anchor >= today)
4499
- return s.anchor;
4500
- return periodDateFrom(s, today, owner) ?? s.anchor;
4702
+ async function person(d, id) {
4703
+ return isPersonId(id) ? d.store.getUser(id) : null;
4501
4704
  }
4502
- async function askedAbout(d, task, owner, date) {
4503
- const old = task.schedule;
4504
- if (old?.kind !== "period")
4505
- return false;
4506
- const fired = (await d.store.listOccurrences({ taskId: task.id })).filter((o) => o.status !== "queued" && !o.dedupeKey.startsWith(SNOOZE_PREFIX));
4507
- return fired.some((o) => periodDate(old, new Date(o.dueAt), owner.timeZone) === date);
4705
+ function who(u) {
4706
+ return u.displayName ?? u.discordId ?? u.id;
4508
4707
  }
4509
- async function editRenewal(d, user, taskId, input) {
4510
- const task = await owned(d, user, taskId, "renewal");
4511
- if (typeof task === "string")
4512
- return { ok: false, error: task };
4513
- if (task.schedule?.kind !== "period")
4514
- return { ok: false, error: FINISHED };
4515
- const old = task.schedule;
4708
+ async function setAdminFlag(d, admin, targetId, flag) {
4709
+ if (!admin.admin)
4710
+ return { ok: false, error: NOT_ADMIN };
4711
+ const target = await person(d, targetId);
4712
+ if (!target)
4713
+ return { ok: false, error: NO_SUCH_PERSON };
4714
+ if (target.admin === flag)
4715
+ return { ok: true, text: `${who(target)} ${flag ? "is already" : "is not"} an admin.` };
4716
+ if (!flag && d.roster.admins() <= 1)
4717
+ return { ok: false, error: LAST_ADMIN };
4718
+ if (!flag && isConfiguredAdmin(d, target))
4719
+ return { ok: false, error: CONFIGURED_ADMIN };
4720
+ await d.store.updateUser(target.id, { admin: flag });
4721
+ d.log.info(`${admin.id} ${flag ? "made" : "revoked"} ${target.id} ${flag ? "an admin" : "as admin"}`);
4722
+ return { ok: true, text: flag ? `${who(target)} is now an admin.` : `${who(target)} is no longer an admin.` };
4723
+ }
4724
+ async function resumeDelivery(d, admin, targetId) {
4725
+ if (!admin.admin)
4726
+ return { ok: false, error: NOT_ADMIN };
4727
+ const target = await person(d, targetId);
4728
+ if (!target)
4729
+ return { ok: false, error: NO_SUCH_PERSON };
4730
+ const resumed = await d.health.resume(target.id, d.clock.now());
4731
+ if (!resumed)
4732
+ return { ok: false, error: `Delivery to ${who(target)} is not paused.` };
4733
+ return { ok: true, text: `Delivery to ${who(target)} resumed; ${resumed.tasks.length} task(s) back on.` };
4734
+ }
4735
+ async function liftDeclineBlock(d, admin, blockId) {
4736
+ if (!admin.admin)
4737
+ return { ok: false, error: NOT_ADMIN };
4738
+ const block = /^b[1-9][0-9]{0,15}$/.test(blockId) ? await d.store.getBlock(blockId) : null;
4516
4739
  const now = d.clock.now();
4517
- const config = task.config;
4518
- const state = task.state;
4519
- const carried = state && typeof state.amount === "number" ? state.amount : null;
4520
- const note = input.note ?? config.note;
4521
- const plan = renewalPlan(d, user, {
4522
- name: blank(input.name) ? task.title : input.name,
4523
- amount: input.amount ?? carried ?? config.amount,
4524
- currency: blank(input.currency) ? config.currency : input.currency,
4525
- renews: blank(input.renews) ? renewalDate(task, user, now) : input.renews,
4526
- every: input.every ?? old.every,
4527
- unit: input.unit ?? old.unit,
4528
- lead: input.lead ?? old.leadDays ?? 0,
4529
- ...note !== undefined ? { note } : {}
4530
- });
4531
- if (!plan.ok)
4532
- return plan;
4533
- const sameShape = plan.schedule.every === old.every && plan.schedule.unit === old.unit && plan.schedule.leadDays === (old.leadDays ?? 0);
4534
- const schedule = sameShape && periodDateFrom(old, plan.schedule.anchor, user) === plan.schedule.anchor ? old : plan.schedule;
4535
- const problems = scheduleProblems(schedule);
4536
- if (problems.length > 0)
4537
- return { ok: false, error: `That schedule does not work: ${problems.join("; ")}.` };
4538
- const patch = {};
4539
- if (plan.name !== task.title)
4540
- patch.title = plan.name;
4541
- const newAmount = plan.amount !== (carried ?? config.amount);
4542
- const nextConfig = { amount: newAmount ? plan.amount : config.amount, currency: plan.currency, ...plan.note ? { note: plan.note } : {} };
4543
- if (!same(nextConfig, config))
4544
- patch.config = nextConfig;
4545
- if (newAmount && carried !== null)
4546
- patch.state = { ...state, amount: plan.amount };
4547
- let updated = await applyEdit(d, task, user, patch);
4548
- let next = await nextRun(d, updated);
4549
- if (!same(schedule, old)) {
4550
- const asked = await askedAbout(d, task, user, schedule.anchor);
4551
- updated = await rescheduleKeepingSnoozes(d, updated, user, schedule, user.id);
4552
- next = (asked ? null : await firstAskIfDue(d, updated, user, schedule)) ?? await nextRun(d, updated);
4553
- }
4554
- const ask = updated.status === "paused" ? "paused" : askText(next, plan.days, user, now);
4555
- const cadence = describeSchedule(schedule, user, user.timeZone, now);
4556
- return { ok: true, task: updated, text: clip(`Saved \`${updated.id}\` (renewal): ${cadence}. Next ask: ${ask}.`) };
4740
+ if (!block || (await activeBlock(d.store, block.ownerId, block.recipientId, now))?.id !== block.id)
4741
+ return { ok: false, error: NO_SUCH_BLOCK };
4742
+ await liftBlock(d.store, { userId: admin.id, admin: true }, block.id, now);
4743
+ const owner = await d.store.getUser(block.ownerId);
4744
+ const recipient = await d.store.getUser(block.recipientId);
4745
+ return {
4746
+ ok: true,
4747
+ text: `Lifted: ${owner ? who(owner) : block.ownerId} may invite ${recipient ? who(recipient) : block.recipientId} again.`
4748
+ };
4557
4749
  }
4558
- async function editPrice(d, user, taskId, input) {
4559
- const task = await owned(d, user, taskId, "price");
4560
- if (typeof task === "string")
4561
- return { ok: false, error: task };
4562
- const config = task.config;
4563
- const schedule = task.schedule;
4564
- const plan = priceSettingsPlan({
4565
- name: input.name ?? task.title,
4566
- hours: input.hours ?? (schedule?.kind === "poll" ? schedule.every : undefined),
4567
- drop: input.drop ?? config.dropPercent ?? DEFAULT_DROP_PERCENT,
4568
- baseline: input.baseline ?? config.baseline ?? DEFAULT_BASELINE
4750
+ async function forgetPerson(d, actor, targetId) {
4751
+ if (actor.id !== targetId && !actor.admin)
4752
+ return { ok: false, error: NOT_ADMIN };
4753
+ const target = await person(d, targetId);
4754
+ if (!target)
4755
+ return { ok: false, error: NO_SUCH_PERSON };
4756
+ if (target.admin && d.roster.admins() <= 1)
4757
+ return { ok: false, error: LAST_ADMIN };
4758
+ if (actor.id !== target.id && isConfiguredAdmin(d, target))
4759
+ return { ok: false, error: CONFIGURED_ADMIN };
4760
+ const now = d.clock.now();
4761
+ const erased = await d.health.exclusive(async () => {
4762
+ if (d.lanes.busy())
4763
+ return null;
4764
+ const e = d.roster.erase(target.id);
4765
+ for (const taskId of e.freed)
4766
+ await d.health.release(taskId, null, "resumed: a recipient left the tracker", now);
4767
+ return e;
4569
4768
  });
4570
- if (!plan.ok)
4571
- return plan;
4572
- const patch = {};
4573
- const title = plan.name || titleFor(config.url);
4574
- if (title !== task.title)
4575
- patch.title = title;
4576
- const nextConfig = { ...config, dropPercent: plan.drop, baseline: plan.baseline };
4577
- if (!same(nextConfig, config))
4578
- patch.config = nextConfig;
4579
- let updated = await applyEdit(d, task, user, patch);
4580
- if (schedule?.kind === "poll" && (schedule.every !== plan.hours || schedule.unit !== "hour")) {
4581
- const next = { ...schedule, every: plan.hours, unit: "hour" };
4582
- updated = await rescheduleKeepingSnoozes(d, updated, user, next, user.id);
4583
- }
4584
- return { ok: true, task: updated, text: savedText(updated, user, await nextRun(d, updated), d.clock.now(), "(price)") };
4769
+ if (erased === null)
4770
+ return { ok: false, error: BUSY };
4771
+ if (!d.roster.checkpoint())
4772
+ d.log.warn(`after forgetting ${target.id}: the write-ahead log is busy, so SQLite's next checkpoint copies it back`);
4773
+ const total = Object.values(erased.rows).reduce((a, b) => a + b, 0);
4774
+ d.log.info(`forgot ${target.id} (${actor.id === target.id ? "at their own request" : `removed by ${actor.id}`}): ${total} row(s) deleted`);
4775
+ return { ok: true, text: actor.id === target.id ? "Everything the tracker held about you is deleted." : `${who(target)} is removed from the tracker.` };
4585
4776
  }
4586
4777
 
4587
4778
  // node_modules/@rackbops/styles/rackbops-noir/bundle.css
@@ -5810,6 +6001,7 @@ function page(f) {
5810
6001
  <span class="rb-muted">${f.signedIn.name}</span>
5811
6002
  <a class="rb-link" href="${f.base}/">My tasks</a>
5812
6003
  <a class="rb-link" href="${f.base}/settings">Settings</a>
6004
+ ${f.signedIn.admin ? html`<a class="rb-link" href="${f.base}/admin">Admin</a>` : null}
5813
6005
  <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>
5814
6006
  </nav>` : null;
5815
6007
  return html`<!doctype html>
@@ -5883,7 +6075,7 @@ function name3(u) {
5883
6075
  return u.displayName ?? "you";
5884
6076
  }
5885
6077
  function framed(v, title, body) {
5886
- return page({ base: v.base, title, signedIn: { name: name3(v.user), csrf: v.csrf }, body });
6078
+ return page({ base: v.base, title, signedIn: { name: name3(v.user), csrf: v.csrf, admin: v.user.admin }, body });
5887
6079
  }
5888
6080
  function signInHelpPage(base, note) {
5889
6081
  return page({
@@ -5967,6 +6159,7 @@ function historyPage(v, h, extra = {}) {
5967
6159
  <p><a class="rb-link" href="${v.base}/">My tasks</a></p>
5968
6160
  <h1>${h.task.title}</h1>
5969
6161
  ${extra.flash ?? null}
6162
+ ${extra.owner ? html`<p class="rb-muted">Owned by ${extra.owner}; you can see it, not change it.</p>` : null}
5970
6163
  <p>${h.task.type}, ${h.task.status === "archived" ? "deleted" : h.task.status}, ${h.cadence}. Next: ${h.task.status === "active" ? h.next ?? "nothing scheduled" : "nothing, while it is not active"}.</p>
5971
6164
  ${extra.controls ?? null}
5972
6165
  </section>
@@ -6001,6 +6194,14 @@ ${form.error ? html`<div class="rb-alert rb-alert--danger" role="alert"><p class
6001
6194
  </div>
6002
6195
  <div><button class="rb-btn rb-btn--primary" type="submit">Save</button></div>
6003
6196
  </form>
6197
+ </section>
6198
+ <section>
6199
+ <h2>API tokens</h2>
6200
+ <p>For a program that uses the tracker's task API as you. <a class="rb-link" href="${v.base}/tokens">API tokens</a></p>
6201
+ </section>
6202
+ <section>
6203
+ <h2>Forget me</h2>
6204
+ <p>Delete everything the tracker holds about you, for good. <a class="rb-link" href="${v.base}/forget">Forget me</a></p>
6004
6205
  </section>`);
6005
6206
  }
6006
6207
  function notFoundPage(base, v) {
@@ -6152,64 +6353,671 @@ function notice(key, error) {
6152
6353
  return text ? html`<div class="rb-alert rb-alert--success" role="status"><p class="rb-alert__title">${text}</p></div>` : null;
6153
6354
  }
6154
6355
 
6155
- // plugins/tracker/src/web/editor.ts
6156
- var READING = "I am still reading the page of your last new price tracker; try again when it is done.";
6157
- var GONE = "You are no longer on this tracker's list.";
6158
- function asViewer(e, fn) {
6159
- return e.queue(async () => {
6160
- const user = await e.d.store.getUser(e.v.user.id);
6161
- return user ? fn(user) : { ok: false, error: GONE };
6162
- });
6163
- }
6164
- var str2 = (form, name) => form.get(name) ?? "";
6165
- var opt = (form, name) => str2(form, name).trim() === "" ? undefined : str2(form, name);
6166
- function int(form, name) {
6167
- const raw = str2(form, name).trim();
6168
- if (raw === "")
6169
- return;
6170
- return /^[0-9]{1,9}$/.test(raw) ? Number(raw) : Number.NaN;
6171
- }
6172
- function decimal(form, name) {
6173
- const raw = str2(form, name).trim();
6174
- if (raw === "")
6175
- return;
6176
- return /^[0-9]{1,12}(\.[0-9]{1,6})?$/.test(raw) ? Number(raw) : Number.NaN;
6356
+ // plugins/tracker/src/web/api-tokens.ts
6357
+ var TOKEN_PREFIX = "trk_";
6358
+ var MAX_TOKENS_PER_PERSON = 10;
6359
+ var MAX_TOKEN_NAME = 50;
6360
+ var TOKEN_EXPIRY_DAYS = [30, 90, 365];
6361
+ var DEFAULT_EXPIRY_DAYS = 90;
6362
+ var DAY_MS3 = 24 * 60 * 60 * 1000;
6363
+ var TOUCH_EVERY_MS = 60 * 1000;
6364
+ function isApiTokenShape(value) {
6365
+ return /^trk_[A-Za-z0-9_-]{43}$/.test(value);
6177
6366
  }
6178
- function typed(type, mode, form) {
6179
- return Object.fromEntries(fieldsFor(type, mode).map((f) => [f.name, str2(form, f.name)]));
6367
+ function isTokenId(id) {
6368
+ return /^k[1-9][0-9]{0,15}$/.test(id);
6180
6369
  }
6181
- function reminderInput(form) {
6182
- const when = opt(form, "when");
6183
- return { text: str2(form, "text"), ...when !== undefined ? { when } : {}, repeat: str2(form, "repeat") || "none" };
6370
+ function expiryChoice(raw) {
6371
+ const days = Number(raw);
6372
+ return TOKEN_EXPIRY_DAYS.includes(days) ? days : undefined;
6184
6373
  }
6185
- function renewalInput(form) {
6186
- const every = int(form, "every");
6187
- const lead = int(form, "lead");
6188
- const unit = opt(form, "unit");
6189
- const note = opt(form, "note");
6374
+ var COLUMNS = "seq, user_id, name, created_at, expires_at, last_used_at, member_checked_at";
6375
+ function toToken(r) {
6190
6376
  return {
6191
- name: str2(form, "name"),
6192
- amount: decimal(form, "amount") ?? Number.NaN,
6193
- currency: str2(form, "currency"),
6194
- renews: str2(form, "renews"),
6195
- ...every !== undefined ? { every } : {},
6196
- ...lead !== undefined ? { lead } : {},
6197
- ...unit !== undefined ? { unit } : {},
6198
- ...note !== undefined ? { note } : {}
6377
+ id: `k${Number(r.seq)}`,
6378
+ userId: r.user_id,
6379
+ name: r.name,
6380
+ createdAt: r.created_at,
6381
+ expiresAt: r.expires_at,
6382
+ lastUsedAt: r.last_used_at,
6383
+ memberCheckedAt: r.member_checked_at
6199
6384
  };
6200
6385
  }
6201
- function priceSettings(form) {
6202
- const name = opt(form, "name");
6203
- const hours = int(form, "hours");
6204
- const drop = decimal(form, "drop");
6205
- const baseline = opt(form, "baseline");
6206
- return {
6207
- ...name !== undefined ? { name } : {},
6208
- ...hours !== undefined ? { hours } : {},
6386
+
6387
+ class ApiTokens {
6388
+ db;
6389
+ constructor(db) {
6390
+ this.db = db;
6391
+ }
6392
+ create(userId, input, now, memberCheckedAt) {
6393
+ const name = input.name.trim();
6394
+ if (name.length === 0)
6395
+ return { ok: false, error: "Give the token a name, such as the program that will use it." };
6396
+ if (name.length > MAX_TOKEN_NAME)
6397
+ return { ok: false, error: `That name is longer than ${MAX_TOKEN_NAME} characters.` };
6398
+ if (/[\u0000-\u001f\u007f]/.test(name))
6399
+ return { ok: false, error: "That name has a control character in it." };
6400
+ if (!TOKEN_EXPIRY_DAYS.includes(input.days))
6401
+ return { ok: false, error: "Choose when the token expires." };
6402
+ const at = now.toISOString();
6403
+ return this.db.transaction(() => {
6404
+ const seq = /^u([1-9][0-9]*)$/.exec(userId)?.[1];
6405
+ if (seq === undefined || this.db.query("SELECT 1 FROM users WHERE seq = ?").get(Number(seq)) === null) {
6406
+ return { ok: false, error: "You are no longer on this tracker's list." };
6407
+ }
6408
+ this.db.query("DELETE FROM api_tokens WHERE expires_at IS NOT NULL AND expires_at <= ?").run(at);
6409
+ const live = this.db.query("SELECT COUNT(*) AS n FROM api_tokens WHERE user_id = ?").get(userId).n;
6410
+ if (live >= MAX_TOKENS_PER_PERSON) {
6411
+ return { ok: false, error: `You already have ${MAX_TOKENS_PER_PERSON} API tokens, the most one person may. Revoke one first.` };
6412
+ }
6413
+ const token = `${TOKEN_PREFIX}${randomToken()}`;
6414
+ const expiresAt = new Date(now.getTime() + input.days * DAY_MS3).toISOString();
6415
+ const r = this.db.query(`INSERT INTO api_tokens (token_hash, user_id, name, created_at, expires_at, last_used_at, member_checked_at)
6416
+ VALUES (?, ?, ?, ?, ?, NULL, ?) RETURNING ${COLUMNS}`).get(sha256(token), userId, name, at, expiresAt, memberCheckedAt);
6417
+ return { ok: true, token, row: toToken(r) };
6418
+ })();
6419
+ }
6420
+ find(token, now) {
6421
+ if (!isApiTokenShape(token))
6422
+ return null;
6423
+ const r = this.db.query(`SELECT ${COLUMNS} FROM api_tokens WHERE token_hash = ? AND (expires_at IS NULL OR expires_at > ?)`).get(sha256(token), now.toISOString());
6424
+ return r ? toToken(r) : null;
6425
+ }
6426
+ get(id) {
6427
+ if (!isTokenId(id))
6428
+ return null;
6429
+ const r = this.db.query(`SELECT ${COLUMNS} FROM api_tokens WHERE seq = ?`).get(Number(id.slice(1)));
6430
+ return r ? toToken(r) : null;
6431
+ }
6432
+ listFor(userId, now) {
6433
+ const rows = this.db.query(`SELECT ${COLUMNS} FROM api_tokens WHERE user_id = ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY seq`).all(userId, now.toISOString());
6434
+ return rows.map(toToken);
6435
+ }
6436
+ touch(token, now) {
6437
+ if (!isTokenId(token.id))
6438
+ return false;
6439
+ if (token.lastUsedAt !== null && now.getTime() - Date.parse(token.lastUsedAt) < TOUCH_EVERY_MS && Date.parse(token.lastUsedAt) <= now.getTime())
6440
+ return false;
6441
+ const cutoff = new Date(now.getTime() - TOUCH_EVERY_MS).toISOString();
6442
+ return this.db.query("UPDATE api_tokens SET last_used_at = ? WHERE seq = ? AND (last_used_at IS NULL OR last_used_at <= ? OR last_used_at > ?)").run(now.toISOString(), Number(token.id.slice(1)), cutoff, now.toISOString()).changes > 0;
6443
+ }
6444
+ revokeOwn(userId, id) {
6445
+ if (!isTokenId(id))
6446
+ return false;
6447
+ return this.db.query("DELETE FROM api_tokens WHERE seq = ? AND user_id = ?").run(Number(id.slice(1)), userId).changes > 0;
6448
+ }
6449
+ revokeAny(id) {
6450
+ if (!isTokenId(id))
6451
+ return null;
6452
+ const r = this.db.query("DELETE FROM api_tokens WHERE seq = ? RETURNING user_id").get(Number(id.slice(1)));
6453
+ return r ? r.user_id : null;
6454
+ }
6455
+ deleteForUser(userId) {
6456
+ this.db.query("DELETE FROM api_tokens WHERE user_id = ?").run(userId);
6457
+ }
6458
+ confirmMember(userId, at) {
6459
+ this.db.query("UPDATE api_tokens SET member_checked_at = ? WHERE user_id = ?").run(at, userId);
6460
+ }
6461
+ }
6462
+
6463
+ // plugins/tracker/src/web/token-pages.ts
6464
+ function tokenHref(v, id) {
6465
+ return `${v.base}/tokens/${encodeURIComponent(id)}/revoke`;
6466
+ }
6467
+ function adminTokenHref(v, id) {
6468
+ return `${v.base}/admin/tokens/${encodeURIComponent(id)}/revoke`;
6469
+ }
6470
+ function at(iso, viewer, now, none) {
6471
+ return iso ? formatInstant(iso, viewer.timeZone, now) : none;
6472
+ }
6473
+ function tokenTable(v, tokens, now, href) {
6474
+ if (tokens.length === 0)
6475
+ return html`<p class="rb-muted">None.</p>`;
6476
+ return html`<div class="rb-table-scroll"><table class="rb-table">
6477
+ <thead><tr><th>Name</th><th>Made</th><th>Last used</th><th>Expires</th><th></th></tr></thead>
6478
+ <tbody>${tokens.map((t) => html`<tr>
6479
+ <td>${t.name}</td>
6480
+ <td>${at(t.createdAt, v.user, now, "")}</td>
6481
+ <td>${at(t.lastUsedAt, v.user, now, "never")}</td>
6482
+ <td>${at(t.expiresAt, v.user, now, "never")}</td>
6483
+ <td><form method="post" action="${href(t.id)}"><input type="hidden" name="csrf" value="${v.csrf}"><button class="rb-btn rb-btn--danger rb-btn--sm" type="submit">Revoke</button></form></td>
6484
+ </tr>`)}</tbody>
6485
+ </table></div>`;
6486
+ }
6487
+ function tokensPage(v, tokens, now, f = {}) {
6488
+ const expiry = f.expiry ?? String(DEFAULT_EXPIRY_DAYS);
6489
+ const choices = TOKEN_EXPIRY_DAYS.map((d) => [String(d), `in ${d} days`]);
6490
+ return framed(v, "API tokens", html`<section>
6491
+ <p><a class="rb-link" href="${v.base}/settings">Settings</a></p>
6492
+ <h1>API tokens</h1>
6493
+ <p>A token lets a program use the tracker's task API as you: list, read, make, edit, pause, resume and delete your own tasks, nothing more. Send it as <code>Authorization: Bearer &lt;token&gt;</code> to <code>${v.base}/api/v1/</code>. Anyone holding it can act as you, so keep it secret, and revoke it when the program no longer needs it. A new token is sent to you by Discord DM, once, and never shown here. At most ${MAX_TOKENS_PER_PERSON}, each for a year at most.</p>
6494
+ ${f.result ? html`<div class="rb-alert rb-alert--success" role="status"><p>${f.result}</p></div>` : null}
6495
+ ${f.error ? html`<div class="rb-alert rb-alert--danger" role="alert"><p class="rb-alert__title">Not made</p><p>${f.error}</p></div>` : null}
6496
+ ${tokenTable(v, tokens, now, (id) => tokenHref(v, id))}
6497
+ </section>
6498
+ <section>
6499
+ <h2>Make a token</h2>
6500
+ <form method="post" action="${v.base}/tokens" class="tr-stack">
6501
+ <input type="hidden" name="csrf" value="${v.csrf}">
6502
+ <div class="rb-field">
6503
+ <label class="rb-label" for="name">Name</label>
6504
+ <input class="rb-input" id="name" name="name" value="${f.name ?? ""}" maxlength="${MAX_TOKEN_NAME}" required>
6505
+ <p class="rb-field__help">What will use it, so you know which to revoke.</p>
6506
+ </div>
6507
+ <div class="rb-field">
6508
+ <label class="rb-label" for="expiry">Expires</label>
6509
+ <select class="rb-select" id="expiry" name="expiry">${choices.map(([value, label]) => html`<option value="${value}"${value === expiry ? html` selected` : null}>${label}</option>`)}</select>
6510
+ </div>
6511
+ <div><button class="rb-btn rb-btn--primary" type="submit">Make token</button></div>
6512
+ </form>
6513
+ </section>`);
6514
+ }
6515
+
6516
+ // plugins/tracker/src/web/admin-pages.ts
6517
+ var CONFIGURED_NOTE = "Named in TRACKER_ADMIN_DISCORD_IDS: made an admin again at every start, so they cannot be revoked or removed here. Remove them from the configuration first.";
6518
+ var CONFIRM_WORD = "forget";
6519
+ function nameOf(p, fallback = "someone") {
6520
+ if (!p)
6521
+ return fallback;
6522
+ return p.displayName ?? p.id;
6523
+ }
6524
+ function unmention(text) {
6525
+ return text.replace(/<@([0-9]{17,20})>/g, "$1");
6526
+ }
6527
+ function flash(result) {
6528
+ if (!result)
6529
+ return null;
6530
+ const text = prose(unmention(result.text));
6531
+ return result.ok ? html`<div class="rb-alert rb-alert--success" role="status"><p>${text}</p></div>` : html`<div class="rb-alert rb-alert--danger" role="alert"><p>${text}</p></div>`;
6532
+ }
6533
+ function hidden(v) {
6534
+ return html`<input type="hidden" name="csrf" value="${v.csrf}">`;
6535
+ }
6536
+ function adminNav(v) {
6537
+ return html`<p class="tr-row"><a class="rb-link" href="${v.base}/admin">People</a> <a class="rb-link" href="${v.base}/admin/tasks">All tasks</a></p>`;
6538
+ }
6539
+ function personHref(v, id, action = "") {
6540
+ return `${v.base}/admin/people/${encodeURIComponent(id)}${action ? `/${action}` : ""}`;
6541
+ }
6542
+ function admittedBy(p, names) {
6543
+ if (p.admittedBy === null)
6544
+ return "the configuration";
6545
+ if (p.admittedBy === FORGOTTEN)
6546
+ return "an admin since forgotten";
6547
+ return names.get(p.admittedBy) ?? p.admittedBy;
6548
+ }
6549
+ function delivery(p, viewer, now) {
6550
+ if (p.deliveryPausedAt)
6551
+ return `paused since ${formatInstant(p.deliveryPausedAt, viewer.timeZone, now)}`;
6552
+ return p.failures > 0 ? `on (${p.failures} failed in a row)` : "on";
6553
+ }
6554
+ function counts(p) {
6555
+ const o = p.owned;
6556
+ return `${o.active} active, ${o.paused} paused, ${o.done} done, ${o.archived} deleted; receives ${p.receiving}`;
6557
+ }
6558
+ function adminPage(v, data, o = {}) {
6559
+ const names = new Map(data.people.map((p) => [p.id, nameOf(p)]));
6560
+ const rows = data.people.map((p) => html`<tr>
6561
+ <td><a class="rb-link" href="${personHref(v, p.id)}">${nameOf(p)}</a>${p.admin ? html` <span class="rb-badge">admin</span>` : null}</td>
6562
+ <td>${p.registeredAt ? "registered" : "admitted, not registered"}</td>
6563
+ <td>${p.timeZone}, ${String(p.preferredHour).padStart(2, "0")}:00</td>
6564
+ <td>${delivery(p, v.user, data.now)}</td>
6565
+ <td>${counts(p)}</td>
6566
+ </tr>`);
6567
+ const blocks = data.blocks.length === 0 ? html`<p class="rb-muted">No decline blocks are in force.</p>` : html`<ul>${data.blocks.map((b) => html`<li class="tr-row">${names.get(b.ownerId) ?? b.ownerId} may not invite ${names.get(b.recipientId) ?? b.recipientId} ${b.expiresAt ? `until ${formatInstant(b.expiresAt, v.user.timeZone, data.now)}` : "until an admin lifts it"}
6568
+ <form method="post" action="${v.base}/admin/blocks/${encodeURIComponent(b.id)}/lift">${hidden(v)}<button class="rb-btn rb-btn--ghost rb-btn--sm" type="submit">Lift</button></form></li>`)}</ul>`;
6569
+ return framed(v, "Admin", html`<section>
6570
+ <h1>People</h1>
6571
+ ${adminNav(v)}
6572
+ ${flash(o.result ?? null)}
6573
+ <div class="rb-table-scroll"><table class="rb-table">
6574
+ <thead><tr><th scope="col">Person</th><th scope="col">Status</th><th scope="col">Zone, hour</th><th scope="col">Delivery</th><th scope="col">Tasks</th></tr></thead>
6575
+ <tbody>${rows}</tbody>
6576
+ </table></div>
6577
+ <p class="rb-muted">Admitted by: ${data.people.map((p) => `${nameOf(p)} -- ${admittedBy(p, names)}`).join("; ")}.</p>
6578
+ </section>
6579
+ <section>
6580
+ <h2>Allow a person</h2>
6581
+ <form method="post" action="${v.base}/admin/allow" class="tr-stack">
6582
+ ${hidden(v)}
6583
+ <div class="rb-field">
6584
+ <label class="rb-label" for="discord_id">Discord user id</label>
6585
+ <input class="rb-input" id="discord_id" name="discord_id" value="${o.allow ?? ""}" maxlength="20" inputmode="numeric" required>
6586
+ <p class="rb-field__help">What <code>/allow</code> does: they can then <code>/register</code>.</p>
6587
+ </div>
6588
+ <div><button class="rb-btn rb-btn--primary" type="submit">Allow</button></div>
6589
+ </form>
6590
+ </section>
6591
+ <section>
6592
+ <h2>Decline blocks</h2>
6593
+ ${blocks}
6594
+ </section>`);
6595
+ }
6596
+ function adminTasksPage(v, tasks) {
6597
+ const rows = tasks.map(({ task, owner, receiving }) => html`<tr>
6598
+ <td><a class="rb-link" href="${taskHref(v, task.id)}">${task.title}</a></td>
6599
+ <td>${task.type}</td>
6600
+ <td>${owner}</td>
6601
+ <td>${task.status === "archived" ? "deleted" : task.status}</td>
6602
+ <td>${receiving}</td>
6603
+ </tr>`);
6604
+ return framed(v, "All tasks", html`<section>
6605
+ <h1>All tasks</h1>
6606
+ ${adminNav(v)}
6607
+ ${tasks.length === 0 ? html`<p class="rb-muted">There are no tasks.</p>` : html`<div class="rb-table-scroll"><table class="rb-table">
6608
+ <thead><tr><th scope="col">Task</th><th scope="col">Type</th><th scope="col">Owner</th><th scope="col">Status</th><th scope="col">Recipients</th></tr></thead>
6609
+ <tbody>${rows}</tbody>
6610
+ </table></div>`}
6611
+ </section>`);
6612
+ }
6613
+ function post2(v, p, action, label, style) {
6614
+ return html`<form method="post" action="${personHref(v, p.id, action)}">${hidden(v)}<button class="rb-btn ${style} rb-btn--sm" type="submit">${label}</button></form>`;
6615
+ }
6616
+ function personPage(v, p, tasks, now, result = null, configured = false, tokens = []) {
6617
+ return framed(v, nameOf(p), html`<section>
6618
+ ${adminNav(v)}
6619
+ <h1>${nameOf(p)}${p.admin ? html` <span class="rb-badge">admin</span>` : null}</h1>
6620
+ ${flash(result)}
6621
+ <ul>
6622
+ <li>Discord id: ${p.discordId ?? "none"}</li>
6623
+ <li>${p.registeredAt ? `Registered ${formatInstant(p.registeredAt, v.user.timeZone, now)}` : "Admitted, not registered yet"}</li>
6624
+ <li>Zone and hour: ${p.timeZone}, ${String(p.preferredHour).padStart(2, "0")}:00</li>
6625
+ <li>Delivery: ${delivery(p, v.user, now)}</li>
6626
+ <li>Tasks: ${counts(p)}</li>
6627
+ </ul>
6628
+ ${configured ? html`<p class="rb-muted">${CONFIGURED_NOTE}</p>` : null}
6629
+ <div class="tr-row">
6630
+ ${p.admin ? configured ? null : post2(v, p, "revoke", "Revoke admin", "rb-btn--ghost") : post2(v, p, "grant", "Make admin", "rb-btn--ghost")}
6631
+ ${p.deliveryPausedAt ? post2(v, p, "resume-delivery", "Resume delivery", "rb-btn--accent") : null}
6632
+ ${configured && p.id !== v.user.id ? null : post2(v, p, "forget", "Remove from the tracker", "rb-btn--danger")}
6633
+ </div>
6634
+ </section>
6635
+ <section>
6636
+ <h2>Their tasks</h2>
6637
+ ${tasks.length === 0 ? html`<p class="rb-muted">None.</p>` : html`<ul>${tasks.map((t) => html`<li><a class="rb-link" href="${taskHref(v, t.id)}">${t.title}</a> -- ${t.type}, ${t.status === "archived" ? "deleted" : t.status}</li>`)}</ul>`}
6638
+ </section>
6639
+ <section>
6640
+ <h2>Their API tokens</h2>
6641
+ <p class="rb-muted">Each acts as them on their own tasks. Revoking one stops it at once; they are not told.</p>
6642
+ ${tokenTable(v, tokens, now, (id) => adminTokenHref(v, id))}
6643
+ </section>`);
6644
+ }
6645
+ var WHAT_GOES = "every task (deleted ones too) with its runs, replies and history; your replies, answers and history on other people's tasks, " + "and your place on the tasks shared with you; decline blocks either way; delivery pauses; your settings, sign-in sessions and API tokens; and your place on the list.";
6646
+ function forgetPage(v, note) {
6647
+ return framed(v, "Forget me", html`<section class="rb-card">
6648
+ <h1>Forget me</h1>
6649
+ <p>This deletes everything the tracker holds about you, at once and for good: ${WHAT_GOES}</p>
6650
+ <p>Nothing is kept or archived. Messages the bot already sent you stay in your Discord DMs, where only you can delete them. To use the tracker again later, an admin has to <code>/allow</code> you again, and you start from nothing.</p>
6651
+ ${note ? html`<div class="rb-alert rb-alert--warning" role="note"><p>${note}</p></div>` : null}
6652
+ <form method="post" action="${v.base}/forget">${hidden(v)}<button class="rb-btn rb-btn--danger" type="submit">Continue</button></form>
6653
+ </section>`);
6654
+ }
6655
+ function confirmForgetPage(v, o) {
6656
+ const title = o.self ? "Delete everything about you?" : `Remove ${o.name} from the tracker?`;
6657
+ const what = o.self ? html`<p>Everything the tracker holds about you is deleted: ${WHAT_GOES} You are signed out.</p>` : html`<p>Everything the tracker holds about ${o.name} is deleted, as forget-me deletes it: their tasks, their replies and history, blocks, pauses, settings and sessions. They are signed out and must be allowed again to come back.</p>`;
6658
+ return framed(v, o.self ? "Forget me" : `Remove ${o.name}`, html`<section class="rb-card">
6659
+ <h1>${title}</h1>
6660
+ ${what}
6661
+ ${o.note ? html`<div class="rb-alert rb-alert--warning" role="note"><p>${o.note}</p></div>` : null}
6662
+ ${o.error ? html`<div class="rb-alert rb-alert--danger" role="alert"><p>${o.error}</p></div>` : null}
6663
+ <form method="post" action="${o.action}" class="tr-stack">
6664
+ ${hidden(v)}
6665
+ <input type="hidden" name="confirm" value="yes">
6666
+ <div class="rb-field">
6667
+ <label class="rb-label" for="word">Type ${CONFIRM_WORD} to confirm</label>
6668
+ <input class="rb-input" id="word" name="word" maxlength="20" autocomplete="off" required>
6669
+ </div>
6670
+ <div class="tr-row"><button class="rb-btn rb-btn--danger" type="submit">${o.self ? "Delete everything" : "Remove"}</button>
6671
+ <a class="rb-btn rb-btn--ghost" href="${o.self ? `${v.base}/settings` : `${v.base}/admin`}">Keep</a></div>
6672
+ </form>
6673
+ </section>`);
6674
+ }
6675
+
6676
+ // plugins/tracker/src/web/admin.ts
6677
+ function unknownPage(base) {
6678
+ return htmlResponse(notFoundPage(base), 404);
6679
+ }
6680
+ function fresh(a, admin, fn) {
6681
+ return a.queue(async () => {
6682
+ const me = await a.d.store.getUser(a.v.user.id);
6683
+ if (!me || admin && !me.admin)
6684
+ return null;
6685
+ return fn(me);
6686
+ });
6687
+ }
6688
+ function said2(done) {
6689
+ return done.ok ? { ok: true, text: done.text } : { ok: false, text: done.error };
6690
+ }
6691
+ function confirmed(form) {
6692
+ if (form.get("confirm") !== "yes")
6693
+ return "ask";
6694
+ return (form.get("word") ?? "").trim().toLowerCase() === CONFIRM_WORD ? "yes" : "wrong";
6695
+ }
6696
+ var WRONG_WORD = `Type ${CONFIRM_WORD} to confirm. Nothing was deleted.`;
6697
+ async function peoplePage(a, result, status, allow) {
6698
+ const data = { people: a.d.roster.people(), blocks: a.d.roster.activeBlocks(a.d.clock.now()), now: a.d.clock.now() };
6699
+ return htmlResponse(adminPage(a.v, data, { result, ...allow !== undefined ? { allow } : {} }), status);
6700
+ }
6701
+ async function onePerson(a, id, result = null) {
6702
+ const p = a.d.roster.people().find((x) => x.id === id);
6703
+ if (!p)
6704
+ return htmlResponse(notFoundPage(a.v.base, a.v), 404);
6705
+ const tasks = await a.d.store.listTasks({ ownerId: p.id });
6706
+ const tokens = a.d.apiTokens.listFor(p.id, a.d.clock.now());
6707
+ return htmlResponse(personPage(a.v, p, tasks, a.d.clock.now(), result, isConfiguredAdmin(a.d, p), tokens), result && !result.ok ? 400 : 200);
6708
+ }
6709
+ async function adminGet(a, r) {
6710
+ if (r.kind === "admin")
6711
+ return peoplePage(a, null, 200);
6712
+ if (r.kind === "admin-person")
6713
+ return onePerson(a, r.id);
6714
+ const people = new Map(a.d.roster.people().map((p) => [p.id, nameOf(p)]));
6715
+ const rows = [];
6716
+ for (const task of await a.d.store.listTasks()) {
6717
+ const receiving = (await a.d.store.listRecipients(task.id)).filter((x) => x.state === "accepted").length;
6718
+ rows.push({ task, owner: people.get(task.ownerId) ?? task.ownerId, receiving });
6719
+ }
6720
+ return htmlResponse(adminTasksPage(a.v, rows));
6721
+ }
6722
+ async function adminPost(a, r, form) {
6723
+ const base = a.v.base;
6724
+ if (r.kind === "admin-allow") {
6725
+ const raw = (form.get("discord_id") ?? "").trim();
6726
+ if (!/^[0-9]{17,20}$/.test(raw))
6727
+ return peoplePage(a, { ok: false, text: "That is not a Discord user id (17 to 20 digits)." }, 400, raw.slice(0, 20));
6728
+ const membership = await a.memberOf(raw);
6729
+ const text = await fresh(a, true, (me) => allowPerson(a.d, me, { discordId: raw, bot: false, membership }));
6730
+ if (text === null)
6731
+ return unknownPage(base);
6732
+ const ok = text.startsWith("Allowed") || text.includes("already on the list");
6733
+ return peoplePage(a, { ok, text }, ok ? 200 : 400);
6734
+ }
6735
+ if (r.kind === "admin-lift") {
6736
+ const done = await fresh(a, true, (me) => liftDeclineBlock(a.d, me, r.id));
6737
+ if (done === null)
6738
+ return unknownPage(base);
6739
+ return peoplePage(a, said2(done), done.ok ? 200 : 400);
6740
+ }
6741
+ if (r.kind === "admin-token-revoke") {
6742
+ const owner = await fresh(a, true, async (me) => {
6743
+ const whose = a.d.apiTokens.revokeAny(r.id);
6744
+ if (whose !== null)
6745
+ a.d.log.info(`${me.id} revoked API token ${r.id} of ${whose}`);
6746
+ return { whose };
6747
+ });
6748
+ if (owner === null)
6749
+ return unknownPage(base);
6750
+ if (owner.whose === null)
6751
+ return htmlResponse(notFoundPage(base, a.v), 404);
6752
+ return onePerson(a, owner.whose, { ok: true, text: "Revoked: that token no longer works." });
6753
+ }
6754
+ if (r.kind !== "admin-act")
6755
+ return unknownPage(base);
6756
+ if (r.action === "forget") {
6757
+ const target = a.d.roster.people().find((x) => x.id === r.id);
6758
+ if (!target)
6759
+ return htmlResponse(notFoundPage(base, a.v), 404);
6760
+ const step = confirmed(form);
6761
+ const self = target.id === a.v.user.id;
6762
+ const note = self && isConfiguredAdmin(a.d, target) ? { note: CONFIGURED_SELF } : {};
6763
+ const page = (error) => confirmForgetPage(a.v, { action: personHref(a.v, target.id, "forget"), self, name: nameOf(target), ...note, ...error ? { error } : {} });
6764
+ if (step === "ask")
6765
+ return htmlResponse(page());
6766
+ if (step === "wrong")
6767
+ return htmlResponse(page(WRONG_WORD), 400);
6768
+ if (!await ticksSettled(a.d))
6769
+ return htmlResponse(page(BUSY), 503);
6770
+ const done = await fresh(a, true, (me) => forgetPerson(a.d, me, target.id));
6771
+ if (done === null)
6772
+ return unknownPage(base);
6773
+ if (!done.ok)
6774
+ return htmlResponse(page(done.error), done.error === NO_SUCH_PERSON ? 404 : done.error === BUSY ? 503 : 400);
6775
+ if (self)
6776
+ return redirect(`${base}/signin?forgotten=1`, [a.signOutCookie]);
6777
+ return peoplePage(a, said2(done), 200);
6778
+ }
6779
+ const act = r.action === "grant" ? (me) => setAdminFlag(a.d, me, r.id, true) : r.action === "revoke" ? (me) => setAdminFlag(a.d, me, r.id, false) : (me) => resumeDelivery(a.d, me, r.id);
6780
+ const done = await fresh(a, true, act);
6781
+ if (done === null)
6782
+ return unknownPage(base);
6783
+ if (!done.ok && done.error === NO_SUCH_PERSON)
6784
+ return htmlResponse(notFoundPage(base, a.v), 404);
6785
+ const me = await a.d.store.getUser(a.v.user.id);
6786
+ if (!me?.admin)
6787
+ return redirect(`${base}/`);
6788
+ return onePerson(a, r.id, said2(done));
6789
+ }
6790
+ async function forgetRoute(a, method, form) {
6791
+ const base = a.v.base;
6792
+ const note = isConfiguredAdmin(a.d, a.v.user) ? CONFIGURED_SELF : undefined;
6793
+ if (method === "GET" || !form)
6794
+ return htmlResponse(forgetPage(a.v, note));
6795
+ const page = (error) => confirmForgetPage(a.v, { action: `${base}/forget`, self: true, name: nameOf(a.v.user), ...note ? { note } : {}, ...error ? { error } : {} });
6796
+ const step = confirmed(form);
6797
+ if (step === "ask")
6798
+ return htmlResponse(page());
6799
+ if (step === "wrong")
6800
+ return htmlResponse(page(WRONG_WORD), 400);
6801
+ if (!await ticksSettled(a.d))
6802
+ return htmlResponse(page(BUSY), 503);
6803
+ const done = await fresh(a, false, (me) => forgetPerson(a.d, me, me.id));
6804
+ if (done === null)
6805
+ return redirect(`${base}/signin`, [a.signOutCookie]);
6806
+ if (!done.ok)
6807
+ return htmlResponse(page(done.error), done.error === BUSY ? 503 : 400);
6808
+ return redirect(`${base}/signin?forgotten=1`, [a.signOutCookie]);
6809
+ }
6810
+
6811
+ // plugins/tracker/src/edit.ts
6812
+ async function owned(d, user, taskId, type) {
6813
+ const task = await ownLiveTask(d, user, taskId);
6814
+ if (!task || task.type !== type)
6815
+ return NO_SUCH_TASK;
6816
+ if (!editable(task))
6817
+ return FINISHED;
6818
+ return task;
6819
+ }
6820
+ function same(a, b) {
6821
+ return JSON.stringify(a) === JSON.stringify(b);
6822
+ }
6823
+ async function applyEdit(d, task, user, patch) {
6824
+ const fields = Object.keys(patch);
6825
+ if (fields.length === 0)
6826
+ return task;
6827
+ const at = d.clock.now().toISOString();
6828
+ const updated = await d.store.updateTask(task.id, { ...patch, at });
6829
+ await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "edited", detail: fields.join(", "), at });
6830
+ return updated;
6831
+ }
6832
+ async function nextRun(d, task) {
6833
+ const queued = await d.store.listOccurrences({ taskId: task.id, status: "queued" });
6834
+ return queued[0] ?? null;
6835
+ }
6836
+ function savedText(task, user, next, now, what) {
6837
+ const cadence = task.schedule ? describeSchedule(task.schedule, user, user.timeZone, now) : "no schedule";
6838
+ const when = task.status === "paused" ? "paused" : next ? formatInstant(next.dueAt, user.timeZone, now) : "nothing scheduled";
6839
+ return clip(`Saved \`${task.id}\` ${what}: ${cadence}. Next: ${when}.`);
6840
+ }
6841
+ var blank = (v) => v === undefined || v.trim() === "";
6842
+ async function editReminder(d, user, taskId, input) {
6843
+ const task = await owned(d, user, taskId, "reminder");
6844
+ if (typeof task === "string")
6845
+ return { ok: false, error: task };
6846
+ const config = task.config ?? {};
6847
+ const text = blank(input.text) ? String(config.text ?? "") : input.text;
6848
+ const repeat = input.repeat ?? repeatOf(task.schedule);
6849
+ const plan = reminderPlan(d, user, { text, repeat, ...input.when !== undefined ? { when: input.when } : {} }, task.schedule);
6850
+ if (!plan.ok)
6851
+ return plan;
6852
+ let updated = config.text === plan.text ? task : await applyEdit(d, task, user, { title: clip(plan.text, MAX_TITLE), config: { ...config, text: plan.text } });
6853
+ if (!same(plan.schedule, task.schedule))
6854
+ updated = await rescheduleKeepingSnoozes(d, updated, user, plan.schedule, user.id);
6855
+ return { ok: true, task: updated, text: savedText(updated, user, await nextRun(d, updated), d.clock.now(), "(reminder)") };
6856
+ }
6857
+ var DAY_MS4 = 24 * 60 * 60 * 1000;
6858
+ function periodDateFrom(s, date, owner) {
6859
+ const ctx = { zone: owner.timeZone, preferredHour: owner.preferredHour };
6860
+ let after = new Date(Date.parse(`${date}T00:00:00Z`) - ((s.leadDays ?? 0) + 2) * DAY_MS4);
6861
+ for (let i = 0;i < 1000; i++) {
6862
+ const due = nextDue(s, after, ctx);
6863
+ if (!due)
6864
+ return null;
6865
+ const on = periodDate(s, due, owner.timeZone);
6866
+ if (on >= date)
6867
+ return on;
6868
+ after = due;
6869
+ }
6870
+ return null;
6871
+ }
6872
+ function renewalDate(task, owner, now) {
6873
+ const s = task.schedule;
6874
+ if (s?.kind !== "period")
6875
+ return "";
6876
+ const w = wallClock(now, owner.timeZone);
6877
+ const today = `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
6878
+ if (s.anchor >= today)
6879
+ return s.anchor;
6880
+ return periodDateFrom(s, today, owner) ?? s.anchor;
6881
+ }
6882
+ async function askedAbout(d, task, owner, date) {
6883
+ const old = task.schedule;
6884
+ if (old?.kind !== "period")
6885
+ return false;
6886
+ const fired = (await d.store.listOccurrences({ taskId: task.id })).filter((o) => o.status !== "queued" && !o.dedupeKey.startsWith(SNOOZE_PREFIX));
6887
+ return fired.some((o) => periodDate(old, new Date(o.dueAt), owner.timeZone) === date);
6888
+ }
6889
+ async function editRenewal(d, user, taskId, input) {
6890
+ const task = await owned(d, user, taskId, "renewal");
6891
+ if (typeof task === "string")
6892
+ return { ok: false, error: task };
6893
+ if (task.schedule?.kind !== "period")
6894
+ return { ok: false, error: FINISHED };
6895
+ const old = task.schedule;
6896
+ const now = d.clock.now();
6897
+ const config = task.config;
6898
+ const state = task.state;
6899
+ const carried = state && typeof state.amount === "number" ? state.amount : null;
6900
+ const note = input.note ?? config.note;
6901
+ const plan = renewalPlan(d, user, {
6902
+ name: blank(input.name) ? task.title : input.name,
6903
+ amount: input.amount ?? carried ?? config.amount,
6904
+ currency: blank(input.currency) ? config.currency : input.currency,
6905
+ renews: blank(input.renews) ? renewalDate(task, user, now) : input.renews,
6906
+ every: input.every ?? old.every,
6907
+ unit: input.unit ?? old.unit,
6908
+ lead: input.lead ?? old.leadDays ?? 0,
6909
+ ...note !== undefined ? { note } : {}
6910
+ });
6911
+ if (!plan.ok)
6912
+ return plan;
6913
+ const sameShape = plan.schedule.every === old.every && plan.schedule.unit === old.unit && plan.schedule.leadDays === (old.leadDays ?? 0);
6914
+ const schedule = sameShape && periodDateFrom(old, plan.schedule.anchor, user) === plan.schedule.anchor ? old : plan.schedule;
6915
+ const problems = scheduleProblems(schedule);
6916
+ if (problems.length > 0)
6917
+ return { ok: false, error: `That schedule does not work: ${problems.join("; ")}.` };
6918
+ const patch = {};
6919
+ if (plan.name !== task.title)
6920
+ patch.title = plan.name;
6921
+ const newAmount = plan.amount !== (carried ?? config.amount);
6922
+ const nextConfig = { amount: newAmount ? plan.amount : config.amount, currency: plan.currency, ...plan.note ? { note: plan.note } : {} };
6923
+ if (!same(nextConfig, config))
6924
+ patch.config = nextConfig;
6925
+ if (newAmount && carried !== null)
6926
+ patch.state = { ...state, amount: plan.amount };
6927
+ let updated = await applyEdit(d, task, user, patch);
6928
+ let next = await nextRun(d, updated);
6929
+ if (!same(schedule, old)) {
6930
+ const asked = await askedAbout(d, task, user, schedule.anchor);
6931
+ updated = await rescheduleKeepingSnoozes(d, updated, user, schedule, user.id);
6932
+ next = (asked ? null : await firstAskIfDue(d, updated, user, schedule)) ?? await nextRun(d, updated);
6933
+ }
6934
+ const ask = updated.status === "paused" ? "paused" : askText(next, plan.days, user, now);
6935
+ const cadence = describeSchedule(schedule, user, user.timeZone, now);
6936
+ return { ok: true, task: updated, text: clip(`Saved \`${updated.id}\` (renewal): ${cadence}. Next ask: ${ask}.`) };
6937
+ }
6938
+ async function editPrice(d, user, taskId, input) {
6939
+ const task = await owned(d, user, taskId, "price");
6940
+ if (typeof task === "string")
6941
+ return { ok: false, error: task };
6942
+ const config = task.config;
6943
+ const schedule = task.schedule;
6944
+ const plan = priceSettingsPlan({
6945
+ name: input.name ?? task.title,
6946
+ hours: input.hours ?? (schedule?.kind === "poll" ? schedule.every : undefined),
6947
+ drop: input.drop ?? config.dropPercent ?? DEFAULT_DROP_PERCENT,
6948
+ baseline: input.baseline ?? config.baseline ?? DEFAULT_BASELINE
6949
+ });
6950
+ if (!plan.ok)
6951
+ return plan;
6952
+ const patch = {};
6953
+ const title = plan.name || titleFor(config.url);
6954
+ if (title !== task.title)
6955
+ patch.title = title;
6956
+ const nextConfig = { ...config, dropPercent: plan.drop, baseline: plan.baseline };
6957
+ if (!same(nextConfig, config))
6958
+ patch.config = nextConfig;
6959
+ let updated = await applyEdit(d, task, user, patch);
6960
+ if (schedule?.kind === "poll" && (schedule.every !== plan.hours || schedule.unit !== "hour")) {
6961
+ const next = { ...schedule, every: plan.hours, unit: "hour" };
6962
+ updated = await rescheduleKeepingSnoozes(d, updated, user, next, user.id);
6963
+ }
6964
+ return { ok: true, task: updated, text: savedText(updated, user, await nextRun(d, updated), d.clock.now(), "(price)") };
6965
+ }
6966
+
6967
+ // plugins/tracker/src/web/form-input.ts
6968
+ var str2 = (form, name) => form.get(name) ?? "";
6969
+ var opt = (form, name) => str2(form, name).trim() === "" ? undefined : str2(form, name);
6970
+ function int(form, name) {
6971
+ const raw = str2(form, name).trim();
6972
+ if (raw === "")
6973
+ return;
6974
+ return /^[0-9]{1,9}$/.test(raw) ? Number(raw) : Number.NaN;
6975
+ }
6976
+ function decimal(form, name) {
6977
+ const raw = str2(form, name).trim();
6978
+ if (raw === "")
6979
+ return;
6980
+ return /^[0-9]{1,12}(\.[0-9]{1,6})?$/.test(raw) ? Number(raw) : Number.NaN;
6981
+ }
6982
+ function typed(type, mode, form) {
6983
+ return Object.fromEntries(fieldsFor(type, mode).map((f) => [f.name, str2(form, f.name)]));
6984
+ }
6985
+ function reminderInput(form) {
6986
+ const when = opt(form, "when");
6987
+ return { text: str2(form, "text"), ...when !== undefined ? { when } : {}, repeat: str2(form, "repeat") || "none" };
6988
+ }
6989
+ function renewalInput(form) {
6990
+ const every = int(form, "every");
6991
+ const lead = int(form, "lead");
6992
+ const unit = opt(form, "unit");
6993
+ const note = opt(form, "note");
6994
+ return {
6995
+ name: str2(form, "name"),
6996
+ amount: decimal(form, "amount") ?? Number.NaN,
6997
+ currency: str2(form, "currency"),
6998
+ renews: str2(form, "renews"),
6999
+ ...every !== undefined ? { every } : {},
7000
+ ...lead !== undefined ? { lead } : {},
7001
+ ...unit !== undefined ? { unit } : {},
7002
+ ...note !== undefined ? { note } : {}
7003
+ };
7004
+ }
7005
+ function priceSettings(form) {
7006
+ const name = opt(form, "name");
7007
+ const hours = int(form, "hours");
7008
+ const drop = decimal(form, "drop");
7009
+ const baseline = opt(form, "baseline");
7010
+ return {
7011
+ ...name !== undefined ? { name } : {},
7012
+ ...hours !== undefined ? { hours } : {},
6209
7013
  ...drop !== undefined ? { drop } : {},
6210
7014
  ...baseline !== undefined ? { baseline } : {}
6211
7015
  };
6212
7016
  }
7017
+ function priceInput(form) {
7018
+ const near = opt(form, "near");
7019
+ return { url: str2(form, "url"), ...priceSettings(form), ...near !== undefined ? { near } : {} };
7020
+ }
6213
7021
  function reminderEdit(form) {
6214
7022
  const text = opt(form, "text");
6215
7023
  const when = opt(form, "when");
@@ -6239,54 +7047,7 @@ function priceEdit(form) {
6239
7047
  const { name: _name, ...rest } = priceSettings(form);
6240
7048
  return { ...rest, ...form.has("name") ? { name: str2(form, "name") } : {} };
6241
7049
  }
6242
- var DEFAULTS = {
6243
- reminder: { repeat: "none" },
6244
- renewal: { unit: "year", every: "1", lead: "7" },
6245
- price: { hours: "12", drop: "10", baseline: "last" }
6246
- };
6247
- function newGet(e, type) {
6248
- return htmlResponse(newTaskPage(e.v, type, type === "renewal" ? { ...DEFAULTS.renewal, currency: "USD" } : DEFAULTS[type]));
6249
- }
6250
- async function newPost(e, type, form) {
6251
- let made;
6252
- if (type === "reminder")
6253
- made = await asViewer(e, (u) => createReminder(e.d, u, reminderInput(form)));
6254
- else if (type === "renewal")
6255
- made = await asViewer(e, (u) => createRenewal(e.d, u, renewalInput(form)));
6256
- else {
6257
- const near = opt(form, "near");
6258
- const input = { url: str2(form, "url"), ...priceSettings(form), ...near !== undefined ? { near } : {} };
6259
- const who = e.v.user.id;
6260
- if (e.reading.has(who))
6261
- made = { ok: false, error: READING };
6262
- else {
6263
- e.reading.add(who);
6264
- try {
6265
- const started = await asViewer(e, (u) => startPrice(e.d, u, input));
6266
- if (!started.ok)
6267
- made = started;
6268
- else {
6269
- const { pending } = started;
6270
- const seen = await previewPrice(e.d, pending);
6271
- made = await asViewer(e, (u) => finishPrice(e.d, u, pending, seen));
6272
- }
6273
- } finally {
6274
- e.reading.delete(who);
6275
- }
6276
- }
6277
- }
6278
- if (!made.ok)
6279
- return htmlResponse(newTaskPage(e.v, type, typed(type, "new", form), made.error), 400);
6280
- return redirect(`${taskHref(e.v, made.task.id)}?done=created`);
6281
- }
6282
- async function taskPage(e, id, done, error) {
6283
- const view = await loadHistory(e.d, e.v.user, id);
6284
- if (!view)
6285
- return htmlResponse(notFoundPage(e.v.base, e.v), 404);
6286
- const controls = view.task.ownerId === e.v.user.id ? ownerControls(e.v, view.task) : null;
6287
- return htmlResponse(historyPage(e.v, view, { controls, flash: notice(done, error) }), error ? 400 : 200);
6288
- }
6289
- function current(d, user, task) {
7050
+ function editValues(d, user, task) {
6290
7051
  const now = d.clock.now();
6291
7052
  if (task.type === "reminder")
6292
7053
  return { text: String(task.config?.text ?? ""), when: "", repeat: repeatOf(task.schedule) };
@@ -6312,28 +7073,93 @@ function current(d, user, task) {
6312
7073
  const every = task.schedule?.kind === "poll" ? task.schedule.every : 12;
6313
7074
  return { name: task.title, hours: String(every), drop: String(c.dropPercent ?? 10), baseline: c.baseline ?? "last" };
6314
7075
  }
7076
+
7077
+ // plugins/tracker/src/web/editor.ts
7078
+ var READING = "I am still reading the page of your last new price tracker; try again when it is done.";
7079
+ var GONE = NO_LONGER_LISTED;
7080
+ var writer = (e) => ({ d: e.d, queue: e.queue, reading: e.reading, userId: e.v.user.id });
7081
+ function asUser(w, fn) {
7082
+ return w.queue(async () => {
7083
+ const user = await w.d.store.getUser(w.userId);
7084
+ return user ? fn(user) : { ok: false, error: GONE };
7085
+ });
7086
+ }
7087
+ async function makeTask(w, type, form) {
7088
+ if (type === "reminder")
7089
+ return asUser(w, (u) => createReminder(w.d, u, reminderInput(form)));
7090
+ if (type === "renewal")
7091
+ return asUser(w, (u) => createRenewal(w.d, u, renewalInput(form)));
7092
+ const input = priceInput(form);
7093
+ if (w.reading.has(w.userId))
7094
+ return { ok: false, error: READING };
7095
+ w.reading.add(w.userId);
7096
+ try {
7097
+ const started = await asUser(w, (u) => startPrice(w.d, u, input));
7098
+ if (!started.ok)
7099
+ return started;
7100
+ const { pending } = started;
7101
+ const seen = await previewPrice(w.d, pending);
7102
+ return await asUser(w, (u) => finishPrice(w.d, u, pending, seen));
7103
+ } finally {
7104
+ w.reading.delete(w.userId);
7105
+ }
7106
+ }
7107
+ async function editableTask(d, user, id) {
7108
+ const task = await ownLiveTask(d, user, id);
7109
+ return task && (task.type === "reminder" || task.type === "renewal" || task.type === "price") ? task : null;
7110
+ }
7111
+ function saveEdit(w, task, form) {
7112
+ if (task.type === "reminder")
7113
+ return asUser(w, (u) => editReminder(w.d, u, task.id, reminderEdit(form)));
7114
+ if (task.type === "renewal")
7115
+ return asUser(w, (u) => editRenewal(w.d, u, task.id, renewalEdit(form)));
7116
+ return asUser(w, (u) => editPrice(w.d, u, task.id, priceEdit(form)));
7117
+ }
7118
+ function actOn(w, taskId, action) {
7119
+ const act = action === "pause" ? pauseTask : action === "resume" ? resumeTask : deleteTask;
7120
+ return asUser(w, (u) => act(w.d, u, taskId));
7121
+ }
7122
+ var DEFAULTS = {
7123
+ reminder: { repeat: "none" },
7124
+ renewal: { unit: "year", every: "1", lead: "7" },
7125
+ price: { hours: "12", drop: "10", baseline: "last" }
7126
+ };
7127
+ function newGet(e, type) {
7128
+ return htmlResponse(newTaskPage(e.v, type, type === "renewal" ? { ...DEFAULTS.renewal, currency: "USD" } : DEFAULTS[type]));
7129
+ }
7130
+ async function newPost(e, type, form) {
7131
+ const made = await makeTask(writer(e), type, form);
7132
+ if (!made.ok)
7133
+ return htmlResponse(newTaskPage(e.v, type, typed(type, "new", form), made.error), 400);
7134
+ return redirect(`${taskHref(e.v, made.task.id)}?done=created`);
7135
+ }
7136
+ async function taskPage(e, id, done, error) {
7137
+ const view = await loadHistory(e.d, e.v.user, id);
7138
+ if (!view)
7139
+ return htmlResponse(notFoundPage(e.v.base, e.v), 404);
7140
+ const own = view.task.ownerId === e.v.user.id;
7141
+ const controls = own ? ownerControls(e.v, view.task) : null;
7142
+ const owner = own ? null : (await e.d.store.getUser(view.task.ownerId))?.displayName ?? view.task.ownerId;
7143
+ return htmlResponse(historyPage(e.v, view, { controls, flash: notice(done, error), owner }), error ? 400 : 200);
7144
+ }
6315
7145
  function editPage(e, task, values, error) {
6316
7146
  const now = task.schedule ? describeSchedule(task.schedule, e.v.user, e.v.user.timeZone, e.d.clock.now()) : "no schedule";
6317
7147
  const page = task.type === "price" ? String(task.config.url) : undefined;
6318
7148
  return editTaskPage(e.v, task, values, { now, ...page !== undefined ? { page } : {}, ...error !== undefined ? { error } : {} });
6319
7149
  }
6320
- async function editableTask(e, id) {
6321
- const task = await ownLiveTask(e.d, e.v.user, id);
6322
- return task && (task.type === "reminder" || task.type === "renewal" || task.type === "price") ? task : null;
6323
- }
6324
7150
  async function editGet(e, id) {
6325
- const task = await editableTask(e, id);
7151
+ const task = await editableTask(e.d, e.v.user, id);
6326
7152
  if (!task)
6327
7153
  return htmlResponse(notFoundPage(e.v.base, e.v), 404);
6328
7154
  if (task.status !== "active" && task.status !== "paused")
6329
7155
  return redirect(taskHref(e.v, task.id));
6330
- return htmlResponse(editPage(e, task, current(e.d, e.v.user, task)));
7156
+ return htmlResponse(editPage(e, task, editValues(e.d, e.v.user, task)));
6331
7157
  }
6332
7158
  async function editPost(e, id, form) {
6333
- const task = await editableTask(e, id);
7159
+ const task = await editableTask(e.d, e.v.user, id);
6334
7160
  if (!task)
6335
7161
  return htmlResponse(notFoundPage(e.v.base, e.v), 404);
6336
- const saved = task.type === "reminder" ? await asViewer(e, (u) => editReminder(e.d, u, task.id, reminderEdit(form))) : task.type === "renewal" ? await asViewer(e, (u) => editRenewal(e.d, u, task.id, renewalEdit(form))) : await asViewer(e, (u) => editPrice(e.d, u, task.id, priceEdit(form)));
7162
+ const saved = await saveEdit(writer(e), task, form);
6337
7163
  if (saved.ok)
6338
7164
  return redirect(`${taskHref(e.v, task.id)}?done=saved`);
6339
7165
  if (saved.error === NO_SUCH_TASK)
@@ -6344,10 +7170,9 @@ async function actionPost(e, id, action, form) {
6344
7170
  const task = await ownLiveTask(e.d, e.v.user, id);
6345
7171
  if (!task)
6346
7172
  return htmlResponse(notFoundPage(e.v.base, e.v), 404);
6347
- if (action === "delete" && str2(form, "confirm") !== "yes")
7173
+ if (action === "delete" && form.get("confirm") !== "yes")
6348
7174
  return htmlResponse(confirmDeletePage(e.v, task));
6349
- const act = action === "pause" ? pauseTask : action === "resume" ? resumeTask : deleteTask;
6350
- const done = await asViewer(e, (u) => act(e.d, u, task.id));
7175
+ const done = await actOn(writer(e), task.id, action);
6351
7176
  if (!done.ok)
6352
7177
  return done.error === NO_SUCH_TASK ? htmlResponse(notFoundPage(e.v.base, e.v), 404) : taskPage(e, task.id, null, done.error);
6353
7178
  if (action === "delete")
@@ -6355,17 +7180,469 @@ async function actionPost(e, id, action, form) {
6355
7180
  return redirect(`${taskHref(e.v, task.id)}?done=${action === "pause" ? "paused" : "resumed"}`);
6356
7181
  }
6357
7182
 
7183
+ // plugins/tracker/src/web/api-tasks.ts
7184
+ function problem(status, code, message, headers) {
7185
+ return { status, body: { error: { code, message } }, ...headers ? { headers } : {} };
7186
+ }
7187
+ var NOT_FOUND_MESSAGE = "No such task.";
7188
+ var notFound = () => problem(404, "not_found", NOT_FOUND_MESSAGE);
7189
+ function jsonKind(f) {
7190
+ if (f.kind === "number")
7191
+ return "integer";
7192
+ return f.decimal ? "number" : "string";
7193
+ }
7194
+ function typesAnswer() {
7195
+ const describe = (fields, mode) => fields.map((f) => ({
7196
+ name: f.name,
7197
+ type: jsonKind(f),
7198
+ required: mode === "new" && f.required === true,
7199
+ description: f.help ? `${f.label}. ${f.help}` : f.label,
7200
+ ...f.maxlength !== undefined ? { maxLength: f.maxlength } : {},
7201
+ ...f.min !== undefined ? { minimum: f.min } : {},
7202
+ ...f.max !== undefined ? { maximum: f.max } : {},
7203
+ ...f.options ? { enum: f.options.map(([value]) => value) } : {}
7204
+ }));
7205
+ return {
7206
+ status: 200,
7207
+ body: { types: EDITOR_TYPES.map((type) => ({ type, create: describe(fieldsFor(type, "new"), "new"), edit: describe(fieldsFor(type, "edit"), "edit") })) }
7208
+ };
7209
+ }
7210
+ function asFields(body, type, mode) {
7211
+ const fields = new Map(fieldsFor(type, mode).map((f) => [f.name, f]));
7212
+ const form = new URLSearchParams;
7213
+ for (const [key, value] of Object.entries(body)) {
7214
+ if (mode === "new" && key === "type")
7215
+ continue;
7216
+ const f = fields.get(key);
7217
+ if (!f)
7218
+ return problem(400, "unknown_field", `\`${key.slice(0, 40)}\` is not a field of a ${type}${mode === "edit" ? " edit" : ""}. GET types lists them.`);
7219
+ const kind = jsonKind(f);
7220
+ if (kind === "string") {
7221
+ if (typeof value !== "string")
7222
+ return problem(400, "invalid", `\`${key}\` must be a string.`);
7223
+ form.set(key, value);
7224
+ } else {
7225
+ if (typeof value !== "number" || !Number.isFinite(value))
7226
+ return problem(400, "invalid", `\`${key}\` must be a number.`);
7227
+ form.set(key, String(value));
7228
+ }
7229
+ }
7230
+ return form;
7231
+ }
7232
+ function refusal(error) {
7233
+ if (error === NO_SUCH_TASK)
7234
+ return notFound();
7235
+ if (error === NO_LONGER_LISTED)
7236
+ return problem(401, "invalid_token", "The token's owner is no longer on this tracker's list.");
7237
+ if (error === READING)
7238
+ return problem(409, "busy", error);
7239
+ if (error === FINISHED || /^That task is [a-z]+, not [a-z]+\.$/.test(error))
7240
+ return problem(409, "conflict", error);
7241
+ if (/^You already (have|track) [0-9]+ /.test(error))
7242
+ return problem(409, "limit_reached", error);
7243
+ if (/ (is|are) not available on this bot\.$/.test(error))
7244
+ return problem(503, "unavailable", error);
7245
+ return problem(400, "invalid", error);
7246
+ }
7247
+ async function nextAt(d, task) {
7248
+ if (task.status !== "active")
7249
+ return null;
7250
+ const queued = await d.store.listOccurrences({ taskId: task.id, status: "queued" });
7251
+ return queued[0]?.dueAt ?? null;
7252
+ }
7253
+ async function taskJson(d, user, task) {
7254
+ const type = EDITOR_TYPES.includes(task.type) ? task.type : null;
7255
+ const values = editValues(d, user, task);
7256
+ const settings = {};
7257
+ if (type) {
7258
+ for (const f of fieldsFor(type, "edit")) {
7259
+ if (f.name === "when")
7260
+ continue;
7261
+ const raw = values[f.name];
7262
+ if (raw === undefined)
7263
+ continue;
7264
+ settings[f.name] = jsonKind(f) === "string" ? raw : Number(raw);
7265
+ }
7266
+ }
7267
+ return {
7268
+ id: task.id,
7269
+ type: task.type,
7270
+ title: task.title,
7271
+ status: task.status === "archived" ? "deleted" : task.status,
7272
+ cadence: task.schedule ? describeSchedule(task.schedule, user, user.timeZone, d.clock.now()) : "no schedule",
7273
+ nextAt: await nextAt(d, task),
7274
+ createdAt: task.createdAt,
7275
+ updatedAt: task.updatedAt,
7276
+ settings,
7277
+ ...task.type === "price" ? { url: String(task.config.url) } : {}
7278
+ };
7279
+ }
7280
+ async function listAnswer(d, user) {
7281
+ const tasks = (await d.store.listTasks({ ownerId: user.id })).filter((t) => t.status !== "archived");
7282
+ const out = [];
7283
+ for (const t of tasks)
7284
+ out.push(await taskJson(d, user, t));
7285
+ return { status: 200, body: { tasks: out } };
7286
+ }
7287
+ async function getAnswer(d, user, id) {
7288
+ const task = await ownTask(d, user, id);
7289
+ if (!task)
7290
+ return notFound();
7291
+ const view = await loadHistory(d, user, task.id);
7292
+ if (!view)
7293
+ return notFound();
7294
+ return {
7295
+ status: 200,
7296
+ body: {
7297
+ task: await taskJson(d, user, view.task),
7298
+ history: { next: view.next, runs: view.runs, earlierRuns: view.earlierRuns, changes: view.changes, earlierChanges: view.earlierChanges }
7299
+ }
7300
+ };
7301
+ }
7302
+ async function written(w, user, result, status, base) {
7303
+ if (!result.ok)
7304
+ return refusal(result.error);
7305
+ const task = await w.d.store.getTask(result.task.id) ?? result.task;
7306
+ return {
7307
+ status,
7308
+ body: { task: await taskJson(w.d, user, task), message: result.text },
7309
+ ...status === 201 ? { headers: { Location: `${base}/tasks/${encodeURIComponent(task.id)}` } } : {}
7310
+ };
7311
+ }
7312
+ async function createAnswer(w, user, body, base) {
7313
+ const type = body.type;
7314
+ if (typeof type !== "string" || !EDITOR_TYPES.includes(type)) {
7315
+ return problem(400, "invalid", `\`type\` is one of ${EDITOR_TYPES.join(", ")}.`);
7316
+ }
7317
+ const form = asFields(body, type, "new");
7318
+ if (!(form instanceof URLSearchParams))
7319
+ return form;
7320
+ return written(w, user, await makeTask(w, type, form), 201, base);
7321
+ }
7322
+ async function editAnswer(w, user, id, body, base) {
7323
+ const task = await editableTask(w.d, user, id);
7324
+ if (!task)
7325
+ return notFound();
7326
+ const form = asFields(body, task.type, "edit");
7327
+ if (!(form instanceof URLSearchParams))
7328
+ return form;
7329
+ return written(w, user, await saveEdit(w, task, form), 200, base);
7330
+ }
7331
+ async function actAnswer(w, user, id, action) {
7332
+ const task = await ownTask(w.d, user, id);
7333
+ if (!task || task.status === "archived")
7334
+ return notFound();
7335
+ const done = await actOn(w, task.id, action);
7336
+ if (!done.ok)
7337
+ return refusal(done.error);
7338
+ const after = await w.d.store.getTask(task.id) ?? task;
7339
+ return { status: 200, body: { task: await taskJson(w.d, user, after), message: done.text } };
7340
+ }
7341
+
7342
+ // plugins/tracker/src/web/api.ts
7343
+ var API_PREFIX = "/api/v1";
7344
+ var MAX_API_BYTES = 16 * 1024;
7345
+ var RATE_BURST = 60;
7346
+ var RATE_PER_SECOND = 1;
7347
+ var FAILED_BURST = 30;
7348
+ var FAILED_PER_SECOND = 0.5;
7349
+ var FAILED_KEY = "failed";
7350
+
7351
+ class RateLimiter {
7352
+ burst;
7353
+ perSecond;
7354
+ buckets = new Map;
7355
+ constructor(burst, perSecond) {
7356
+ this.burst = burst;
7357
+ this.perSecond = perSecond;
7358
+ }
7359
+ take(key, nowMs) {
7360
+ const b = this.buckets.get(key);
7361
+ const elapsed = b ? Math.max(0, nowMs - b.at) / 1000 : 0;
7362
+ const tokens = b ? Math.min(this.burst, b.tokens + elapsed * this.perSecond) : this.burst;
7363
+ if (tokens < 1) {
7364
+ this.buckets.set(key, { tokens, at: nowMs });
7365
+ return Math.max(1, Math.ceil((1 - tokens) / this.perSecond));
7366
+ }
7367
+ this.buckets.set(key, { tokens: tokens - 1, at: nowMs });
7368
+ if (this.buckets.size > 1e4) {
7369
+ for (const [k, v] of this.buckets)
7370
+ if (v.tokens + (nowMs - v.at) / 1000 * this.perSecond >= this.burst)
7371
+ this.buckets.delete(k);
7372
+ }
7373
+ return 0;
7374
+ }
7375
+ }
7376
+ var HEADERS = {
7377
+ "Content-Type": "application/json; charset=utf-8",
7378
+ "Cache-Control": "no-store",
7379
+ "X-Content-Type-Options": "nosniff",
7380
+ "Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
7381
+ "Referrer-Policy": "no-referrer"
7382
+ };
7383
+ function json2(a) {
7384
+ return new Response(JSON.stringify(a.body), { status: a.status, headers: { ...HEADERS, ...a.headers ?? {} } });
7385
+ }
7386
+ var CHALLENGE = 'Bearer realm="tracker"';
7387
+ function bearer(header) {
7388
+ if (header === null)
7389
+ return null;
7390
+ const m = /^Bearer +([^\s]+) *$/i.exec(header);
7391
+ return m?.[1] ?? null;
7392
+ }
7393
+ var ACT = /^\/tasks\/([^/]+)\/(pause|resume)$/;
7394
+ var ONE = /^\/tasks\/([^/]+)$/;
7395
+ function segment(raw) {
7396
+ try {
7397
+ return decodeURIComponent(raw ?? "");
7398
+ } catch {
7399
+ return "";
7400
+ }
7401
+ }
7402
+ function route(rest) {
7403
+ if (rest === "/me")
7404
+ return { kind: "me" };
7405
+ if (rest === "/types")
7406
+ return { kind: "types" };
7407
+ if (rest === "/tasks")
7408
+ return { kind: "tasks" };
7409
+ const act = ACT.exec(rest);
7410
+ if (act)
7411
+ return { kind: "act", id: segment(act[1]), action: act[2] };
7412
+ const one = ONE.exec(rest);
7413
+ return one ? { kind: "task", id: segment(one[1]) } : null;
7414
+ }
7415
+ function allowedMethods(r) {
7416
+ if (r.kind === "tasks")
7417
+ return "GET, POST";
7418
+ if (r.kind === "task")
7419
+ return "GET, PATCH, DELETE";
7420
+ if (r.kind === "act")
7421
+ return "POST";
7422
+ return "GET";
7423
+ }
7424
+ async function readJson(request) {
7425
+ const type = (request.headers.get("content-type") ?? "").trim();
7426
+ const no = (answer) => ({ ok: false, answer });
7427
+ if (!/^application\/json *(;.*)?$/i.test(type))
7428
+ return no(problem(415, "unsupported_media_type", "Send the body as Content-Type: application/json."));
7429
+ const text = await readBody(request, MAX_API_BYTES);
7430
+ if (text === null)
7431
+ return no(problem(413, "too_large", `The body is over ${MAX_API_BYTES} bytes.`));
7432
+ let value;
7433
+ try {
7434
+ value = JSON.parse(text);
7435
+ } catch {
7436
+ return no(problem(400, "invalid_json", "The body is not JSON."));
7437
+ }
7438
+ if (typeof value !== "object" || value === null || Array.isArray(value))
7439
+ return no(problem(400, "invalid_json", "The body must be a JSON object."));
7440
+ return { ok: true, body: value };
7441
+ }
7442
+ async function authenticate(w, request) {
7443
+ const { d } = w;
7444
+ const no = (answer) => ({ ok: false, answer });
7445
+ const header = request.headers.get("authorization");
7446
+ if (header === null)
7447
+ return no(problem(401, "unauthorized", "Send Authorization: Bearer <token>. Make a token on the web area's API tokens page.", { "WWW-Authenticate": CHALLENGE }));
7448
+ const invalid = problem(401, "invalid_token", "That token is unknown, revoked or expired.", { "WWW-Authenticate": `${CHALLENGE}, error="invalid_token"` });
7449
+ const sent = bearer(header);
7450
+ const now = d.clock.now();
7451
+ const token = sent === null ? null : d.apiTokens.find(sent, now);
7452
+ if (!token) {
7453
+ const waitFailed = w.failed.take(FAILED_KEY, now.getTime());
7454
+ if (waitFailed > 0)
7455
+ return no(problem(429, "rate_limited", `Too many requests with bad tokens; try again in ${waitFailed} s.`, { "Retry-After": String(waitFailed) }));
7456
+ return no(invalid);
7457
+ }
7458
+ const wait = w.limiter.take(token.id, now.getTime());
7459
+ if (wait > 0)
7460
+ return no(problem(429, "rate_limited", `Too many requests with this token; try again in ${wait} s.`, { "Retry-After": String(wait) }));
7461
+ const user = await d.store.getUser(token.userId);
7462
+ if (!user || !d.admissions.isRegistered(user.id)) {
7463
+ d.apiTokens.deleteForUser(token.userId);
7464
+ return no(invalid);
7465
+ }
7466
+ const found = await w.recheck(user, token.memberCheckedAt);
7467
+ if (found === "not-member")
7468
+ return no(problem(403, "not_member", "The token's owner is no longer a member of this tracker's server; their tokens are revoked."));
7469
+ if (found === "unknown")
7470
+ return no(problem(503, "membership_unknown", "I could not check that the token's owner is still a member of this tracker's server; try again later.", { "Retry-After": "60" }));
7471
+ const current = d.apiTokens.find(sent, d.clock.now());
7472
+ if (!current)
7473
+ return no(invalid);
7474
+ d.apiTokens.touch(current, d.clock.now());
7475
+ return { ok: true, token: current, user };
7476
+ }
7477
+ async function handleApi(w, request, path) {
7478
+ if (request.headers.get("origin") !== null)
7479
+ return json2(problem(403, "origin_refused", "The task API is not for browsers: send no Origin header."));
7480
+ const auth = await authenticate(w, request);
7481
+ if (!auth.ok)
7482
+ return json2(auth.answer);
7483
+ const { user } = auth;
7484
+ const r = path.startsWith(`${API_PREFIX}/`) ? route(path.slice(API_PREFIX.length)) : null;
7485
+ if (r === null)
7486
+ return json2(problem(404, "not_found", "No such endpoint."));
7487
+ const allowed = allowedMethods(r);
7488
+ const method = request.method;
7489
+ if (!allowed.split(", ").includes(method))
7490
+ return json2(problem(405, "method_not_allowed", `Use ${allowed}.`, { Allow: allowed }));
7491
+ const writer = { d: w.d, queue: w.queue, reading: w.reading, userId: user.id };
7492
+ const base = `${w.base}${API_PREFIX}`;
7493
+ if (method === "POST" || method === "PATCH") {
7494
+ const read = await readJson(request);
7495
+ if (!read.ok)
7496
+ return json2(read.answer);
7497
+ const { body } = read;
7498
+ if (r.kind === "tasks")
7499
+ return json2(await createAnswer(writer, user, body, base));
7500
+ if (r.kind === "task")
7501
+ return json2(await editAnswer(writer, user, r.id, body, base));
7502
+ if (r.kind === "act") {
7503
+ if (Object.keys(body).length > 0)
7504
+ return json2(problem(400, "unknown_field", `\`${r.action}\` takes no fields: send {}.`));
7505
+ return json2(await actAnswer(writer, user, r.id, r.action));
7506
+ }
7507
+ }
7508
+ if (method === "DELETE" && r.kind === "task")
7509
+ return json2(await actAnswer(writer, user, r.id, "delete"));
7510
+ if (r.kind === "types")
7511
+ return json2(typesAnswer());
7512
+ if (r.kind === "tasks")
7513
+ return json2(await listAnswer(w.d, user));
7514
+ if (r.kind === "task")
7515
+ return json2(await getAnswer(w.d, user, r.id));
7516
+ return json2({
7517
+ status: 200,
7518
+ body: {
7519
+ user: { id: user.id, name: user.displayName, timeZone: user.timeZone, preferredHour: user.preferredHour },
7520
+ token: { id: auth.token.id, name: auth.token.name, createdAt: auth.token.createdAt, expiresAt: auth.token.expiresAt }
7521
+ }
7522
+ });
7523
+ }
7524
+
7525
+ // plugins/tracker/src/web/tokens.ts
7526
+ var MAX_CREATIONS_PER_HOUR = 10;
7527
+ var HOUR_MS = 60 * 60 * 1000;
7528
+ var TOO_MANY_CREATED = `You have made ${MAX_CREATIONS_PER_HOUR} tokens in the last hour, the most one person may. Try again later.`;
7529
+
7530
+ class CreationLog {
7531
+ made = new Map;
7532
+ recent(userId, nowMs) {
7533
+ const times = (this.made.get(userId) ?? []).filter((t) => t > nowMs - HOUR_MS && t <= nowMs);
7534
+ if (times.length === 0)
7535
+ this.made.delete(userId);
7536
+ else
7537
+ this.made.set(userId, times);
7538
+ return times;
7539
+ }
7540
+ full(userId, nowMs) {
7541
+ return this.recent(userId, nowMs).length >= MAX_CREATIONS_PER_HOUR;
7542
+ }
7543
+ record(userId, nowMs) {
7544
+ this.made.set(userId, [...this.recent(userId, nowMs), nowMs]);
7545
+ }
7546
+ }
7547
+ var TOKEN_SENT = "Your new token is in your Discord DMs from this bot. It is not shown here, and the tracker keeps only a hash of it.";
7548
+ var TOKEN_NOT_SENT = "I could not DM you the token, so none was made. Open your DMs from this server's members and try again.";
7549
+ function tokenMessage(name, token, expiresAt) {
7550
+ return [
7551
+ `Your tracker API token "${name}"${expiresAt ? `, expiring ${expiresAt.slice(0, 10)}` : ""}:`,
7552
+ `\`${token}\``,
7553
+ "Anyone holding it can use the tracker's task API as you: keep it secret, and revoke it on the web area's API tokens page when you no longer need it. If you did not make it, revoke it now."
7554
+ ].join(`
7555
+ `);
7556
+ }
7557
+ function tokensGet(d, v) {
7558
+ return htmlResponse(tokensPage(v, d.apiTokens.listFor(v.user.id, d.clock.now()), d.clock.now()));
7559
+ }
7560
+ async function tokensPost(d, v, session, form, log) {
7561
+ const now = d.clock.now();
7562
+ const name = (form.get("name") ?? "").slice(0, 200);
7563
+ const expiry = form.get("expiry") ?? String(DEFAULT_EXPIRY_DAYS);
7564
+ const days = expiryChoice(expiry);
7565
+ const refuse = (error) => htmlResponse(tokensPage(v, d.apiTokens.listFor(v.user.id, d.clock.now()), d.clock.now(), { error, name, expiry: days === undefined ? String(DEFAULT_EXPIRY_DAYS) : expiry }), 400);
7566
+ if (days === undefined)
7567
+ return refuse("Choose when the token expires.");
7568
+ const discordId = v.user.discordId;
7569
+ if (!d.dm || !discordId)
7570
+ return refuse(TOKEN_NOT_SENT);
7571
+ if (log.full(v.user.id, now.getTime()))
7572
+ return refuse(TOO_MANY_CREATED);
7573
+ const made = d.apiTokens.create(v.user.id, { name, days }, now, session.memberCheckedAt);
7574
+ if (!made.ok)
7575
+ return refuse(made.error);
7576
+ log.record(v.user.id, now.getTime());
7577
+ try {
7578
+ await d.dm(discordId, { content: tokenMessage(made.row.name, made.token, made.row.expiresAt) });
7579
+ } catch (err) {
7580
+ d.apiTokens.revokeOwn(v.user.id, made.row.id);
7581
+ d.log.warn(`${v.user.id}'s API token ${made.row.id} deleted: the DM carrying it failed (${err instanceof Error ? err.message : String(err)})`);
7582
+ return refuse(TOKEN_NOT_SENT);
7583
+ }
7584
+ d.log.info(`${v.user.id} made API token ${made.row.id}`);
7585
+ return htmlResponse(tokensPage(v, d.apiTokens.listFor(v.user.id, d.clock.now()), d.clock.now(), { result: `"${made.row.name}": ${TOKEN_SENT}` }));
7586
+ }
7587
+ function tokenRevokePost(d, v, id) {
7588
+ if (!d.apiTokens.revokeOwn(v.user.id, id))
7589
+ return htmlResponse(notFoundPage(v.base, v), 404);
7590
+ d.log.info(`${v.user.id} revoked their API token ${id}`);
7591
+ const now = d.clock.now();
7592
+ return htmlResponse(tokensPage(v, d.apiTokens.listFor(v.user.id, now), now, { result: "Revoked: that token no longer works." }));
7593
+ }
7594
+
6358
7595
  // plugins/tracker/src/web/routes.ts
6359
7596
  var TASK_PATH = /^\/tasks\/([^/]+)$/;
6360
7597
  var TASK_ACTION = /^\/tasks\/([^/]+)\/(edit|pause|resume|delete)$/;
6361
7598
  var NEW_PATH = /^\/new\/([a-z]+)$/;
6362
- function route(path) {
7599
+ var PERSON_PATH = /^\/admin\/people\/([^/]+)$/;
7600
+ var PERSON_ACTION = /^\/admin\/people\/([^/]+)\/(grant|revoke|resume-delivery|forget)$/;
7601
+ var BLOCK_LIFT = /^\/admin\/blocks\/([^/]+)\/lift$/;
7602
+ var TOKEN_REVOKE = /^\/tokens\/([^/]+)\/revoke$/;
7603
+ var ADMIN_TOKEN_REVOKE = /^\/admin\/tokens\/([^/]+)\/revoke$/;
7604
+ function isAdminRoute(r) {
7605
+ return r.kind.startsWith("admin");
7606
+ }
7607
+ function segment2(raw) {
7608
+ try {
7609
+ return decodeURIComponent(raw ?? "");
7610
+ } catch {
7611
+ return "";
7612
+ }
7613
+ }
7614
+ function route2(path) {
6363
7615
  if (path === "/")
6364
7616
  return { kind: "tasks" };
6365
7617
  if (path === "/settings")
6366
7618
  return { kind: "settings" };
6367
7619
  if (path === "/logout")
6368
7620
  return { kind: "logout" };
7621
+ if (path === "/forget")
7622
+ return { kind: "forget" };
7623
+ if (path === "/tokens")
7624
+ return { kind: "tokens" };
7625
+ const revoke = TOKEN_REVOKE.exec(path);
7626
+ if (revoke)
7627
+ return { kind: "token-revoke", id: segment2(revoke[1]) };
7628
+ const adminRevoke = ADMIN_TOKEN_REVOKE.exec(path);
7629
+ if (adminRevoke)
7630
+ return { kind: "admin-token-revoke", id: segment2(adminRevoke[1]) };
7631
+ if (path === "/admin")
7632
+ return { kind: "admin" };
7633
+ if (path === "/admin/tasks")
7634
+ return { kind: "admin-tasks" };
7635
+ if (path === "/admin/allow")
7636
+ return { kind: "admin-allow" };
7637
+ const personAct = PERSON_ACTION.exec(path);
7638
+ if (personAct)
7639
+ return { kind: "admin-act", id: segment2(personAct[1]), action: personAct[2] };
7640
+ const onePerson = PERSON_PATH.exec(path);
7641
+ if (onePerson)
7642
+ return { kind: "admin-person", id: segment2(onePerson[1]) };
7643
+ const lift = BLOCK_LIFT.exec(path);
7644
+ if (lift)
7645
+ return { kind: "admin-lift", id: segment2(lift[1]) };
6369
7646
  const created = NEW_PATH.exec(path);
6370
7647
  if (created) {
6371
7648
  const type = created[1];
@@ -6374,12 +7651,7 @@ function route(path) {
6374
7651
  const one = TASK_PATH.exec(path) ?? TASK_ACTION.exec(path);
6375
7652
  if (!one)
6376
7653
  return null;
6377
- let id;
6378
- try {
6379
- id = decodeURIComponent(one[1] ?? "");
6380
- } catch {
6381
- id = "";
6382
- }
7654
+ const id = segment2(one[1]);
6383
7655
  const action = one[2];
6384
7656
  if (action === undefined)
6385
7657
  return { kind: "task", id };
@@ -6388,9 +7660,10 @@ function route(path) {
6388
7660
  return { kind: "act", id, action };
6389
7661
  }
6390
7662
  function methodsOf(r) {
6391
- if (r.kind === "logout" || r.kind === "act")
7663
+ if (r.kind === "logout" || r.kind === "act" || r.kind === "admin-allow" || r.kind === "admin-act" || r.kind === "admin-lift" || r.kind === "token-revoke" || r.kind === "admin-token-revoke") {
6392
7664
  return "POST";
6393
- if (r.kind === "settings" || r.kind === "edit" || r.kind === "new")
7665
+ }
7666
+ if (r.kind === "settings" || r.kind === "edit" || r.kind === "new" || r.kind === "forget" || r.kind === "tokens")
6394
7667
  return "GET, POST";
6395
7668
  return "GET";
6396
7669
  }
@@ -6443,6 +7716,11 @@ function plain(status, text, extra = {}) {
6443
7716
  headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", ...extra }
6444
7717
  });
6445
7718
  }
7719
+ function signInNote(q) {
7720
+ if (q.get("forgotten") === "1")
7721
+ return "Everything the tracker held about you is deleted, and you are signed out.";
7722
+ return q.get("out") === "1" ? "You are signed out." : undefined;
7723
+ }
6446
7724
  async function lookupWithin(lookup, ms) {
6447
7725
  let timer;
6448
7726
  const late = new Promise((resolve) => {
@@ -6462,6 +7740,9 @@ function createWebHandler(w) {
6462
7740
  const inFlight = new Map;
6463
7741
  const failedAt = new Map;
6464
7742
  const reading = new Set;
7743
+ const limiter = new RateLimiter(RATE_BURST, RATE_PER_SECOND);
7744
+ const failed = new RateLimiter(FAILED_BURST, FAILED_PER_SECOND);
7745
+ const created = new CreationLog;
6465
7746
  async function authenticate(d, request) {
6466
7747
  const id = readCookie(request, SESSION_COOKIE);
6467
7748
  if (!id)
@@ -6496,14 +7777,14 @@ function createWebHandler(w) {
6496
7777
  const shared = pending;
6497
7778
  return lookupWithin(() => shared, MEMBER_CHECK_TIMEOUT_MS);
6498
7779
  }
6499
- async function recheckMembership(d, session, user) {
7780
+ async function recheck(d, user, checkedAt) {
6500
7781
  if (w.guildId === null)
6501
- return null;
7782
+ return "ok";
6502
7783
  const now = d.clock.now().getTime();
6503
- const age = session.memberCheckedAt ? now - Date.parse(session.memberCheckedAt) : Number.POSITIVE_INFINITY;
7784
+ const age = checkedAt ? now - Date.parse(checkedAt) : Number.POSITIVE_INFINITY;
6504
7785
  const fresh = (limit) => age >= 0 && age < limit;
6505
7786
  if (fresh(MEMBER_RECHECK_MS))
6506
- return null;
7787
+ return "ok";
6507
7788
  const lastFailed = failedAt.get(user.id);
6508
7789
  const backingOff = lastFailed !== undefined && now - lastFailed >= 0 && now - lastFailed < MEMBER_RETRY_MS;
6509
7790
  let found = "unknown";
@@ -6516,17 +7797,31 @@ function createWebHandler(w) {
6516
7797
  }
6517
7798
  if (found === "not-member") {
6518
7799
  d.sessions.deleteForUser(user.id);
6519
- return { kind: "signed-out", note: LEFT_SERVER };
7800
+ d.apiTokens.deleteForUser(user.id);
7801
+ return "not-member";
6520
7802
  }
6521
7803
  if (found === "member") {
6522
- d.sessions.confirmMember(user.id, d.clock.now().toISOString());
6523
- return null;
7804
+ const at = d.clock.now().toISOString();
7805
+ d.sessions.confirmMember(user.id, at);
7806
+ d.apiTokens.confirmMember(user.id, at);
7807
+ return "ok";
6524
7808
  }
6525
- if (fresh(MEMBER_GRACE_MS))
7809
+ return fresh(MEMBER_GRACE_MS) ? "ok" : "unknown";
7810
+ }
7811
+ async function recheckMembership(d, session, user) {
7812
+ const found = await recheck(d, user, session.memberCheckedAt);
7813
+ if (found === "ok")
6526
7814
  return null;
7815
+ if (found === "not-member")
7816
+ return { kind: "signed-out", note: LEFT_SERVER };
6527
7817
  d.sessions.deleteForUser(user.id);
6528
7818
  return { kind: "signed-out", note: RECHECK_FAILED };
6529
7819
  }
7820
+ async function memberOf(discordId) {
7821
+ if (w.guildId === null)
7822
+ return "not-checked";
7823
+ return await lookupWithin(() => w.membership(discordId), MEMBER_CHECK_TIMEOUT_MS) ?? "unknown";
7824
+ }
6530
7825
  async function readForm(request) {
6531
7826
  const type = request.headers.get("content-type") ?? "";
6532
7827
  if (!type.toLowerCase().startsWith("application/x-www-form-urlencoded"))
@@ -6583,6 +7878,14 @@ function createWebHandler(w) {
6583
7878
  return redirect(`${base}/settings?saved=1`);
6584
7879
  }
6585
7880
  return async (request, info) => {
7881
+ if (info.path === "/api" || info.path.startsWith("/api/")) {
7882
+ if (w.origin === null)
7883
+ return plain(404, "Not found");
7884
+ const d = w.deps();
7885
+ if (!d)
7886
+ return json2(problem(503, "starting", "The tracker is starting up; try again in a minute.", { "Retry-After": "60" }));
7887
+ return handleApi({ d, queue: w.queue, reading, base, limiter, failed, recheck: (user, at) => recheck(d, user, at) }, request, info.path);
7888
+ }
6586
7889
  const method = request.method;
6587
7890
  if (method !== "GET" && method !== "POST")
6588
7891
  return plain(405, "Method not allowed", { Allow: "GET, POST" });
@@ -6605,22 +7908,28 @@ function createWebHandler(w) {
6605
7908
  });
6606
7909
  }
6607
7910
  if (path === "/signin")
6608
- return only("GET") ?? htmlResponse(signInHelpPage(base, url.searchParams.get("out") === "1" ? "You are signed out." : undefined));
7911
+ return only("GET") ?? htmlResponse(signInHelpPage(base, signInNote(url.searchParams)));
6609
7912
  if (path === "/login")
6610
7913
  return method === "GET" ? loginGet(d, url) : loginPost(d, request);
6611
- const r = route(path);
7914
+ const r = route2(path);
6612
7915
  if (r === null)
6613
7916
  return htmlResponse(notFoundPage(base), 404);
6614
7917
  const allowed = methodsOf(r);
6615
- if (!allowed.split(", ").includes(method))
7918
+ const admin = isAdminRoute(r);
7919
+ if (!admin && !allowed.split(", ").includes(method))
6616
7920
  return plain(405, "Method not allowed", { Allow: allowed });
6617
7921
  const auth = await authenticate(d, request);
6618
7922
  if (auth.kind === "signed-out")
6619
7923
  return htmlResponse(signInHelpPage(base, auth.note), 403, { "Set-Cookie": clear(SESSION_COOKIE) });
6620
7924
  if (auth.kind !== "ok")
6621
7925
  return redirect(`${base}/signin`, auth.kind === "stale" ? [clear(SESSION_COOKIE)] : []);
7926
+ if (admin && !auth.user.admin)
7927
+ return unknownPage(base);
7928
+ if (admin && !allowed.split(", ").includes(method))
7929
+ return plain(405, "Method not allowed", { Allow: allowed });
6622
7930
  const v = { base, user: auth.user, csrf: auth.session.csrf };
6623
7931
  const e = { d, v, queue: w.queue, reading };
7932
+ const a = { d, v, queue: w.queue, memberOf: (id) => memberOf(id), signOutCookie: clear(SESSION_COOKIE) };
6624
7933
  if (method === "POST") {
6625
7934
  const form = await readForm(request);
6626
7935
  if (!form)
@@ -6639,6 +7948,17 @@ function createWebHandler(w) {
6639
7948
  return editPost(e, r.id, form);
6640
7949
  case "act":
6641
7950
  return actionPost(e, r.id, r.action, form);
7951
+ case "forget":
7952
+ return forgetRoute(a, method, form);
7953
+ case "tokens":
7954
+ return tokensPost(d, v, auth.session, form, created);
7955
+ case "token-revoke":
7956
+ return tokenRevokePost(d, v, r.id);
7957
+ case "admin-allow":
7958
+ case "admin-act":
7959
+ case "admin-lift":
7960
+ case "admin-token-revoke":
7961
+ return adminPost(a, r, form);
6642
7962
  default:
6643
7963
  return plain(405, "Method not allowed", { Allow: allowed });
6644
7964
  }
@@ -6654,6 +7974,14 @@ function createWebHandler(w) {
6654
7974
  return editGet(e, r.id);
6655
7975
  case "new":
6656
7976
  return newGet(e, r.type);
7977
+ case "forget":
7978
+ return forgetRoute(a, method, null);
7979
+ case "tokens":
7980
+ return tokensGet(d, v);
7981
+ case "admin":
7982
+ case "admin-tasks":
7983
+ case "admin-person":
7984
+ return adminGet(a, r);
6657
7985
  default:
6658
7986
  return htmlResponse(settingsPage(v, { hour: String(auth.user.preferredHour), zone: auth.user.timeZone, saved: url.searchParams.get("saved") === "1" }));
6659
7987
  }
@@ -6746,6 +8074,28 @@ function createPlugin(host, options = {}) {
6746
8074
  if (failed > 0)
6747
8075
  host.log.warn(`${kind === "poll" ? "poll" : "notify"} lane: ${ran} ran, ${failed} failed`);
6748
8076
  }
8077
+ const running = new Set;
8078
+ const tracked = (p) => {
8079
+ running.add(p);
8080
+ const done = () => void running.delete(p);
8081
+ p.then(done, done);
8082
+ return p;
8083
+ };
8084
+ const lanes = {
8085
+ busy: () => running.size > 0,
8086
+ async idle(ms) {
8087
+ const until = Date.now() + ms;
8088
+ while (running.size > 0) {
8089
+ const left = until - Date.now();
8090
+ if (left <= 0)
8091
+ return false;
8092
+ let timer;
8093
+ await Promise.race([Promise.allSettled([...running]), new Promise((r) => timer = setTimeout(r, left))]);
8094
+ clearTimeout(timer);
8095
+ }
8096
+ return true;
8097
+ }
8098
+ };
6749
8099
  let discordClient = null;
6750
8100
  const capture = (interaction) => {
6751
8101
  const client = interaction.client;
@@ -6789,6 +8139,9 @@ function createPlugin(host, options = {}) {
6789
8139
  const openedStore = new SqliteStore(opened);
6790
8140
  const openedClaims = new ClaimStore(opened);
6791
8141
  try {
8142
+ const vacuum = vacuumOnce(opened, path);
8143
+ if ("error" in vacuum)
8144
+ host.log.warn(`could not vacuum the tracker's database (${vacuum.error}); trying again at the next start`);
6792
8145
  const granted = await seedAdmins(openedStore, adminIds, clock.now());
6793
8146
  const admissions = new Admissions(opened);
6794
8147
  for (const id of adminIds) {
@@ -6831,6 +8184,10 @@ function createPlugin(host, options = {}) {
6831
8184
  notifier: dm ? createDmNotifier({ store: openedStore, claims: openedClaims, dm, clock, log: host.log, health: delivery }) : NO_DM,
6832
8185
  logins: new LoginLinks(opened),
6833
8186
  sessions: new Sessions(opened),
8187
+ apiTokens: new ApiTokens(opened),
8188
+ roster: new Roster(opened),
8189
+ configuredAdmins: new Set(adminIds),
8190
+ lanes,
6834
8191
  webEditor: webOrigin !== null
6835
8192
  };
6836
8193
  if (guildId === null)
@@ -6851,11 +8208,11 @@ function createPlugin(host, options = {}) {
6851
8208
  ticks: [
6852
8209
  {
6853
8210
  name: "notify",
6854
- run: (signal) => tick("notify", signal)
8211
+ run: (signal) => tracked(tick("notify", signal))
6855
8212
  },
6856
8213
  {
6857
8214
  name: "poll",
6858
- run: (signal) => tick("poll", signal)
8215
+ run: (signal) => tracked(tick("poll", signal))
6859
8216
  }
6860
8217
  ],
6861
8218
  async http(request, info) {