@rackbops/plugin-tracker 0.5.0 → 0.6.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 +92 -6
  2. package/dist/plugin.js +874 -176
  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;
@@ -4087,6 +4117,7 @@ var MIGRATIONS = [
4087
4117
  CREATE INDEX web_sessions_expiry ON web_sessions (expires_at);
4088
4118
  `
4089
4119
  ];
4120
+ var BUSY_TIMEOUT_MS = 5000;
4090
4121
  function migrate(db) {
4091
4122
  const current = db.query("PRAGMA user_version").get().user_version;
4092
4123
  if (current > MIGRATIONS.length) {
@@ -4098,14 +4129,223 @@ function migrate(db) {
4098
4129
  db.exec(`PRAGMA user_version = ${version + 1}`);
4099
4130
  })();
4100
4131
  }
4132
+ return current;
4101
4133
  }
4102
4134
  function openDatabase(path) {
4103
4135
  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;
4136
+ try {
4137
+ if (path !== ":memory:")
4138
+ db.exec("PRAGMA journal_mode = WAL");
4139
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
4140
+ db.exec("PRAGMA secure_delete = ON");
4141
+ migrate(db);
4142
+ return db;
4143
+ } catch (err) {
4144
+ db.close();
4145
+ throw err;
4146
+ }
4147
+ }
4148
+ var META_TABLE = "tracker_meta";
4149
+ var VACUUMED = "vacuumed_for_secure_delete";
4150
+ function vacuumOnce(db, path) {
4151
+ if (path === ":memory:")
4152
+ return { ran: false };
4153
+ try {
4154
+ db.exec(`CREATE TABLE IF NOT EXISTS ${META_TABLE} (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
4155
+ if (db.query(`SELECT 1 FROM ${META_TABLE} WHERE key = ?`).get(VACUUMED) !== null)
4156
+ return { ran: false };
4157
+ db.exec("PRAGMA busy_timeout = 0");
4158
+ try {
4159
+ db.exec("VACUUM");
4160
+ const r = db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
4161
+ if (r && Number(r.busy) !== 0)
4162
+ throw new Error("the write-ahead log is busy");
4163
+ } finally {
4164
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
4165
+ }
4166
+ db.query(`INSERT INTO ${META_TABLE} (key, value) VALUES (?, ?)`).run(VACUUMED, new Date().toISOString());
4167
+ return { ran: true };
4168
+ } catch (err) {
4169
+ return { ran: false, error: err instanceof Error ? err.message : String(err) };
4170
+ }
4171
+ }
4172
+
4173
+ // plugins/tracker/src/roster.ts
4174
+ var FORGOTTEN = "forgotten";
4175
+ function historyNames(kind, detail, id) {
4176
+ switch (kind) {
4177
+ case "recipient_invited":
4178
+ case "recipient_accepted":
4179
+ case "recipient_declined":
4180
+ case "recipient_opted_out":
4181
+ return detail === id;
4182
+ case "blocked":
4183
+ return detail === `${id} 24h` || detail === `${id} permanent`;
4184
+ case "recipient_removed":
4185
+ return detail.startsWith(`${id}: `);
4186
+ case "paused":
4187
+ return detail.startsWith(`delivery to {${id}} paused:`);
4188
+ default:
4189
+ return false;
4190
+ }
4191
+ }
4192
+ function eventNames(type, text, id) {
4193
+ return type === "delivered" && text.startsWith(`${id} `);
4194
+ }
4195
+ function errorForms(id) {
4196
+ return new RegExp(`(recipient |user |delivery to )${id}(?![0-9A-Za-z_])`, "g");
4197
+ }
4198
+ function errorNames(text, id) {
4199
+ return text !== null && errorForms(id).test(text);
4200
+ }
4201
+ function redactError(text, id) {
4202
+ return text.replace(errorForms(id), `$1(${FORGOTTEN})`);
4203
+ }
4204
+
4205
+ class Roster {
4206
+ db;
4207
+ constructor(db) {
4208
+ this.db = db;
4209
+ }
4210
+ people() {
4211
+ 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,
4212
+ a.admitted_by, a.registered_at, h.paused_at, h.failures
4213
+ FROM users u
4214
+ LEFT JOIN admissions a ON a.user_id = 'u' || u.seq
4215
+ LEFT JOIN delivery_health h ON h.user_id = 'u' || u.seq
4216
+ ORDER BY u.seq`).all();
4217
+ const owned = this.db.query("SELECT owner_id, status, COUNT(*) AS n FROM tasks GROUP BY owner_id, status").all();
4218
+ 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
4219
+ WHERE r.state = 'accepted' AND t.status IN ('active', 'paused') GROUP BY r.user_id`).all();
4220
+ return rows.map((r) => {
4221
+ const id = `u${Number(r.seq)}`;
4222
+ const counts = { active: 0, paused: 0, done: 0, archived: 0 };
4223
+ for (const o of owned)
4224
+ if (o.owner_id === id && o.status in counts)
4225
+ counts[o.status] = Number(o.n);
4226
+ const str = (v) => v === null || v === undefined ? null : String(v);
4227
+ return {
4228
+ id,
4229
+ discordId: str(r.discord_id),
4230
+ displayName: str(r.display_name),
4231
+ timeZone: String(r.time_zone),
4232
+ preferredHour: Number(r.preferred_hour),
4233
+ admin: Number(r.admin) === 1,
4234
+ createdAt: String(r.created_at),
4235
+ admittedBy: str(r.admitted_by),
4236
+ registeredAt: str(r.registered_at),
4237
+ deliveryPausedAt: str(r.paused_at),
4238
+ failures: Number(r.failures ?? 0),
4239
+ owned: counts,
4240
+ receiving: Number(receiving.find((x) => x.user_id === id)?.n ?? 0)
4241
+ };
4242
+ });
4243
+ }
4244
+ checkpoint() {
4245
+ this.db.exec("PRAGMA busy_timeout = 0");
4246
+ try {
4247
+ const r = this.db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
4248
+ return !r || Number(r.busy) === 0;
4249
+ } finally {
4250
+ this.db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
4251
+ }
4252
+ }
4253
+ othersNamed(taskId, userId, name) {
4254
+ const ids = new Set;
4255
+ for (const r of this.db.query("SELECT user_id FROM task_recipients WHERE task_id = ?").all(taskId))
4256
+ ids.add(r.user_id);
4257
+ for (const r of this.db.query("SELECT detail FROM task_events WHERE task_id = ? AND kind LIKE 'recipient_%'").all(taskId)) {
4258
+ const id = /^(u[1-9][0-9]*)(:|$)/.exec(r.detail)?.[1];
4259
+ if (id)
4260
+ ids.add(id);
4261
+ }
4262
+ ids.delete(userId);
4263
+ const named = this.db.query("SELECT 1 FROM users WHERE seq = ? AND display_name = ?");
4264
+ return [...ids].some((id) => named.get(Number(id.slice(1)), name) !== null);
4265
+ }
4266
+ admins() {
4267
+ return this.db.query("SELECT COUNT(*) AS n FROM users WHERE admin = 1").get().n;
4268
+ }
4269
+ activeBlocks(now) {
4270
+ const rows = this.db.query(`SELECT seq, owner_id, recipient_id, expires_at, created_at FROM invite_blocks
4271
+ WHERE lifted_at IS NULL AND (expires_at IS NULL OR expires_at > ?) ORDER BY seq DESC`).all(now.toISOString());
4272
+ return rows.map((r) => ({
4273
+ id: `b${Number(r.seq)}`,
4274
+ ownerId: String(r.owner_id),
4275
+ recipientId: String(r.recipient_id),
4276
+ expiresAt: r.expires_at === null ? null : String(r.expires_at),
4277
+ createdAt: String(r.created_at)
4278
+ }));
4279
+ }
4280
+ erase(userId) {
4281
+ const seq = /^u([1-9][0-9]*)$/.exec(userId)?.[1];
4282
+ if (seq === undefined)
4283
+ throw new Error(`no user ${userId}`);
4284
+ return this.db.transaction(() => {
4285
+ const person = this.db.query("SELECT display_name FROM users WHERE seq = ?").get(Number(seq));
4286
+ if (!person)
4287
+ throw new Error(`no user ${userId}`);
4288
+ const rows = {};
4289
+ const del = (table, where, ...params) => {
4290
+ rows[table] = (rows[table] ?? 0) + this.db.query(`DELETE FROM ${table} WHERE ${where}`).run(...params).changes;
4291
+ };
4292
+ const THEIR_TASKS = "SELECT 't' || seq FROM tasks WHERE owner_id = ?";
4293
+ const THEIR_RUNS = `SELECT 'o' || seq FROM occurrences WHERE task_id IN (${THEIR_TASKS})`;
4294
+ 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);
4295
+ const taskIds = (sql) => this.db.query(sql).all(userId).map((r) => r.t);
4296
+ const recipientOf = new Set([
4297
+ ...pausedFor,
4298
+ ...taskIds("SELECT task_id AS t FROM task_recipients WHERE user_id = ?"),
4299
+ ...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)
4300
+ ]);
4301
+ del("events", `occurrence_id IN (${THEIR_RUNS})`, userId);
4302
+ del("delivery_claims", `occurrence_id IN (${THEIR_RUNS}) OR user_id = ?`, userId, userId);
4303
+ del("replies", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
4304
+ del("task_events", `task_id IN (${THEIR_TASKS}) OR actor_id = ?`, userId, userId);
4305
+ del("series", `task_id IN (${THEIR_TASKS})`, userId);
4306
+ del("task_recipients", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
4307
+ del("delivery_pauses", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
4308
+ del("occurrences", `task_id IN (${THEIR_TASKS})`, userId);
4309
+ del("tasks", "owner_id = ?", userId);
4310
+ const containing = (table, columns, column) => this.db.query(`SELECT seq, ${columns} FROM ${table} WHERE instr(${column}, ?) > 0`).all(userId);
4311
+ for (const r of containing("task_events", "kind, detail", "detail")) {
4312
+ if (historyNames(String(r.kind), String(r.detail), userId))
4313
+ del("task_events", "seq = ?", Number(r.seq));
4314
+ }
4315
+ for (const r of containing("events", "type, text", "text")) {
4316
+ if (eventNames(String(r.type), String(r.text), userId))
4317
+ del("events", "seq = ?", Number(r.seq));
4318
+ else if (errorNames(String(r.text), userId))
4319
+ this.db.query("UPDATE events SET text = ? WHERE seq = ?").run(redactError(String(r.text), userId), r.seq);
4320
+ }
4321
+ for (const r of containing("occurrences", "error", "error")) {
4322
+ if (errorNames(String(r.error), userId))
4323
+ this.db.query("UPDATE occurrences SET error = ? WHERE seq = ?").run(redactError(String(r.error), userId), r.seq);
4324
+ }
4325
+ if (person.display_name !== null) {
4326
+ const legacy = `delivery to ${person.display_name} paused:`;
4327
+ const rowsNaming = this.db.query("SELECT seq, task_id, detail FROM task_events WHERE kind = 'paused' AND actor_id IS NULL").all();
4328
+ for (const r of rowsNaming) {
4329
+ if (!recipientOf.has(r.task_id) || !r.detail.startsWith(legacy))
4330
+ continue;
4331
+ if (this.othersNamed(r.task_id, userId, person.display_name))
4332
+ continue;
4333
+ del("task_events", "seq = ?", r.seq);
4334
+ }
4335
+ }
4336
+ del("invite_blocks", "owner_id = ? OR recipient_id = ?", userId, userId);
4337
+ this.db.query("UPDATE invite_blocks SET lifted_by = ? WHERE lifted_by = ?").run(FORGOTTEN, userId);
4338
+ del("admissions", "user_id = ?", userId);
4339
+ this.db.query("UPDATE admissions SET admitted_by = ? WHERE admitted_by = ?").run(FORGOTTEN, userId);
4340
+ del("delivery_health", "user_id = ?", userId);
4341
+ del("web_sessions", "user_id = ?", userId);
4342
+ del("web_login_tokens", "user_id = ?", userId);
4343
+ del("users", "seq = ?", Number(seq));
4344
+ const held = this.db.query("SELECT 1 FROM delivery_pauses WHERE task_id = ? LIMIT 1");
4345
+ const freed = [...new Set(pausedFor)].filter((t) => held.get(t) === null);
4346
+ return { rows, freed };
4347
+ })();
4348
+ }
4109
4349
  }
