@rackbops/plugin-tracker 0.6.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 +145 -10
  2. package/dist/plugin.js +751 -92
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -8,8 +8,9 @@ command, delivered by DM, answered by button, with history -- the first slice of
8
8
  (#80): sign-in by one-time link, my tasks, a task's history, and settings -- renewals and
9
9
  the price tracker (#81, plan E6): two more types on the notify side, no model, nothing sent to
10
10
  city-hall -- the web area's task editor (#80, slice 2): make, edit, pause, resume and delete
11
- your own tasks from the web, under the commands' own rules -- and the admin view and forget-me (#80,
12
- slice 3).
11
+ your own tasks from the web, under the commands' own rules -- the admin view and forget-me (#80,
12
+ slice 3) -- and the JSON task API with personal API tokens (#80, slice 4), for a program acting as
13
+ you, such as a later intake agent (plan E10).
13
14
 
14
15
  ## Commands
15
16
 
@@ -65,12 +66,15 @@ served at a hashed path, cached for a year).
65
66
  | `/new/reminder`, `/new/renewal`, `/new/price` | The editor's new-task forms (GET), and making one (POST). |
66
67
  | `/tasks/<id>/edit` | The owner's edit form (GET) and saving it (POST). |
67
68
  | `/tasks/<id>/pause`, `/resume`, `/delete` | POST only. Delete answers a confirmation first; only a second post carrying `confirm=yes` deletes. |
68
- | `/settings` | Preferred hour and time zone, checked as `/register` checks them. |
69
+ | `/settings` | Preferred hour and time zone, checked as `/register` checks them. Links to API tokens and Forget me. |
70
+ | `/tokens` | Your API tokens (below): each one's name, when it was made, last used and expires, with Revoke; and the form to make one (POST), which sends the new token to you by Discord DM -- never on the page. |
71
+ | `/tokens/<id>/revoke` | POST only: revokes one of your own tokens. Anyone else's answers the same 404 as an unknown id. |
69
72
  | `/forget` | Forget me (below): what it deletes (GET); a POST asks for the confirmation; a POST with `confirm=yes` and the word `forget` erases. Linked from Settings. |
70
73
  | `/admin` | Admins only: everyone on the list, the decline blocks in force (each with Lift), and a form to allow a person by Discord id. |
71
74
  | `/admin/tasks` | Admins only: every task in the store, with its owner, status and how many receive it, each linking to its page. |
72
75
  | `/admin/people/<id>` | Admins only: one person -- Discord id, registration, zone and hour, delivery, task counts, their tasks -- with Make admin or Revoke admin, Resume delivery (when paused), and Remove from the tracker. |
73
- | `/admin/allow`, `/admin/people/<id>/grant`, `/revoke`, `/resume-delivery`, `/forget`, `/admin/blocks/<id>/lift` | Admins only, POST only: the acts below. |
76
+ | `/admin/allow`, `/admin/people/<id>/grant`, `/revoke`, `/resume-delivery`, `/forget`, `/admin/blocks/<id>/lift`, `/admin/tokens/<id>/revoke` | Admins only, POST only: the acts below. |
77
+ | `/api/v1/...` | The JSON task API (below): bearer tokens only, never the cookie. |
74
78
  | `/signin` | Where a request that is not signed in is sent: says to run `/web`. |
75
79
  | `/login?t=...` | The link `/web` gives. |
76
80
 
@@ -162,6 +166,8 @@ the function the command path runs:
162
166
  - **Resume delivery** of a person paused after failed DMs: `DeliveryHealth.resume`, what the
163
167
  person's own next command does -- the count clears and the tasks nothing else holds go back on.
164
168
  - **Lift** a decline block in force: docket's `liftBlock`, recorded as that admin's lift.
169
+ - **Revoke an API token** of anyone's, from their page, which lists their tokens (never the
170
+ secret). It stops working at once; the owner is not told. Logged by token id.
165
171
  - **Remove from the tracker**: forget-me for that person (below), with the same confirmation.
166
172
 
