@rackbops/plugin-tracker 0.6.0 → 0.8.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 +154 -19
  2. package/dist/plugin.js +783 -110
  3. package/package.json +4 -4
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
 
@@ -32,10 +33,10 @@ as in a server (plan 5.5, item 39).
32
33
  | `/settings hour hour` | registered | The preferred hour; recurring reminders with no time of their own move to it. |
33
34
  | `/web` | registered | A one-time link to sign in to the web area (below), good for 10 minutes and one use. Says so when `TRACKER_WEB_URL` is unset. |
34
35
 
35
- **Gates.** Every command and button checks, in order: membership of the `TRACKER_GUILD_ID`
36
- server (when set), admission (in the tracker's store), then what the action needs (an admin for
37
- `/allow`, admission for `/register`, registration for the rest). A person nobody admitted is told
38
- to ask an admin to `/allow` them. Decline and opt-out skip the membership and registration gates,
36
+ **Gates.** Every command and button checks, in order: membership of a `TRACKER_GUILD_ID`
37
+ server (when set; a member of any listed server passes), admission (in the tracker's store), then
38
+ what the action needs (an admin for `/allow`, admission for `/register`, registration for the
39
+ rest). A person nobody admitted is told to ask an admin to `/allow` them. Decline and opt-out skip the membership and registration gates,
39
40
  so anyone can always stop the messages; the Reply button checks membership when its modal is sent.
40
41
 
41
42
  **Buttons.** The owner's DM carries Done, Snooze 1h and Reply; a recipient's copy carries the
@@ -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
 
@@ -127,10 +131,10 @@ other than GET or POST (or the wrong one of the two for a path) is 405.
127
131
 
128
132
  **Membership on the web.** With `TRACKER_GUILD_ID` set, `/web` issues a link only to a member,
129
133
  and the session remembers when that was confirmed. A web request more than 15 minutes after the
130
- last confirmation re-checks with one member lookup (about 3 seconds at most, outside the write
131
- queue), through the discord.js Client of an interaction the plugin has handled since it started
132
- (the host API has no member lookup of its own; the Client is held in memory, never stored).
133
- Not a member: every session of theirs ends, on a page that says why. A member: the time is
134
+ last confirmation re-checks membership (one lookup per listed server, all at once, the first yes enough; about 3
135
+ seconds at most, outside the write queue), through the discord.js Client of an interaction the
136
+ plugin has handled since it started (the host API has no member lookup of its own; the Client is
137
+ held in memory, never stored). Not a member of any listed server: every session of theirs ends, on a page that says why. A member: the time is
134
138
  refreshed. A failed or slow lookup, or no interaction yet since a restart: they stay signed in
135
139
  while the last confirmation is under 24 hours old, and are then signed out and told to run `/web`
136
140
  again. Concurrent requests share one lookup per person, and after a failed one that person is not
@@ -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 check, shared with the web), and one who
260
+ has left every listed server loses every token and every session; a lookup that keeps failing
261
+ lets them on 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 every `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
@@ -245,15 +378,17 @@ The `__Host-` prefix, which would stop that, requires `Path=/` and so cannot be
245
378
  | Env key | Secret | Meaning |
246
379
  |---|---|---|
247
380
  | `TRACKER_ADMIN_DISCORD_IDS` | no | Comma-separated Discord user ids (spaces around commas allowed; an empty entry, as from a trailing comma, is refused and the plugin does not load) made admin at start. Unset = none. Removing an id does not revoke it. |
248
- | `TRACKER_GUILD_ID` | no | The Discord server whose members may use the tracker. Checked through the interaction's client with a single-member lookup (no privileged intent). Unset = no membership gate, and a warning is logged each time the plugin activates; a malformed value refuses to load. |
381
+ | `TRACKER_GUILD_ID` | no | The Discord server whose members may use the tracker, or a comma-separated list of them (spaces around commas allowed; a repeated id counts once): a member of any listed server passes. Checked through the interaction's client with a single-member lookup per server (no privileged intent), all asked at once; a yes from any server is a member, a no from every server is not, and otherwise the answer is unknown (refused, never revoked). Inside a listed server, the person running a command needs no lookup. Unset = no membership gate, and a warning is logged each time the plugin activates; a malformed or empty entry anywhere in the list refuses to load, naming it. One store and one admission list serve every listed server; there are no per-server admins. |
249
382
  | `TRACKER_WEB_URL` | no | The https origin the bot's HTTP is reached at through its tunnel, e.g. `https://clerk.example.com` (no path). `/web` links and the allowed `Origin` come from it, never from a request's `Host`. Unset = no web area (`/web` says so, the pages answer 404); anything but a bare https origin refuses to load. |
250
383
 
251
384
  `/tracker/healthz` and the web area need the bot's `HTTP_PORT` set; without it there is no HTTP at all.
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).