4110
4350
 
4111
4351
  // plugins/tracker/src/store.ts
@@ -4428,160 +4668,97 @@ class SqliteStore {
4428
4668
  }
4429
4669
  }
4430
4670
 
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;
4671
+ // plugins/tracker/src/admin.ts
4672
+ var NO_SUCH_PERSON = "There is no such person on this tracker's list.";
4673
+ var NO_SUCH_BLOCK = "There is no such block in force.";
4674
+ var LAST_ADMIN = "That is the tracker's only admin: make someone else an admin first.";
4675
+ var BUSY = "The tracker is busy sending reminders; nothing was deleted. Try again in a minute.";
4676
+ var TICK_WAIT_MS = 20000;
4677
+ 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.";
4678
+ 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.";
4679
+ function isConfiguredAdmin(d, u) {
4680
+ return u.discordId !== null && d.configuredAdmins.has(u.discordId);
4451
4681
  }
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}.`);
4460
- }
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)") };
4682
+ function ticksSettled(d) {
4683
+ return d.lanes.idle(TICK_WAIT_MS);
4476
4684
  }
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;
4685
+ function isPersonId(id) {
4686
+ return /^u[1-9][0-9]{0,15}$/.test(id);
4491
4687
  }
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;
4688
+ async function person(d, id) {
4689
+ return isPersonId(id) ? d.store.getUser(id) : null;
4501
4690
  }
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);
4691
+ function who(u) {
4692
+ return u.displayName ?? u.discordId ?? u.id;
4508
4693
  }
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;
4694
+ async function setAdminFlag(d, admin, targetId, flag) {
4695
+ if (!admin.admin)
4696
+ return { ok: false, error: NOT_ADMIN };
4697
+ const target = await person(d, targetId);
4698
+ if (!target)
4699
+ return { ok: false, error: NO_SUCH_PERSON };
4700
+ if (target.admin === flag)
4701
+ return { ok: true, text: `${who(target)} ${flag ? "is already" : "is not"} an admin.` };
4702
+ if (!flag && d.roster.admins() <= 1)
4703
+ return { ok: false, error: LAST_ADMIN };
4704
+ if (!flag && isConfiguredAdmin(d, target))
4705
+ return { ok: false, error: CONFIGURED_ADMIN };
4706
+ await d.store.updateUser(target.id, { admin: flag });
4707
+ d.log.info(`${admin.id} ${flag ? "made" : "revoked"} ${target.id} ${flag ? "an admin" : "as admin"}`);
4708
+ return { ok: true, text: flag ? `${who(target)} is now an admin.` : `${who(target)} is no longer an admin.` };
4709
+ }
4710
+ async function resumeDelivery(d, admin, targetId) {
4711
+ if (!admin.admin)
4712
+ return { ok: false, error: NOT_ADMIN };
4713
+ const target = await person(d, targetId);
4714
+ if (!target)
4715
+ return { ok: false, error: NO_SUCH_PERSON };
4716
+ const resumed = await d.health.resume(target.id, d.clock.now());
4717
+ if (!resumed)
4718
+ return { ok: false, error: `Delivery to ${who(target)} is not paused.` };
4719
+ return { ok: true, text: `Delivery to ${who(target)} resumed; ${resumed.tasks.length} task(s) back on.` };
4720
+ }
4721
+ async function liftDeclineBlock(d, admin, blockId) {
4722
+ if (!admin.admin)
4723
+ return { ok: false, error: NOT_ADMIN };
4724
+ const block = /^b[1-9][0-9]{0,15}$/.test(blockId) ? await d.store.getBlock(blockId) : null;
4516
4725
  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}.`) };