167
173
  Not here yet, though plan 5.10 lists them: grants, budgets and retry (they belong to the execute
@@ -178,7 +184,8 @@ in one SQLite transaction in the write queue, **deleted, not archived**:
178
184
  made or that name them (an invitation of them, a pause for them), the run events and delivery
179
185
  claims of DMs to them, and their pause rows;
180
186
  - every decline block they are either side of;
181
- - their admission, delivery health, web sessions and unused sign-in links, and their person row.
187
+ - their admission, delivery health, web sessions, unused sign-in links and API tokens, and their
188
+ person row.
182
189
 
183
190
  What stays, and why: another person's task that they received stays that owner's; one paused only
184
191
  because DMs to them failed goes back on. Another person's row keeps its own audit fact but not their
@@ -199,8 +206,8 @@ bot sent stay in the person's Discord DMs, and the host's log may hold their tra
199
206
 
200
207
  Bytes: the database runs with `secure_delete` on, so a deleted row is overwritten, and the first
201
208
  start of 0.6.0 on an existing database runs one `VACUUM`, so pages freed before that are rewritten
202
- too. That one-time VACUUM is recorded in a small `tracker_meta` table, not by a schema bump: the
203
- schema stays at 3, so rolling back to 0.5.0 still opens the database. If the VACUUM cannot run (a
209
+ too. That one-time VACUUM is recorded in a small `tracker_meta` table, not by a schema bump (0.6.0
210
+ kept the schema at 3; 0.7.0's API tokens are schema 4, see the CHANGELOG). If the VACUUM cannot run (a
204
211
  reader holds the file), the start goes on, a warning is logged, and the next start tries again.
205
212
  Not a VACUUM per erasure: it rewrites the whole file under an
206
213
  exclusive lock, and `secure_delete` already covers every later delete. After an erasure the
@@ -223,6 +230,131 @@ are among the rows, so they are signed out everywhere at once, and the browser's
223
230
  The `__Host-` prefix, which would stop that, requires `Path=/` and so cannot be used with
224
231
  `Path=/tracker/`. Put the bot on a host whose sibling subdomains are all yours.
225
232
 
233
+ ## Task API
234
+
235
+ A JSON API over your own tasks, for a program that acts as you -- a script, or the plain-language
236
+ intake agent the plan defers (E10, item 31), which would end its dialogue by posting a task here.
237
+ It is served under `/tracker/api/v1/` on the same origin as the web area, and only when
238
+ `TRACKER_WEB_URL` is set. Every write goes through the same functions as the web editor and the
239
+ slash commands -- the same fields, defaults, limits, caps and messages -- in the same write queue.
240
+
241
+ **Tokens.** Make one on the web area's `/tokens` page (linked from Settings): a name, and an expiry
242
+ of 30, 90 (the default) or 365 days -- every token expires. The token -- `trk_` and 43 characters --
243
+ is sent to you once, by Discord DM from the bot; it never appears in any web page or response, and
244
+ the tracker keeps only its SHA-256. If that DM cannot be delivered (DMs closed), the token is
245
+ deleted at once and the page says so: open your DMs and try again. At most 10 live tokens per
246
+ person, and at most 10 made per person in any rolling hour, revoked ones counted (each is a DM, so
247
+ a script riding your session cannot flood your DMs by making and revoking); the count is kept in
248
+ memory only, so a restart clears it. Each lists when it was made, last used (recorded at most once a minute) and expires, and
249
+ can be revoked there at once; an admin sees and revokes anyone's from the admin view's page for
250
+ that person. Forget-me erases a person's tokens with the rest. The log names a token by its id
251
+ (`k1`), never by its secret.
252
+
253
+ **Authentication.** `Authorization: Bearer <token>` on every request. The web area's session
254
+ cookie is never read here: every plugin shares one browser origin, so a cookie-authenticated JSON
255
+ API could be called by any script on it. A token acts as its owner with the owner's rights only,
256
+ and only on the owner's own tasks -- an admin's token included; an admin's wider reads stay on the
257
+ signed-in web pages. Every request re-reads the owner, as the web does a session: a person no
258
+ longer on the tracker, or no longer registered, is refused and their tokens deleted; with `TRACKER_GUILD_ID` set, membership
259
+ is re-checked on the web's schedule (after 15 minutes, one lookup, shared with the web), and one who
260
+ has left the server loses every token and every session; a lookup that keeps failing lets them on
261
+ for 24 hours from the last confirmation, then answers 503 until one succeeds (the token is kept).
262
+ A token is looked up by the SHA-256 of what was sent, so the comparison is over a hash the sender
263
+ cannot steer.
264
+
265
+ **Browsers.** No answer carries a CORS header, and a request with an `Origin` header -- which a
266
+ browser adds to every cross-origin request and to any same-origin one that is not a GET -- is
267
+ refused with 403: the API is for programs, not pages. A browser page cannot send a cross-origin
268
+ `Authorization` header without a CORS preflight, which is refused. A script of another plugin on
269
+ the same origin (or an XSS there) can ride a signed-in person's cookie and use the web area as
270
+ them for as long as the session lasts -- that is the shared origin's cost, as for the cookies above
271
+ -- but it cannot get a token's secret: the page that makes one never shows it, the secret goes
272
+ only to the person's Discord DMs (a token made that way is one they are told about and can
273
+ revoke), and every token expires within a year. Put nothing on that origin you do not trust.
274
+
275
+ **Limits.** Each token may make 60 requests at once, refilled at one per second; over that, `429`
276
+ with `Retry-After`. A limited request is refused before its owner is looked up. Requests whose
277
+ token does not look up (unknown, revoked, expired, malformed) share one global bucket of 30,
278
+ refilled at one every two seconds; past it they get `429` instead of `401`, and valid tokens are
279
+ unaffected. A body is at most
280
+ 16 KiB, read no further. One new price tracker's page read in flight per person, shared with the
281
+ web editor (`409 busy`).
282
+
283
+ **Requests.** A `POST` or `PATCH` body is one JSON object with `Content-Type: application/json`
284
+ (else `415`), and names only the fields listed for that type (else `400 unknown_field`), each of
285
+ its JSON type: text as a string, a whole number or a number as a JSON number. A field left out of a
286
+ create gets the command's default; a field left out of an edit keeps what the task has, and an empty
287
+ string does the same, except that an empty `note` clears a renewal's note and an empty `name` gives
288
+ a price back the page's address -- exactly as the web editor's empty fields do. `null` is never a
289
+ value: it is refused as the wrong JSON type (send `""` to clear a note).
290
+
291
+ Which types: `GET /tasks`, `GET /tasks/<id>`, pause, resume and `DELETE` work on any task you own,
292
+ whatever its type; `POST /tasks` and `PATCH` take the three editor types (reminder, renewal,
293
+ price), and a `PATCH` of a task of another type answers `404`, as the web editor does.
294
+
295
+ | Method and path | What |
296
+ |---|---|
297
+ | `GET /api/v1/me` | The token's owner (id, name, zone, preferred hour) and the token (id, name, made, expires). |
298
+ | `GET /api/v1/types` | Each type's create and edit fields: name, JSON type, required (never, for an edit), description, and limits (`maxLength`, `minimum`, `maximum`, `enum`). This describes the tracker's own editor fields -- what these endpoints take -- and is **not** docket-core's `TaskType.intake` (`IntakeSpec`), which describes a type's config. |
299
+ | `GET /api/v1/tasks` | Your tasks that are not deleted -- active, paused and done -- oldest first. Not the ones shared with you. |
300
+ | `POST /api/v1/tasks` | Makes one: `type` is `reminder`, `renewal` or `price`, and the rest are that type's create fields. `201`, with `Location`. A price's page is read first, and nothing is made unless a price is found in it. |
301
+ | `GET /api/v1/tasks/<id>` | One of your tasks (a deleted one too), with its history: the newest runs and changes, as `/task history` shows them. |
302
+ | `PATCH /api/v1/tasks/<id>` | Edits it: the fields to change. A price's page is not editable. |
303
+ | `POST /api/v1/tasks/<id>/pause`, `/resume` | Body `{}`. As the web's Pause and Resume, and `/task resume`. |
304
+ | `DELETE /api/v1/tasks/<id>` | Deletes it as the web does: archived, nothing more sent, history kept. No confirmation step. |
305
+
306
+ A task is `{"id", "type", "title", "status", "cadence", "nextAt", "createdAt", "updatedAt",
307
+ "settings"}`, and a price also has a top-level `url`: `status` is `active`, `paused`, `done` or
308
+ `deleted`; `cadence` is the schedule in words, in your zone; `nextAt` the next run's instant (UTC),
309
+ null when paused or nothing is due; `settings` exactly the fields a `PATCH` takes, as the task has
310
+ them. A price's `url` is read-only -- another page is another tracker -- so it is not in `settings`
311
+ and a `PATCH` naming it is refused (`unknown_field`). A write answers
312
+ `{"task", "message"}`, `message` being the words the web and the command say.
313
+
314
+ **Errors** are `{"error": {"code": "...", "message": "..."}}`:
315
+
316
+ | Status | Code | When |
317
+ |---|---|---|
318
+ | 400 | `invalid` | A rule refused a value; `message` is the rule's own words, as the web editor shows them. Also a field of the wrong JSON type, or no `type`. |
319
+ | 400 | `unknown_field` | A field that type does not take (on a pause or resume, any field). |
320
+ | 400 | `invalid_json` | The body is not JSON, or not one object. |
321
+ | 401 | `unauthorized` | No `Authorization` header. With `WWW-Authenticate: Bearer realm="tracker"`. |
322
+ | 401 | `invalid_token` | A token unknown, revoked or expired, or whose owner is no longer registered (their tokens are then deleted): one answer for all. |
323
+ | 403 | `origin_refused` | The request carried an `Origin` header. |
324
+ | 403 | `not_member` | The owner has left the `TRACKER_GUILD_ID` server; their tokens are revoked. |
325
+ | 404 | `not_found` | No such endpoint, or no such task of yours. Anyone else's task -- one shared with you, or any task to an admin's token -- answers exactly as an unknown id. |
326
+ | 405 | `method_not_allowed` | With `Allow`. |
327
+ | 409 | `conflict` | A finished task edited, or a pause of a task not active (a resume of one not paused). |
328
+ | 409 | `limit_reached` | 200 active or paused tasks, or 20 price trackers, as the commands count them. |
329
+ | 409 | `busy` | Your last new price tracker's page is still being read. |
330
+ | 413 | `too_large` | Body over 16 KiB. |
331
+ | 415 | `unsupported_media_type` | Not `application/json`. |
332
+ | 429 | `rate_limited` | With `Retry-After`: this token's bucket, or the shared one for bad tokens. |
333
+ | 503 | `starting`, `membership_unknown`, `unavailable` | The plugin is starting; the owner's membership could not be checked for 24 hours; the type is not available on this bot. |
334
+
335
+ **Example.**
336
+
337
+ ```
338
+ $ curl -s https://clerk.example.com/tracker/api/v1/tasks \
339
+ -H "Authorization: Bearer $TRACKER_TOKEN" -H "Content-Type: application/json" \
340
+ -d '{"type": "reminder", "text": "water the plants", "when": "tomorrow 9am", "repeat": "week"}'
341
+ {"task":{"id":"t7","type":"reminder","title":"water the plants","status":"active",
342
+ "cadence":"weekly on Fri at 9:00","nextAt":"2026-10-02T13:00:00.000Z",
343
+ "createdAt":"2026-10-01T12:00:00.000Z","updatedAt":"2026-10-01T12:00:00.000Z",
344
+ "settings":{"text":"water the plants","repeat":"week"}},
345
+ "message":"Reminder `t7` set: water the plants\nNext: Fri Oct 2, 9:00, weekly on Fri at 9:00."}
346
+
347
+ $ curl -s -X PATCH https://clerk.example.com/tracker/api/v1/tasks/t7 \
348
+ -H "Authorization: Bearer $TRACKER_TOKEN" -H "Content-Type: application/json" \
349
+ -d '{"repeat": "day"}'
350
+
351
+ $ curl -s -X POST https://clerk.example.com/tracker/api/v1/tasks/t7/pause \
352
+ -H "Authorization: Bearer $TRACKER_TOKEN" -H "Content-Type: application/json" -d '{}'
353
+
354
+ $ curl -s https://clerk.example.com/tracker/api/v1/tasks/t9 -H "Authorization: Bearer $TRACKER_TOKEN"
355
+ {"error":{"code":"not_found","message":"No such task."}}
356
+ ```
357
+
226
358
  ## What it does
227
359
 
228
360
  | Piece | File | Notes |
@@ -237,7 +369,8 @@ The `__Host-` prefix, which would stop that, requires `Path=/` and so cannot be
237
369
  | Commands | `src/discord.ts`, `src/interactions.ts`, `src/discord-common.ts` (discord.js); `src/actions.ts`, `src/press.ts`, `src/history.ts`, `src/access.ts` | The discord.js files read options and render; the rest is Discord-free over the injected store, clock and notifier. Store writes are handled one at a time; Discord lookups and DMs run outside that queue. |
238
370
  | Health | `src/health.ts` | `GET /tracker/healthz` (through the bot's HTTP router): `200` `ok`/`starting`, `503` `inactive`/`stale`/`blocked`. |
239
371
  | Admin, forget-me | `src/admin.ts`, `src/roster.ts`; `src/web/admin.ts`, `src/web/admin-pages.ts` | The admin acts and forget-me, Discord-free; the SQL docket's Store has no method for (listing people and blocks, erasing a person); the routes and pages. |
240
- | Web area | `src/web/` | `app.ts` authenticates and dispatches (pure over the injected store and clock), `routes.ts` names the paths; `pages.ts`, `editor-pages.ts` and `html.ts` render; `editor.ts` reads the editor's forms and calls the shared rules; `signin-link.ts` and `sessions.ts` hold the sign-in; `theme.ts` the stylesheet; `command.ts` is `/web`. |
372
+ | Web area | `src/web/` | `app.ts` authenticates and dispatches (pure over the injected store and clock), `routes.ts` names the paths; `pages.ts`, `editor-pages.ts` and `html.ts` render; `form-input.ts` reads the editor's fields and `editor.ts` calls the shared rules with them; `signin-link.ts` and `sessions.ts` hold the sign-in; `theme.ts` the stylesheet; `command.ts` is `/web`. |
373
+ | Task API | `src/web/api.ts`, `src/web/api-tasks.ts`, `src/web/api-tokens.ts`, `src/web/tokens.ts`, `src/web/token-pages.ts` | The JSON API's authentication, gates and rate limit; its task routes over editor.ts's writes; the tokens (SQL, hashed); the tokens page and its routes. |
241
374
  | Task rules | `src/reminders.ts`, `src/tracked.ts`, `src/price.ts`, `src/edit.ts`, `src/manage.ts`, `src/limits.ts` | What makes, edits, pauses, resumes and deletes a task, and the limits on it, Discord-free, for the commands and the web editor alike. |
242
375
 
243
376
  ## Configuration
@@ -252,8 +385,10 @@ The `__Host-` prefix, which would stop that, requires `Path=/` and so cannot be
252
385
 
253
386
  ## Not yet
254
387
 
255
- - The rest of the web area (E5): sharing a task from the web, the JSON task API, and the admin
256
- view's grants, budgets and retry (with the execute lane). Discord OAuth2 as a second sign-in
388
+ - The rest of the web area (E5): sharing a task from the web, and the admin view's grants, budgets
389
+ and retry (with the execute lane). The task API covers reminders, renewals and prices only --
390
+ not `/task done`, `snooze`, `decide`, `share` or the settings -- and no intake agent uses it yet
391
+ (E10, deferred). Discord OAuth2 as a second sign-in
257
392
  method, if chosen (plan item 41).
258
393
  - The city-hall Executor adapter (the execute lane).
259
394
  - Editing in Discord (the editor is on the web only), and changing a price tracker's page or `near` after it is made (make a new one); a free-form pattern for `price` (`near` is the safe subset: a user's regular expression run on a large page could hang the bot).
package/dist/plugin.js CHANGED
@@ -4115,6 +4115,19 @@ var MIGRATIONS = [
4115
4115
  );
4116
4116
  CREATE INDEX web_sessions_user ON web_sessions (user_id);
4117
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);
4118
4131
  `
4119
4132
  ];
4120
4133
  var BUSY_TIMEOUT_MS = 5000;
@@ -4340,6 +4353,7 @@ class Roster {
4340
4353
  del("delivery_health", "user_id = ?", userId);
4341
4354
  del("web_sessions", "user_id = ?", userId);
4342
4355
  del("web_login_tokens", "user_id = ?", userId);
4356
+ del("api_tokens", "user_id = ?", userId);
4343
4357
  del("users", "seq = ?", Number(seq));
4344
4358
  const held = this.db.query("SELECT 1 FROM delivery_pauses WHERE task_id = ? LIMIT 1");
4345
4359
  const freed = [...new Set(pausedFor)].filter((t) => held.get(t) === null);
@@ -6182,6 +6196,10 @@ ${form.error ? html`<div class="rb-alert rb-alert--danger" role="alert"><p class
6182
6196
  </form>