4726
+ if (!block || (await activeBlock(d.store, block.ownerId, block.recipientId, now))?.id !== block.id)
4727
+ return { ok: false, error: NO_SUCH_BLOCK };
4728
+ await liftBlock(d.store, { userId: admin.id, admin: true }, block.id, now);
4729
+ const owner = await d.store.getUser(block.ownerId);
4730
+ const recipient = await d.store.getUser(block.recipientId);
4731
+ return {
4732
+ ok: true,
4733
+ text: `Lifted: ${owner ? who(owner) : block.ownerId} may invite ${recipient ? who(recipient) : block.recipientId} again.`
4734
+ };
4557
4735
  }
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
4736
+ async function forgetPerson(d, actor, targetId) {
4737
+ if (actor.id !== targetId && !actor.admin)
4738
+ return { ok: false, error: NOT_ADMIN };
4739
+ const target = await person(d, targetId);
4740
+ if (!target)
4741
+ return { ok: false, error: NO_SUCH_PERSON };
4742
+ if (target.admin && d.roster.admins() <= 1)
4743
+ return { ok: false, error: LAST_ADMIN };
4744
+ if (actor.id !== target.id && isConfiguredAdmin(d, target))
4745
+ return { ok: false, error: CONFIGURED_ADMIN };
4746
+ const now = d.clock.now();
4747
+ const erased = await d.health.exclusive(async () => {
4748
+ if (d.lanes.busy())
4749
+ return null;
4750
+ const e = d.roster.erase(target.id);
4751
+ for (const taskId of e.freed)
4752
+ await d.health.release(taskId, null, "resumed: a recipient left the tracker", now);
4753
+ return e;
4569
4754
  });
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)") };
4755
+ if (erased === null)
4756
+ return { ok: false, error: BUSY };
4757
+ if (!d.roster.checkpoint())
4758
+ d.log.warn(`after forgetting ${target.id}: the write-ahead log is busy, so SQLite's next checkpoint copies it back`);
4759
+ const total = Object.values(erased.rows).reduce((a, b) => a + b, 0);
4760
+ d.log.info(`forgot ${target.id} (${actor.id === target.id ? "at their own request" : `removed by ${actor.id}`}): ${total} row(s) deleted`);
4761
+ return { ok: true, text: actor.id === target.id ? "Everything the tracker held about you is deleted." : `${who(target)} is removed from the tracker.` };
4585
4762
  }
4586
4763
 
4587
4764
  // node_modules/@rackbops/styles/rackbops-noir/bundle.css