6183
6197
  </section>
6184
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>
6185
6203
  <h2>Forget me</h2>
6186
6204
  <p>Delete everything the tracker holds about you, for good. <a class="rb-link" href="${v.base}/forget">Forget me</a></p>
6187
6205
  </section>`);
@@ -6335,6 +6353,166 @@ function notice(key, error) {
6335
6353
  return text ? html`<div class="rb-alert rb-alert--success" role="status"><p class="rb-alert__title">${text}</p></div>` : null;
6336
6354
  }
6337
6355
 
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);
6366
+ }
6367
+ function isTokenId(id) {
6368
+ return /^k[1-9][0-9]{0,15}$/.test(id);
6369
+ }
6370
+ function expiryChoice(raw) {
6371
+ const days = Number(raw);
6372
+ return TOKEN_EXPIRY_DAYS.includes(days) ? days : undefined;
6373
+ }
6374
+ var COLUMNS = "seq, user_id, name, created_at, expires_at, last_used_at, member_checked_at";
6375
+ function toToken(r) {
6376
+ return {
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
6384
+ };
6385
+ }
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
+
6338
6516
  // plugins/tracker/src/web/admin-pages.ts
6339
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.";
6340
6518
  var CONFIRM_WORD = "forget";
@@ -6435,7 +6613,7 @@ ${tasks.length === 0 ? html`<p class="rb-muted">There are no tasks.</p>` : html`
6435
6613
  function post2(v, p, action, label, style) {
6436
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>`;
6437
6615
  }
6438
- function personPage(v, p, tasks, now, result = null, configured = false) {
6616
+ function personPage(v, p, tasks, now, result = null, configured = false, tokens = []) {
6439
6617
  return framed(v, nameOf(p), html`<section>
6440
6618
  ${adminNav(v)}
6441
6619
  <h1>${nameOf(p)}${p.admin ? html` <span class="rb-badge">admin</span>` : null}</h1>
@@ -6457,9 +6635,14 @@ ${configured && p.id !== v.user.id ? null : post2(v, p, "forget", "Remove from t
6457
6635
  <section>
6458
6636
  <h2>Their tasks</h2>
6459
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))}
6460
6643
  </section>`);
6461
6644
  }
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.";
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.";
6463
6646
  function forgetPage(v, note) {
6464
6647
  return framed(v, "Forget me", html`<section class="rb-card">