@@ -5810,6 +5987,7 @@ function page(f) {
5810
5987
  <span class="rb-muted">${f.signedIn.name}</span>
5811
5988
  <a class="rb-link" href="${f.base}/">My tasks</a>
5812
5989
  <a class="rb-link" href="${f.base}/settings">Settings</a>
5990
+ ${f.signedIn.admin ? html`<a class="rb-link" href="${f.base}/admin">Admin</a>` : null}
5813
5991
  <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
5992
  </nav>` : null;
5815
5993
  return html`<!doctype html>
@@ -5883,7 +6061,7 @@ function name3(u) {
5883
6061
  return u.displayName ?? "you";
5884
6062
  }
5885
6063
  function framed(v, title, body) {
5886
- return page({ base: v.base, title, signedIn: { name: name3(v.user), csrf: v.csrf }, body });
6064
+ return page({ base: v.base, title, signedIn: { name: name3(v.user), csrf: v.csrf, admin: v.user.admin }, body });
5887
6065
  }
5888
6066
  function signInHelpPage(base, note) {
5889
6067
  return page({
@@ -5967,6 +6145,7 @@ function historyPage(v, h, extra = {}) {
5967
6145
  <p><a class="rb-link" href="${v.base}/">My tasks</a></p>
5968
6146
  <h1>${h.task.title}</h1>
5969
6147
  ${extra.flash ?? null}
6148
+ ${extra.owner ? html`<p class="rb-muted">Owned by ${extra.owner}; you can see it, not change it.</p>` : null}
5970
6149
  <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
6150
  ${extra.controls ?? null}
5972
6151
  </section>
@@ -6001,6 +6180,10 @@ ${form.error ? html`<div class="rb-alert rb-alert--danger" role="alert"><p class
6001
6180
  </div>
6002
6181
  <div><button class="rb-btn rb-btn--primary" type="submit">Save</button></div>
6003
6182
  </form>
6183
+ </section>
6184
+ <section>
6185
+ <h2>Forget me</h2>
6186
+ <p>Delete everything the tracker holds about you, for good. <a class="rb-link" href="${v.base}/forget">Forget me</a></p>
6004
6187
  </section>`);
6005
6188
  }
6006
6189
  function notFoundPage(base, v) {
@@ -6152,9 +6335,441 @@ function notice(key, error) {
6152
6335
  return text ? html`<div class="rb-alert rb-alert--success" role="status"><p class="rb-alert__title">${text}</p></div>` : null;
6153
6336
  }
6154
6337
 
6338
+ // plugins/tracker/src/web/admin-pages.ts
6339
+ 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.";
6340
+ var CONFIRM_WORD = "forget";
6341
+ function nameOf(p, fallback = "someone") {
6342
+ if (!p)
6343
+ return fallback;
6344
+ return p.displayName ?? p.id;
6345
+ }
6346
+ function unmention(text) {
6347
+ return text.replace(/<@([0-9]{17,20})>/g, "$1");
6348
+ }
6349
+ function flash(result) {
6350
+ if (!result)
6351
+ return null;
6352
+ const text = prose(unmention(result.text));
6353
+ 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>`;
6354
+ }
6355
+ function hidden(v) {
6356
+ return html`<input type="hidden" name="csrf" value="${v.csrf}">`;
6357
+ }
6358
+ function adminNav(v) {
6359
+ 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>`;
6360
+ }
6361
+ function personHref(v, id, action = "") {
6362
+ return `${v.base}/admin/people/${encodeURIComponent(id)}${action ? `/${action}` : ""}`;
6363
+ }
6364
+ function admittedBy(p, names) {
6365
+ if (p.admittedBy === null)
6366
+ return "the configuration";
6367
+ if (p.admittedBy === FORGOTTEN)
6368
+ return "an admin since forgotten";
6369
+ return names.get(p.admittedBy) ?? p.admittedBy;
6370
+ }
6371
+ function delivery(p, viewer, now) {
6372
+ if (p.deliveryPausedAt)
6373
+ return `paused since ${formatInstant(p.deliveryPausedAt, viewer.timeZone, now)}`;
6374
+ return p.failures > 0 ? `on (${p.failures} failed in a row)` : "on";
6375
+ }
6376
+ function counts(p) {
6377
+ const o = p.owned;
6378
+ return `${o.active} active, ${o.paused} paused, ${o.done} done, ${o.archived} deleted; receives ${p.receiving}`;
6379
+ }
6380
+ function adminPage(v, data, o = {}) {
6381
+ const names = new Map(data.people.map((p) => [p.id, nameOf(p)]));
6382
+ const rows = data.people.map((p) => html`<tr>
6383
+ <td><a class="rb-link" href="${personHref(v, p.id)}">${nameOf(p)}</a>${p.admin ? html` <span class="rb-badge">admin</span>` : null}</td>
6384
+ <td>${p.registeredAt ? "registered" : "admitted, not registered"}</td>
6385
+ <td>${p.timeZone}, ${String(p.preferredHour).padStart(2, "0")}:00</td>
6386
+ <td>${delivery(p, v.user, data.now)}</td>
6387
+ <td>${counts(p)}</td>
6388
+ </tr>`);
6389
+ 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"}
6390
+ <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>`;
6391
+ return framed(v, "Admin", html`<section>
6392
+ <h1>People</h1>
6393
+ ${adminNav(v)}
6394
+ ${flash(o.result ?? null)}
6395
+ <div class="rb-table-scroll"><table class="rb-table">
6396
+ <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>
6397
+ <tbody>${rows}</tbody>
6398
+ </table></div>
6399
+ <p class="rb-muted">Admitted by: ${data.people.map((p) => `${nameOf(p)} -- ${admittedBy(p, names)}`).join("; ")}.</p>
6400
+ </section>
6401
+ <section>
6402
+ <h2>Allow a person</h2>
6403
+ <form method="post" action="${v.base}/admin/allow" class="tr-stack">
6404
+ ${hidden(v)}
6405
+ <div class="rb-field">
6406
+ <label class="rb-label" for="discord_id">Discord user id</label>
6407
+ <input class="rb-input" id="discord_id" name="discord_id" value="${o.allow ?? ""}" maxlength="20" inputmode="numeric" required>
6408
+ <p class="rb-field__help">What <code>/allow</code> does: they can then <code>/register</code>.</p>
6409
+ </div>
6410
+ <div><button class="rb-btn rb-btn--primary" type="submit">Allow</button></div>
6411
+ </form>
6412
+ </section>
6413
+ <section>
6414
+ <h2>Decline blocks</h2>
6415
+ ${blocks}
6416
+ </section>`);
6417
+ }
6418
+ function adminTasksPage(v, tasks) {
6419
+ const rows = tasks.map(({ task, owner, receiving }) => html`<tr>
6420
+ <td><a class="rb-link" href="${taskHref(v, task.id)}">${task.title}</a></td>
6421
+ <td>${task.type}</td>
6422
+ <td>${owner}</td>
6423
+ <td>${task.status === "archived" ? "deleted" : task.status}</td>
6424
+ <td>${receiving}</td>
6425
+ </tr>`);
6426
+ return framed(v, "All tasks", html`<section>
6427
+ <h1>All tasks</h1>
6428
+ ${adminNav(v)}
6429
+ ${tasks.length === 0 ? html`<p class="rb-muted">There are no tasks.</p>` : html`<div class="rb-table-scroll"><table class="rb-table">
6430
+ <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>
6431
+ <tbody>${rows}</tbody>
6432
+ </table></div>`}
6433
+ </section>`);
6434
+ }
6435
+ function post2(v, p, action, label, style) {
6436
+ 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>`;
6437
+ }
6438
+ function personPage(v, p, tasks, now, result = null, configured = false) {
6439
+ return framed(v, nameOf(p), html`<section>
6440
+ ${adminNav(v)}
6441
+ <h1>${nameOf(p)}${p.admin ? html` <span class="rb-badge">admin</span>` : null}</h1>
6442
+ ${flash(result)}
6443
+ <ul>
6444
+ <li>Discord id: ${p.discordId ?? "none"}</li>
6445
+ <li>${p.registeredAt ? `Registered ${formatInstant(p.registeredAt, v.user.timeZone, now)}` : "Admitted, not registered yet"}</li>
6446
+ <li>Zone and hour: ${p.timeZone}, ${String(p.preferredHour).padStart(2, "0")}:00</li>
6447
+ <li>Delivery: ${delivery(p, v.user, now)}</li>
6448
+ <li>Tasks: ${counts(p)}</li>
6449
+ </ul>
6450
+ ${configured ? html`<p class="rb-muted">${CONFIGURED_NOTE}</p>` : null}
6451
+ <div class="tr-row">
6452
+ ${p.admin ? configured ? null : post2(v, p, "revoke", "Revoke admin", "rb-btn--ghost") : post2(v, p, "grant", "Make admin", "rb-btn--ghost")}
6453
+ ${p.deliveryPausedAt ? post2(v, p, "resume-delivery", "Resume delivery", "rb-btn--accent") : null}
6454
+ ${configured && p.id !== v.user.id ? null : post2(v, p, "forget", "Remove from the tracker", "rb-btn--danger")}
6455
+ </div>
6456
+ </section>
6457
+ <section>
6458
+ <h2>Their tasks</h2>
6459
+ ${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>`}
6460
+ </section>`);
6461
+ }
6462
+ 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 and sign-in sessions; and your place on the list.";
6463
+ function forgetPage(v, note) {
6464
+ return framed(v, "Forget me", html`<section class="rb-card">
6465
+ <h1>Forget me</h1>
6466
+ <p>This deletes everything the tracker holds about you, at once and for good: ${WHAT_GOES}</p>
6467
+ <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>
6468
+ ${note ? html`<div class="rb-alert rb-alert--warning" role="note"><p>${note}</p></div>` : null}
6469
+ <form method="post" action="${v.base}/forget">${hidden(v)}<button class="rb-btn rb-btn--danger" type="submit">Continue</button></form>
6470
+ </section>`);
6471
+ }
6472
+ function confirmForgetPage(v, o) {
6473
+ const title = o.self ? "Delete everything about you?" : `Remove ${o.name} from the tracker?`;
6474
+ 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>`;
6475
+ return framed(v, o.self ? "Forget me" : `Remove ${o.name}`, html`<section class="rb-card">
6476
+ <h1>${title}</h1>
6477
+ ${what}
6478
+ ${o.note ? html`<div class="rb-alert rb-alert--warning" role="note"><p>${o.note}</p></div>` : null}
6479
+ ${o.error ? html`<div class="rb-alert rb-alert--danger" role="alert"><p>${o.error}</p></div>` : null}
6480
+ <form method="post" action="${o.action}" class="tr-stack">
6481
+ ${hidden(v)}
6482
+ <input type="hidden" name="confirm" value="yes">
6483
+ <div class="rb-field">
6484
+ <label class="rb-label" for="word">Type ${CONFIRM_WORD} to confirm</label>
6485
+ <input class="rb-input" id="word" name="word" maxlength="20" autocomplete="off" required>
6486
+ </div>
6487
+ <div class="tr-row"><button class="rb-btn rb-btn--danger" type="submit">${o.self ? "Delete everything" : "Remove"}</button>
6488
+ <a class="rb-btn rb-btn--ghost" href="${o.self ? `${v.base}/settings` : `${v.base}/admin`}">Keep</a></div>
6489
+ </form>
6490
+ </section>`);
6491
+ }
6492
+
6493
+ // plugins/tracker/src/web/admin.ts
6494
+ function unknownPage(base) {
6495
+ return htmlResponse(notFoundPage(base), 404);
6496
+ }
6497
+ function fresh(a, admin, fn) {
6498
+ return a.queue(async () => {
6499
+ const me = await a.d.store.getUser(a.v.user.id);
6500
+ if (!me || admin && !me.admin)
6501
+ return null;
6502
+ return fn(me);
6503
+ });
6504
+ }
6505
+ function said2(done) {
6506
+ return done.ok ? { ok: true, text: done.text } : { ok: false, text: done.error };
6507
+ }
6508
+ function confirmed(form) {
6509
+ if (form.get("confirm") !== "yes")
6510
+ return "ask";
6511
+ return (form.get("word") ?? "").trim().toLowerCase() === CONFIRM_WORD ? "yes" : "wrong";
6512
+ }
6513
+ var WRONG_WORD = `Type ${CONFIRM_WORD} to confirm. Nothing was deleted.`;
6514
+ async function peoplePage(a, result, status, allow) {
6515
+ const data = { people: a.d.roster.people(), blocks: a.d.roster.activeBlocks(a.d.clock.now()), now: a.d.clock.now() };
6516
+ return htmlResponse(adminPage(a.v, data, { result, ...allow !== undefined ? { allow } : {} }), status);
6517
+ }
6518
+ async function onePerson(a, id, result = null) {
6519
+ const p = a.d.roster.people().find((x) => x.id === id);
6520
+ if (!p)
6521
+ return htmlResponse(notFoundPage(a.v.base, a.v), 404);
6522
+ const tasks = await a.d.store.listTasks({ ownerId: p.id });
6523
+ return htmlResponse(personPage(a.v, p, tasks, a.d.clock.now(), result, isConfiguredAdmin(a.d, p)), result && !result.ok ? 400 : 200);
6524
+ }
6525
+ async function adminGet(a, r) {
6526
+ if (r.kind === "admin")
6527
+ return peoplePage(a, null, 200);
6528
+ if (r.kind === "admin-person")
6529
+ return onePerson(a, r.id);
6530
+ const people = new Map(a.d.roster.people().map((p) => [p.id, nameOf(p)]));
6531
+ const rows = [];
6532
+ for (const task of await a.d.store.listTasks()) {
6533
+ const receiving = (await a.d.store.listRecipients(task.id)).filter((x) => x.state === "accepted").length;
6534
+ rows.push({ task, owner: people.get(task.ownerId) ?? task.ownerId, receiving });
6535
+ }
6536
+ return htmlResponse(adminTasksPage(a.v, rows));
6537
+ }
6538
+ async function adminPost(a, r, form) {
6539
+ const base = a.v.base;
6540
+ if (r.kind === "admin-allow") {
6541
+ const raw = (form.get("discord_id") ?? "").trim();
6542
+ if (!/^[0-9]{17,20}$/.test(raw))
6543
+ return peoplePage(a, { ok: false, text: "That is not a Discord user id (17 to 20 digits)." }, 400, raw.slice(0, 20));
6544
+ const membership = await a.memberOf(raw);
6545
+ const text = await fresh(a, true, (me) => allowPerson(a.d, me, { discordId: raw, bot: false, membership }));
6546
+ if (text === null)
6547
+ return unknownPage(base);
6548
+ const ok = text.startsWith("Allowed") || text.includes("already on the list");
6549
+ return peoplePage(a, { ok, text }, ok ? 200 : 400);
6550
+ }
6551
+ if (r.kind === "admin-lift") {
6552
+ const done = await fresh(a, true, (me) => liftDeclineBlock(a.d, me, r.id));
6553
+ if (done === null)
6554
+ return unknownPage(base);
6555
+ return peoplePage(a, said2(done), done.ok ? 200 : 400);
6556
+ }
6557
+ if (r.kind !== "admin-act")
6558
+ return unknownPage(base);
6559
+ if (r.action === "forget") {
6560
+ const target = a.d.roster.people().find((x) => x.id === r.id);
6561
+ if (!target)
6562
+ return htmlResponse(notFoundPage(base, a.v), 404);
6563
+ const step = confirmed(form);
6564
+ const self = target.id === a.v.user.id;
6565
+ const note = self && isConfiguredAdmin(a.d, target) ? { note: CONFIGURED_SELF } : {};
6566
+ const page = (error) => confirmForgetPage(a.v, { action: personHref(a.v, target.id, "forget"), self, name: nameOf(target), ...note, ...error ? { error } : {} });
6567
+ if (step === "ask")
6568
+ return htmlResponse(page());
6569
+ if (step === "wrong")
6570
+ return htmlResponse(page(WRONG_WORD), 400);
6571
+ if (!await ticksSettled(a.d))
6572
+ return htmlResponse(page(BUSY), 503);
6573
+ const done = await fresh(a, true, (me) => forgetPerson(a.d, me, target.id));
6574
+ if (done === null)
6575
+ return unknownPage(base);
6576
+ if (!done.ok)
6577
+ return htmlResponse(page(done.error), done.error === NO_SUCH_PERSON ? 404 : done.error === BUSY ? 503 : 400);
6578
+ if (self)
6579
+ return redirect(`${base}/signin?forgotten=1`, [a.signOutCookie]);
6580
+ return peoplePage(a, said2(done), 200);
6581
+ }
6582
+ 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);
6583
+ const done = await fresh(a, true, act);
6584
+ if (done === null)
6585
+ return unknownPage(base);
6586
+ if (!done.ok && done.error === NO_SUCH_PERSON)
6587
+ return htmlResponse(notFoundPage(base, a.v), 404);
6588
+ const me = await a.d.store.getUser(a.v.user.id);
6589
+ if (!me?.admin)
6590
+ return redirect(`${base}/`);
6591
+ return onePerson(a, r.id, said2(done));
6592
+ }
6593
+ async function forgetRoute(a, method, form) {
6594
+ const base = a.v.base;
6595
+ const note = isConfiguredAdmin(a.d, a.v.user) ? CONFIGURED_SELF : undefined;
6596
+ if (method === "GET" || !form)
6597
+ return htmlResponse(forgetPage(a.v, note));
6598
+ const page = (error) => confirmForgetPage(a.v, { action: `${base}/forget`, self: true, name: nameOf(a.v.user), ...note ? { note } : {}, ...error ? { error } : {} });
6599
+ const step = confirmed(form);
6600
+ if (step === "ask")
6601
+ return htmlResponse(page());
6602
+ if (step === "wrong")
6603
+ return htmlResponse(page(WRONG_WORD), 400);
6604
+ if (!await ticksSettled(a.d))
6605
+ return htmlResponse(page(BUSY), 503);
6606
+ const done = await fresh(a, false, (me) => forgetPerson(a.d, me, me.id));
6607
+ if (done === null)
6608
+ return redirect(`${base}/signin`, [a.signOutCookie]);
6609
+ if (!done.ok)
6610
+ return htmlResponse(page(done.error), done.error === BUSY ? 503 : 400);
6611
+ return redirect(`${base}/signin?forgotten=1`, [a.signOutCookie]);
6612
+ }
6613
+
6614
+ // plugins/tracker/src/edit.ts
6615
+ async function owned(d, user, taskId, type) {
6616
+ const task = await ownLiveTask(d, user, taskId);
6617
+ if (!task || task.type !== type)
6618
+ return NO_SUCH_TASK;
6619
+ if (!editable(task))
6620
+ return FINISHED;
6621
+ return task;
6622
+ }
6623
+ function same(a, b) {
6624
+ return JSON.stringify(a) === JSON.stringify(b);
6625
+ }
6626
+ async function applyEdit(d, task, user, patch) {
6627
+ const fields = Object.keys(patch);
6628
+ if (fields.length === 0)
6629
+ return task;
6630
+ const at = d.clock.now().toISOString();
6631
+ const updated = await d.store.updateTask(task.id, { ...patch, at });
6632
+ await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "edited", detail: fields.join(", "), at });
6633
+ return updated;
6634
+ }
6635
+ async function nextRun(d, task) {
6636
+ const queued = await d.store.listOccurrences({ taskId: task.id, status: "queued" });
6637
+ return queued[0] ?? null;
6638
+ }
6639
+ function savedText(task, user, next, now, what) {
6640
+ const cadence = task.schedule ? describeSchedule(task.schedule, user, user.timeZone, now) : "no schedule";
6641
+ const when = task.status === "paused" ? "paused" : next ? formatInstant(next.dueAt, user.timeZone, now) : "nothing scheduled";
6642
+ return clip(`Saved \`${task.id}\` ${what}: ${cadence}. Next: ${when}.`);
6643
+ }
6644
+ var blank = (v) => v === undefined || v.trim() === "";
6645
+ async function editReminder(d, user, taskId, input) {
6646
+ const task = await owned(d, user, taskId, "reminder");
6647
+ if (typeof task === "string")
6648
+ return { ok: false, error: task };
6649
+ const config = task.config ?? {};
6650
+ const text = blank(input.text) ? String(config.text ?? "") : input.text;
6651
+ const repeat = input.repeat ?? repeatOf(task.schedule);
6652
+ const plan = reminderPlan(d, user, { text, repeat, ...input.when !== undefined ? { when: input.when } : {} }, task.schedule);
6653
+ if (!plan.ok)
6654
+ return plan;
6655
+ let updated = config.text === plan.text ? task : await applyEdit(d, task, user, { title: clip(plan.text, MAX_TITLE), config: { ...config, text: plan.text } });
6656
+ if (!same(plan.schedule, task.schedule))
6657
+ updated = await rescheduleKeepingSnoozes(d, updated, user, plan.schedule, user.id);
6658
+ return { ok: true, task: updated, text: savedText(updated, user, await nextRun(d, updated), d.clock.now(), "(reminder)") };
6659
+ }
6660
+ var DAY_MS3 = 24 * 60 * 60 * 1000;
6661
+ function periodDateFrom(s, date, owner) {
6662
+ const ctx = { zone: owner.timeZone, preferredHour: owner.preferredHour };
6663
+ let after = new Date(Date.parse(`${date}T00:00:00Z`) - ((s.leadDays ?? 0) + 2) * DAY_MS3);
6664
+ for (let i = 0;i < 1000; i++) {
6665
+ const due = nextDue(s, after, ctx);
6666
+ if (!due)
6667
+ return null;
6668
+ const on = periodDate(s, due, owner.timeZone);
6669
+ if (on >= date)
6670
+ return on;
6671
+ after = due;
6672
+ }
6673
+ return null;
6674
+ }
6675
+ function renewalDate(task, owner, now) {
6676
+ const s = task.schedule;
6677
+ if (s?.kind !== "period")
6678
+ return "";
6679
+ const w = wallClock(now, owner.timeZone);
6680
+ const today = `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
6681
+ if (s.anchor >= today)
6682
+ return s.anchor;
6683
+ return periodDateFrom(s, today, owner) ?? s.anchor;
6684
+ }
6685
+ async function askedAbout(d, task, owner, date) {
6686
+ const old = task.schedule;
6687
+ if (old?.kind !== "period")
6688
+ return false;
6689
+ const fired = (await d.store.listOccurrences({ taskId: task.id })).filter((o) => o.status !== "queued" && !o.dedupeKey.startsWith(SNOOZE_PREFIX));
6690
+ return fired.some((o) => periodDate(old, new Date(o.dueAt), owner.timeZone) === date);
6691
+ }
6692
+ async function editRenewal(d, user, taskId, input) {
6693
+ const task = await owned(d, user, taskId, "renewal");
6694
+ if (typeof task === "string")
6695
+ return { ok: false, error: task };
6696
+ if (task.schedule?.kind !== "period")
6697
+ return { ok: false, error: FINISHED };
6698
+ const old = task.schedule;
6699
+ const now = d.clock.now();
6700
+ const config = task.config;
6701
+ const state = task.state;
6702
+ const carried = state && typeof state.amount === "number" ? state.amount : null;
6703
+ const note = input.note ?? config.note;
6704
+ const plan = renewalPlan(d, user, {
6705
+ name: blank(input.name) ? task.title : input.name,
6706
+ amount: input.amount ?? carried ?? config.amount,
6707
+ currency: blank(input.currency) ? config.currency : input.currency,
6708
+ renews: blank(input.renews) ? renewalDate(task, user, now) : input.renews,
6709
+ every: input.every ?? old.every,
6710
+ unit: input.unit ?? old.unit,
6711
+ lead: input.lead ?? old.leadDays ?? 0,
6712
+ ...note !== undefined ? { note } : {}
6713
+ });
6714
+ if (!plan.ok)
6715
+ return plan;
6716
+ const sameShape = plan.schedule.every === old.every && plan.schedule.unit === old.unit && plan.schedule.leadDays === (old.leadDays ?? 0);
6717
+ const schedule = sameShape && periodDateFrom(old, plan.schedule.anchor, user) === plan.schedule.anchor ? old : plan.schedule;
6718
+ const problems = scheduleProblems(schedule);
6719
+ if (problems.length > 0)
6720
+ return { ok: false, error: `That schedule does not work: ${problems.join("; ")}.` };
6721
+ const patch = {};
6722
+ if (plan.name !== task.title)
6723
+ patch.title = plan.name;
6724
+ const newAmount = plan.amount !== (carried ?? config.amount);
6725
+ const nextConfig = { amount: newAmount ? plan.amount : config.amount, currency: plan.currency, ...plan.note ? { note: plan.note } : {} };
6726
+ if (!same(nextConfig, config))
6727
+ patch.config = nextConfig;
6728
+ if (newAmount && carried !== null)
6729
+ patch.state = { ...state, amount: plan.amount };
6730
+ let updated = await applyEdit(d, task, user, patch);
6731
+ let next = await nextRun(d, updated);
6732
+ if (!same(schedule, old)) {
6733
+ const asked = await askedAbout(d, task, user, schedule.anchor);
6734
+ updated = await rescheduleKeepingSnoozes(d, updated, user, schedule, user.id);
6735
+ next = (asked ? null : await firstAskIfDue(d, updated, user, schedule)) ?? await nextRun(d, updated);
6736
+ }
6737
+ const ask = updated.status === "paused" ? "paused" : askText(next, plan.days, user, now);
6738
+ const cadence = describeSchedule(schedule, user, user.timeZone, now);
6739
+ return { ok: true, task: updated, text: clip(`Saved \`${updated.id}\` (renewal): ${cadence}. Next ask: ${ask}.`) };
6740
+ }
6741
+ async function editPrice(d, user, taskId, input) {
6742
+ const task = await owned(d, user, taskId, "price");
6743
+ if (typeof task === "string")
6744
+ return { ok: false, error: task };
6745
+ const config = task.config;
6746
+ const schedule = task.schedule;
6747
+ const plan = priceSettingsPlan({
6748
+ name: input.name ?? task.title,
6749
+ hours: input.hours ?? (schedule?.kind === "poll" ? schedule.every : undefined),
6750
+ drop: input.drop ?? config.dropPercent ?? DEFAULT_DROP_PERCENT,
6751
+ baseline: input.baseline ?? config.baseline ?? DEFAULT_BASELINE
6752
+ });
6753
+ if (!plan.ok)
6754
+ return plan;
6755
+ const patch = {};
6756
+ const title = plan.name || titleFor(config.url);
6757
+ if (title !== task.title)
6758
+ patch.title = title;
6759
+ const nextConfig = { ...config, dropPercent: plan.drop, baseline: plan.baseline };
6760
+ if (!same(nextConfig, config))
6761
+ patch.config = nextConfig;
6762
+ let updated = await applyEdit(d, task, user, patch);
6763
+ if (schedule?.kind === "poll" && (schedule.every !== plan.hours || schedule.unit !== "hour")) {
6764
+ const next = { ...schedule, every: plan.hours, unit: "hour" };
6765
+ updated = await rescheduleKeepingSnoozes(d, updated, user, next, user.id);
6766
+ }
6767
+ return { ok: true, task: updated, text: savedText(updated, user, await nextRun(d, updated), d.clock.now(), "(price)") };
6768
+ }
6769
+
6155
6770
  // plugins/tracker/src/web/editor.ts
6156
6771
  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.";
6772
+ var GONE = NO_LONGER_LISTED;
6158
6773
  function asViewer(e, fn) {
6159
6774
  return e.queue(async () => {
6160
6775
  const user = await e.d.store.getUser(e.v.user.id);
@@ -6283,8 +6898,10 @@ async function taskPage(e, id, done, error) {
6283
6898
  const view = await loadHistory(e.d, e.v.user, id);
6284
6899
  if (!view)
6285
6900
  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);
6901
+ const own = view.task.ownerId === e.v.user.id;
6902
+ const controls = own ? ownerControls(e.v, view.task) : null;
6903
+ const owner = own ? null : (await e.d.store.getUser(view.task.ownerId))?.displayName ?? view.task.ownerId;
6904
+ return htmlResponse(historyPage(e.v, view, { controls, flash: notice(done, error), owner }), error ? 400 : 200);
6288
6905
  }
6289
6906
  function current(d, user, task) {
6290
6907
  const now = d.clock.now();
@@ -6359,6 +6976,19 @@ async function actionPost(e, id, action, form) {
6359
6976
  var TASK_PATH = /^\/tasks\/([^/]+)$/;
6360
6977
  var TASK_ACTION = /^\/tasks\/([^/]+)\/(edit|pause|resume|delete)$/;
6361
6978
  var NEW_PATH = /^\/new\/([a-z]+)$/;
6979
+ var PERSON_PATH = /^\/admin\/people\/([^/]+)$/;
6980
+ var PERSON_ACTION = /^\/admin\/people\/([^/]+)\/(grant|revoke|resume-delivery|forget)$/;
6981
+ var BLOCK_LIFT = /^\/admin\/blocks\/([^/]+)\/lift$/;
6982
+ function isAdminRoute(r) {
6983
+ return r.kind.startsWith("admin");
6984
+ }
6985
+ function segment(raw) {
6986
+ try {
6987
+ return decodeURIComponent(raw ?? "");
6988
+ } catch {
6989
+ return "";
6990
+ }
6991
+ }
6362
6992
  function route(path) {
6363
6993
  if (path === "/")
6364
6994
  return { kind: "tasks" };
@@ -6366,6 +6996,23 @@ function route(path) {
6366
6996
  return { kind: "settings" };
6367
6997
  if (path === "/logout")
6368
6998
  return { kind: "logout" };
6999
+ if (path === "/forget")
7000
+ return { kind: "forget" };
7001
+ if (path === "/admin")
7002
+ return { kind: "admin" };
7003
+ if (path === "/admin/tasks")
7004
+ return { kind: "admin-tasks" };
7005
+ if (path === "/admin/allow")
7006
+ return { kind: "admin-allow" };
7007
+ const personAct = PERSON_ACTION.exec(path);
7008
+ if (personAct)
7009
+ return { kind: "admin-act", id: segment(personAct[1]), action: personAct[2] };
7010
+ const onePerson = PERSON_PATH.exec(path);
7011
+ if (onePerson)
7012
+ return { kind: "admin-person", id: segment(onePerson[1]) };
7013
+ const lift = BLOCK_LIFT.exec(path);
7014
+ if (lift)
7015
+ return { kind: "admin-lift", id: segment(lift[1]) };
6369
7016
  const created = NEW_PATH.exec(path);
6370
7017
  if (created) {
6371
7018
  const type = created[1];
@@ -6374,12 +7021,7 @@ function route(path) {
6374
7021
  const one = TASK_PATH.exec(path) ?? TASK_ACTION.exec(path);
6375
7022
  if (!one)
6376
7023
  return null;
6377
- let id;
6378
- try {
6379
- id = decodeURIComponent(one[1] ?? "");
6380
- } catch {
6381
- id = "";
6382
- }
7024
+ const id = segment(one[1]);
6383
7025
  const action = one[2];
6384
7026
  if (action === undefined)
6385
7027
  return { kind: "task", id };
@@ -6388,9 +7030,9 @@ function route(path) {
6388
7030
  return { kind: "act", id, action };
6389
7031
  }
6390
7032
  function methodsOf(r) {
6391
- if (r.kind === "logout" || r.kind === "act")
7033
+ if (r.kind === "logout" || r.kind === "act" || r.kind === "admin-allow" || r.kind === "admin-act" || r.kind === "admin-lift")
6392
7034
  return "POST";
6393
- if (r.kind === "settings" || r.kind === "edit" || r.kind === "new")
7035
+ if (r.kind === "settings" || r.kind === "edit" || r.kind === "new" || r.kind === "forget")
6394
7036
  return "GET, POST";
6395
7037
  return "GET";
6396
7038
  }
@@ -6443,6 +7085,11 @@ function plain(status, text, extra = {}) {
6443
7085
  headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", ...extra }
6444
7086
  });
6445
7087
  }
7088
+ function signInNote(q) {
7089
+ if (q.get("forgotten") === "1")
7090
+ return "Everything the tracker held about you is deleted, and you are signed out.";
7091
+ return q.get("out") === "1" ? "You are signed out." : undefined;
7092
+ }
6446
7093
  async function lookupWithin(lookup, ms) {
6447
7094
  let timer;
6448
7095
  const late = new Promise((resolve) => {
@@ -6527,6 +7174,11 @@ function createWebHandler(w) {
6527
7174
  d.sessions.deleteForUser(user.id);
6528
7175
  return { kind: "signed-out", note: RECHECK_FAILED };
6529
7176
  }
7177
+ async function memberOf(discordId) {
7178
+ if (w.guildId === null)
7179
+ return "not-checked";
7180
+ return await lookupWithin(() => w.membership(discordId), MEMBER_CHECK_TIMEOUT_MS) ?? "unknown";
7181
+ }
6530
7182
  async function readForm(request) {
6531
7183
  const type = request.headers.get("content-type") ?? "";
6532
7184
  if (!type.toLowerCase().startsWith("application/x-www-form-urlencoded"))
@@ -6605,22 +7257,28 @@ function createWebHandler(w) {
6605
7257
  });
6606
7258
  }
6607
7259
  if (path === "/signin")
6608
- return only("GET") ?? htmlResponse(signInHelpPage(base, url.searchParams.get("out") === "1" ? "You are signed out." : undefined));
7260
+ return only("GET") ?? htmlResponse(signInHelpPage(base, signInNote(url.searchParams)));
6609
7261
  if (path === "/login")
6610
7262
  return method === "GET" ? loginGet(d, url) : loginPost(d, request);
6611
7263
  const r = route(path);
6612
7264
  if (r === null)
6613
7265
  return htmlResponse(notFoundPage(base), 404);
6614
7266
  const allowed = methodsOf(r);
6615
- if (!allowed.split(", ").includes(method))
7267
+ const admin = isAdminRoute(r);
7268
+ if (!admin && !allowed.split(", ").includes(method))
6616
7269
  return plain(405, "Method not allowed", { Allow: allowed });
6617
7270
  const auth = await authenticate(d, request);
6618
7271
  if (auth.kind === "signed-out")
6619
7272
  return htmlResponse(signInHelpPage(base, auth.note), 403, { "Set-Cookie": clear(SESSION_COOKIE) });
6620
7273
  if (auth.kind !== "ok")
6621
7274
  return redirect(`${base}/signin`, auth.kind === "stale" ? [clear(SESSION_COOKIE)] : []);
7275
+ if (admin && !auth.user.admin)
7276
+ return unknownPage(base);
7277
+ if (admin && !allowed.split(", ").includes(method))
7278
+ return plain(405, "Method not allowed", { Allow: allowed });
6622
7279
  const v = { base, user: auth.user, csrf: auth.session.csrf };
6623
7280
  const e = { d, v, queue: w.queue, reading };
7281
+ const a = { d, v, queue: w.queue, memberOf: (id) => memberOf(id), signOutCookie: clear(SESSION_COOKIE) };
6624
7282
  if (method === "POST") {
6625
7283
  const form = await readForm(request);
6626
7284
  if (!form)
@@ -6639,6 +7297,12 @@ function createWebHandler(w) {
6639
7297
  return editPost(e, r.id, form);
6640
7298
  case "act":
6641
7299
  return actionPost(e, r.id, r.action, form);
7300
+ case "forget":
7301
+ return forgetRoute(a, method, form);
7302
+ case "admin-allow":
7303
+ case "admin-act":
7304
+ case "admin-lift":
7305
+ return adminPost(a, r, form);
6642
7306
  default:
6643
7307
  return plain(405, "Method not allowed", { Allow: allowed });
6644
7308
  }
@@ -6654,6 +7318,12 @@ function createWebHandler(w) {
6654
7318
  return editGet(e, r.id);
6655
7319
  case "new":
6656
7320
  return newGet(e, r.type);
7321
+ case "forget":
7322
+ return forgetRoute(a, method, null);
7323
+ case "admin":
7324
+ case "admin-tasks":
7325
+ case "admin-person":
7326
+ return adminGet(a, r);
6657
7327
  default:
6658
7328
  return htmlResponse(settingsPage(v, { hour: String(auth.user.preferredHour), zone: auth.user.timeZone, saved: url.searchParams.get("saved") === "1" }));
6659
7329
  }
@@ -6746,6 +7416,28 @@ function createPlugin(host, options = {}) {
6746
7416
  if (failed > 0)
6747
7417
  host.log.warn(`${kind === "poll" ? "poll" : "notify"} lane: ${ran} ran, ${failed} failed`);
6748
7418
  }
7419
+ const running = new Set;
7420
+ const tracked = (p) => {
7421
+ running.add(p);
7422
+ const done = () => void running.delete(p);
7423
+ p.then(done, done);
7424
+ return p;
7425
+ };
7426
+ const lanes = {
7427
+ busy: () => running.size > 0,
7428
+ async idle(ms) {
7429
+ const until = Date.now() + ms;
7430
+ while (running.size > 0) {
7431
+ const left = until - Date.now();
7432
+ if (left <= 0)
7433
+ return false;
7434
+ let timer;
7435
+ await Promise.race([Promise.allSettled([...running]), new Promise((r) => timer = setTimeout(r, left))]);
7436
+ clearTimeout(timer);
7437
+ }
7438
+ return true;
7439
+ }
7440
+ };
6749
7441
  let discordClient = null;
6750
7442
  const capture = (interaction) => {
6751
7443
  const client = interaction.client;
@@ -6789,6 +7481,9 @@ function createPlugin(host, options = {}) {
6789
7481
  const openedStore = new SqliteStore(opened);
6790
7482
  const openedClaims = new ClaimStore(opened);
6791
7483
  try {
7484
+ const vacuum = vacuumOnce(opened, path);
7485
+ if ("error" in vacuum)
7486
+ host.log.warn(`could not vacuum the tracker's database (${vacuum.error}); trying again at the next start`);
6792
7487
  const granted = await seedAdmins(openedStore, adminIds, clock.now());
6793
7488
  const admissions = new Admissions(opened);
6794
7489
  for (const id of adminIds) {
@@ -6831,6 +7526,9 @@ function createPlugin(host, options = {}) {
6831
7526
  notifier: dm ? createDmNotifier({ store: openedStore, claims: openedClaims, dm, clock, log: host.log, health: delivery }) : NO_DM,
6832
7527
  logins: new LoginLinks(opened),
6833
7528
  sessions: new Sessions(opened),
7529
+ roster: new Roster(opened),
7530
+ configuredAdmins: new Set(adminIds),
7531
+ lanes,
6834
7532
  webEditor: webOrigin !== null
6835
7533
  };
6836
7534
  if (guildId === null)
@@ -6851,11 +7549,11 @@ function createPlugin(host, options = {}) {
6851
7549
  ticks: [
6852
7550
  {
6853
7551
  name: "notify",
6854
- run: (signal) => tick("notify", signal)
7552
+ run: (signal) => tracked(tick("notify", signal))
6855
7553
  },
6856
7554
  {
6857
7555
  name: "poll",
6858
- run: (signal) => tick("poll", signal)
7556
+ run: (signal) => tracked(tick("poll", signal))
6859
7557
  }
6860
7558
  ],
6861
7559
  async http(request, info) {