6465
6648
  <h1>Forget me</h1>
@@ -6520,7 +6703,8 @@ async function onePerson(a, id, result = null) {
6520
6703
  if (!p)
6521
6704
  return htmlResponse(notFoundPage(a.v.base, a.v), 404);
6522
6705
  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);
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);
6524
6708
  }
6525
6709
  async function adminGet(a, r) {
6526
6710
  if (r.kind === "admin")
@@ -6554,6 +6738,19 @@ async function adminPost(a, r, form) {
6554
6738
  return unknownPage(base);
6555
6739
  return peoplePage(a, said2(done), done.ok ? 200 : 400);
6556
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
+ }
6557
6754
  if (r.kind !== "admin-act")
6558
6755
  return unknownPage(base);
6559
6756
  if (r.action === "forget") {
@@ -6657,10 +6854,10 @@ async function editReminder(d, user, taskId, input) {
6657
6854
  updated = await rescheduleKeepingSnoozes(d, updated, user, plan.schedule, user.id);
6658
6855
  return { ok: true, task: updated, text: savedText(updated, user, await nextRun(d, updated), d.clock.now(), "(reminder)") };
6659
6856
  }
6660
- var DAY_MS3 = 24 * 60 * 60 * 1000;
6857
+ var DAY_MS4 = 24 * 60 * 60 * 1000;
6661
6858
  function periodDateFrom(s, date, owner) {
6662
6859
  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);
6860
+ let after = new Date(Date.parse(`${date}T00:00:00Z`) - ((s.leadDays ?? 0) + 2) * DAY_MS4);
6664
6861
  for (let i = 0;i < 1000; i++) {
6665
6862
  const due = nextDue(s, after, ctx);
6666
6863
  if (!due)
@@ -6767,15 +6964,7 @@ async function editPrice(d, user, taskId, input) {
6767
6964
  return { ok: true, task: updated, text: savedText(updated, user, await nextRun(d, updated), d.clock.now(), "(price)") };
6768
6965
  }
6769
6966
 
6770
- // plugins/tracker/src/web/editor.ts
6771
- var READING = "I am still reading the page of your last new price tracker; try again when it is done.";
6772
- var GONE = NO_LONGER_LISTED;
6773
- function asViewer(e, fn) {
6774
- return e.queue(async () => {
6775
- const user = await e.d.store.getUser(e.v.user.id);
6776
- return user ? fn(user) : { ok: false, error: GONE };
6777
- });
6778
- }
6967
+ // plugins/tracker/src/web/form-input.ts
6779
6968
  var str2 = (form, name) => form.get(name) ?? "";
6780
6969
  var opt = (form, name) => str2(form, name).trim() === "" ? undefined : str2(form, name);
6781
6970
  function int(form, name) {
@@ -6825,6 +7014,10 @@ function priceSettings(form) {
6825
7014
  ...baseline !== undefined ? { baseline } : {}
6826
7015
  };
6827
7016
  }
7017
+ function priceInput(form) {
7018
+ const near = opt(form, "near");
7019
+ return { url: str2(form, "url"), ...priceSettings(form), ...near !== undefined ? { near } : {} };
7020
+ }
6828
7021
  function reminderEdit(form) {
6829
7022
  const text = opt(form, "text");
6830
7023
  const when = opt(form, "when");
@@ -6854,56 +7047,7 @@ function priceEdit(form) {
6854
7047
  const { name: _name, ...rest } = priceSettings(form);
6855
7048
  return { ...rest, ...form.has("name") ? { name: str2(form, "name") } : {} };
6856
7049
  }
6857
- var DEFAULTS = {
6858
- reminder: { repeat: "none" },
6859
- renewal: { unit: "year", every: "1", lead: "7" },
6860
- price: { hours: "12", drop: "10", baseline: "last" }
6861
- };
6862
- function newGet(e, type) {
6863
- return htmlResponse(newTaskPage(e.v, type, type === "renewal" ? { ...DEFAULTS.renewal, currency: "USD" } : DEFAULTS[type]));
6864
- }
6865
- async function newPost(e, type, form) {
6866
- let made;
6867
- if (type === "reminder")
6868
- made = await asViewer(e, (u) => createReminder(e.d, u, reminderInput(form)));
6869
- else if (type === "renewal")
6870
- made = await asViewer(e, (u) => createRenewal(e.d, u, renewalInput(form)));
6871
- else {
6872
- const near = opt(form, "near");
6873
- const input = { url: str2(form, "url"), ...priceSettings(form), ...near !== undefined ? { near } : {} };
6874
- const who = e.v.user.id;
6875
- if (e.reading.has(who))
6876
- made = { ok: false, error: READING };
6877
- else {
6878
- e.reading.add(who);
6879
- try {
6880
- const started = await asViewer(e, (u) => startPrice(e.d, u, input));
6881
- if (!started.ok)
6882
- made = started;
6883
- else {
6884
- const { pending } = started;
6885
- const seen = await previewPrice(e.d, pending);
6886
- made = await asViewer(e, (u) => finishPrice(e.d, u, pending, seen));
6887
- }
6888
- } finally {
6889
- e.reading.delete(who);
6890
- }
6891
- }
6892
- }
6893
- if (!made.ok)
6894
- return htmlResponse(newTaskPage(e.v, type, typed(type, "new", form), made.error), 400);
6895
- return redirect(`${taskHref(e.v, made.task.id)}?done=created`);
6896
- }
6897
- async function taskPage(e, id, done, error) {
6898
- const view = await loadHistory(e.d, e.v.user, id);
6899
- if (!view)
6900
- return htmlResponse(notFoundPage(e.v.base, e.v), 404);
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);
6905
- }
6906
- function current(d, user, task) {
7050
+ function editValues(d, user, task) {
6907
7051
  const now = d.clock.now();
6908
7052
  if (task.type === "reminder")
6909
7053
  return { text: String(task.config?.text ?? ""), when: "", repeat: repeatOf(task.schedule) };
@@ -6929,28 +7073,93 @@ function current(d, user, task) {
6929
7073
  const every = task.schedule?.kind === "poll" ? task.schedule.every : 12;
6930
7074
  return { name: task.title, hours: String(every), drop: String(c.dropPercent ?? 10), baseline: c.baseline ?? "last" };
6931
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
+ }
6932
7145
  function editPage(e, task, values, error) {
6933
7146
  const now = task.schedule ? describeSchedule(task.schedule, e.v.user, e.v.user.timeZone, e.d.clock.now()) : "no schedule";
6934
7147
  const page = task.type === "price" ? String(task.config.url) : undefined;
6935
7148
  return editTaskPage(e.v, task, values, { now, ...page !== undefined ? { page } : {}, ...error !== undefined ? { error } : {} });
6936
7149
  }
6937
- async function editableTask(e, id) {
6938
- const task = await ownLiveTask(e.d, e.v.user, id);
6939
- return task && (task.type === "reminder" || task.type === "renewal" || task.type === "price") ? task : null;
6940
- }
6941
7150
  async function editGet(e, id) {
6942
- const task = await editableTask(e, id);
7151
+ const task = await editableTask(e.d, e.v.user, id);
6943
7152
  if (!task)
6944
7153
  return htmlResponse(notFoundPage(e.v.base, e.v), 404);
6945
7154
  if (task.status !== "active" && task.status !== "paused")
6946
7155
  return redirect(taskHref(e.v, task.id));
6947
- 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)));
6948
7157
  }
6949
7158
  async function editPost(e, id, form) {
6950
- const task = await editableTask(e, id);
7159
+ const task = await editableTask(e.d, e.v.user, id);
6951
7160
  if (!task)
6952
7161
  return htmlResponse(notFoundPage(e.v.base, e.v), 404);
6953
- 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);
6954
7163
  if (saved.ok)
6955
7164
  return redirect(`${taskHref(e.v, task.id)}?done=saved`);
6956
7165
  if (saved.error === NO_SUCH_TASK)
@@ -6961,10 +7170,9 @@ async function actionPost(e, id, action, form) {
6961
7170
  const task = await ownLiveTask(e.d, e.v.user, id);
6962
7171
  if (!task)
6963
7172
  return htmlResponse(notFoundPage(e.v.base, e.v), 404);
6964
- if (action === "delete" && str2(form, "confirm") !== "yes")
7173
+ if (action === "delete" && form.get("confirm") !== "yes")
6965
7174
  return htmlResponse(confirmDeletePage(e.v, task));
6966
- const act = action === "pause" ? pauseTask : action === "resume" ? resumeTask : deleteTask;
6967
- const done = await asViewer(e, (u) => act(e.d, u, task.id));
7175
+ const done = await actOn(writer(e), task.id, action);
6968
7176
  if (!done.ok)
6969
7177
  return done.error === NO_SUCH_TASK ? htmlResponse(notFoundPage(e.v.base, e.v), 404) : taskPage(e, task.id, null, done.error);
6970
7178
  if (action === "delete")
@@ -6972,6 +7180,418 @@ async function actionPost(e, id, action, form) {
6972
7180
  return redirect(`${taskHref(e.v, task.id)}?done=${action === "pause" ? "paused" : "resumed"}`);
6973
7181
  }
6974
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
+
6975
7595
  // plugins/tracker/src/web/routes.ts
6976
7596
  var TASK_PATH = /^\/tasks\/([^/]+)$/;
6977
7597
  var TASK_ACTION = /^\/tasks\/([^/]+)\/(edit|pause|resume|delete)$/;
@@ -6979,17 +7599,19 @@ var NEW_PATH = /^\/new\/([a-z]+)$/;
6979
7599
  var PERSON_PATH = /^\/admin\/people\/([^/]+)$/;
6980
7600
  var PERSON_ACTION = /^\/admin\/people\/([^/]+)\/(grant|revoke|resume-delivery|forget)$/;
6981
7601
  var BLOCK_LIFT = /^\/admin\/blocks\/([^/]+)\/lift$/;
7602
+ var TOKEN_REVOKE = /^\/tokens\/([^/]+)\/revoke$/;
7603
+ var ADMIN_TOKEN_REVOKE = /^\/admin\/tokens\/([^/]+)\/revoke$/;
6982
7604
  function isAdminRoute(r) {
6983
7605
  return r.kind.startsWith("admin");
6984
7606
  }
6985
- function segment(raw) {
7607
+ function segment2(raw) {
6986
7608
  try {
6987
7609
  return decodeURIComponent(raw ?? "");
6988
7610
  } catch {
6989
7611
  return "";
6990
7612
  }
6991
7613
  }
6992
- function route(path) {
7614
+ function route2(path) {
6993
7615
  if (path === "/")
6994
7616
  return { kind: "tasks" };
6995
7617
  if (path === "/settings")
@@ -6998,6 +7620,14 @@ function route(path) {
6998
7620
  return { kind: "logout" };
6999
7621
  if (path === "/forget")
7000
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]) };
7001
7631
  if (path === "/admin")
7002
7632
  return { kind: "admin" };
7003
7633
  if (path === "/admin/tasks")
@@ -7006,13 +7636,13 @@ function route(path) {
7006
7636
  return { kind: "admin-allow" };
7007
7637
  const personAct = PERSON_ACTION.exec(path);
7008
7638
  if (personAct)
7009
- return { kind: "admin-act", id: segment(personAct[1]), action: personAct[2] };
7639
+ return { kind: "admin-act", id: segment2(personAct[1]), action: personAct[2] };
7010
7640
  const onePerson = PERSON_PATH.exec(path);
7011
7641
  if (onePerson)
7012
- return { kind: "admin-person", id: segment(onePerson[1]) };
7642
+ return { kind: "admin-person", id: segment2(onePerson[1]) };
7013
7643
  const lift = BLOCK_LIFT.exec(path);
7014
7644
  if (lift)
7015
- return { kind: "admin-lift", id: segment(lift[1]) };
7645
+ return { kind: "admin-lift", id: segment2(lift[1]) };
7016
7646
  const created = NEW_PATH.exec(path);
7017
7647
  if (created) {
7018
7648
  const type = created[1];
@@ -7021,7 +7651,7 @@ function route(path) {
7021
7651
  const one = TASK_PATH.exec(path) ?? TASK_ACTION.exec(path);
7022
7652
  if (!one)
7023
7653
  return null;
7024
- const id = segment(one[1]);
7654
+ const id = segment2(one[1]);
7025
7655
  const action = one[2];
7026
7656
  if (action === undefined)
7027
7657
  return { kind: "task", id };
@@ -7030,9 +7660,10 @@ function route(path) {
7030
7660
  return { kind: "act", id, action };
7031
7661
  }
7032
7662
  function methodsOf(r) {
7033
- if (r.kind === "logout" || r.kind === "act" || r.kind === "admin-allow" || r.kind === "admin-act" || r.kind === "admin-lift")
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") {
7034
7664
  return "POST";
7035
- if (r.kind === "settings" || r.kind === "edit" || r.kind === "new" || r.kind === "forget")
7665
+ }
7666
+ if (r.kind === "settings" || r.kind === "edit" || r.kind === "new" || r.kind === "forget" || r.kind === "tokens")
7036
7667
  return "GET, POST";
7037
7668
  return "GET";
7038
7669
  }
@@ -7109,6 +7740,9 @@ function createWebHandler(w) {
7109
7740
  const inFlight = new Map;
7110
7741
  const failedAt = new Map;
7111
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;
7112
7746
  async function authenticate(d, request) {
7113
7747
  const id = readCookie(request, SESSION_COOKIE);
7114
7748
  if (!id)
@@ -7143,14 +7777,14 @@ function createWebHandler(w) {
7143
7777
  const shared = pending;
7144
7778
  return lookupWithin(() => shared, MEMBER_CHECK_TIMEOUT_MS);
7145
7779
  }
7146
- async function recheckMembership(d, session, user) {
7780
+ async function recheck(d, user, checkedAt) {
7147
7781
  if (w.guildId === null)
7148
- return null;
7782
+ return "ok";
7149
7783
  const now = d.clock.now().getTime();
7150
- const age = session.memberCheckedAt ? now - Date.parse(session.memberCheckedAt) : Number.POSITIVE_INFINITY;
7784
+ const age = checkedAt ? now - Date.parse(checkedAt) : Number.POSITIVE_INFINITY;
7151
7785
  const fresh = (limit) => age >= 0 && age < limit;
7152
7786
  if (fresh(MEMBER_RECHECK_MS))
7153
- return null;
7787
+ return "ok";
7154
7788
  const lastFailed = failedAt.get(user.id);
7155
7789
  const backingOff = lastFailed !== undefined && now - lastFailed >= 0 && now - lastFailed < MEMBER_RETRY_MS;
7156
7790
  let found = "unknown";
@@ -7163,14 +7797,23 @@ function createWebHandler(w) {
7163
7797
  }
7164
7798
  if (found === "not-member") {
7165
7799
  d.sessions.deleteForUser(user.id);
7166
- return { kind: "signed-out", note: LEFT_SERVER };
7800
+ d.apiTokens.deleteForUser(user.id);
7801
+ return "not-member";
7167
7802
  }
7168
7803
  if (found === "member") {
7169
- d.sessions.confirmMember(user.id, d.clock.now().toISOString());
7170
- 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";
7171
7808
  }
7172
- 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")
7173
7814
  return null;
7815
+ if (found === "not-member")
7816
+ return { kind: "signed-out", note: LEFT_SERVER };
7174
7817
  d.sessions.deleteForUser(user.id);
7175
7818
  return { kind: "signed-out", note: RECHECK_FAILED };
7176
7819
  }
@@ -7235,6 +7878,14 @@ function createWebHandler(w) {
7235
7878
  return redirect(`${base}/settings?saved=1`);
7236
7879
  }
7237
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
+ }
7238
7889
  const method = request.method;
7239
7890
  if (method !== "GET" && method !== "POST")
7240
7891
  return plain(405, "Method not allowed", { Allow: "GET, POST" });
@@ -7260,7 +7911,7 @@ function createWebHandler(w) {
7260
7911
  return only("GET") ?? htmlResponse(signInHelpPage(base, signInNote(url.searchParams)));
7261
7912
  if (path === "/login")
7262
7913
  return method === "GET" ? loginGet(d, url) : loginPost(d, request);
7263
- const r = route(path);
7914
+ const r = route2(path);
7264
7915
  if (r === null)
7265
7916
  return htmlResponse(notFoundPage(base), 404);
7266
7917
  const allowed = methodsOf(r);
@@ -7299,9 +7950,14 @@ function createWebHandler(w) {
7299
7950
  return actionPost(e, r.id, r.action, form);
7300
7951
  case "forget":
7301
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);
7302
7957
  case "admin-allow":
7303
7958
  case "admin-act":
7304
7959
  case "admin-lift":
7960
+ case "admin-token-revoke":
7305
7961
  return adminPost(a, r, form);
7306
7962
  default:
7307
7963
  return plain(405, "Method not allowed", { Allow: allowed });
@@ -7320,6 +7976,8 @@ function createWebHandler(w) {
7320
7976
  return newGet(e, r.type);
7321
7977
  case "forget":
7322
7978
  return forgetRoute(a, method, null);
7979
+ case "tokens":
7980
+ return tokensGet(d, v);
7323
7981
  case "admin":
7324
7982
  case "admin-tasks":
7325
7983
  case "admin-person":
@@ -7526,6 +8184,7 @@ function createPlugin(host, options = {}) {
7526
8184
  notifier: dm ? createDmNotifier({ store: openedStore, claims: openedClaims, dm, clock, log: host.log, health: delivery }) : NO_DM,
7527
8185
  logins: new LoginLinks(opened),
7528
8186
  sessions: new Sessions(opened),
8187
+ apiTokens: new ApiTokens(opened),
7529
8188
  roster: new Roster(opened),
7530
8189
  configuredAdmins: new Set(adminIds),
7531
8190
  lanes,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rackbops/plugin-tracker",
3
- "version": "0.6.0",
4
- "description": "The task tracker for Rackbops Clerk: reminders, renewals and price drops by slash command, delivered by DM with buttons, on docket's SQLite store (/allow, /register, /remind, /renewal, /price, /tasks, /task, /settings, /web), with a web area for your tasks, history and settings, a web editor to make, change, pause and delete your own tasks, an admin view, and forget-me",
3
+ "version": "0.7.0",
4
+ "description": "The task tracker for Rackbops Clerk: reminders, renewals and price drops by slash command, delivered by DM with buttons, on docket's SQLite store (/allow, /register, /remind, /renewal, /price, /tasks, /task, /settings, /web), with a web area for your tasks, history and settings, a web editor to make, change, pause and delete your own tasks, an admin view, forget-me, and a JSON task API with personal API tokens",
5
5
  "type": "module",
6
6
  "main": "dist/plugin.js",
7
7
  "files": ["dist"],