@rackbops/plugin-tracker 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +83 -10
  2. package/dist/plugin.js +2715 -244
  3. package/package.json +12 -4
package/README.md CHANGED
@@ -2,9 +2,12 @@
2
2
 
3
3
  The task tracker for Rackbops Clerk: the host of [`Rackbops/docket`](https://github.com/Rackbops/docket)
4
4
  on a rackbops-discord-bot instance. Plan of record: Rackbops/Tooling
5
- `research/city-hall-task-tracker.md` (sections 0, 5.1-5.3, 5.5, 5.8). This version is the core
6
- (rackbops-bot-plugins#78) and the Discord surface of the first slice (#79): reminders by slash
7
- command, delivered by DM, answered by button, with history.
5
+ `research/city-hall-task-tracker.md` (sections 0, 5.1-5.3, 5.5, 5.8, 5.10). This version is the core
6
+ (rackbops-bot-plugins#78), the Discord surface of the first slice (#79) -- reminders by slash
7
+ command, delivered by DM, answered by button, with history -- the first slice of the web area
8
+ (#80): sign-in by one-time link, my tasks, a task's history, and settings -- and renewals and
9
+ the price tracker (#81, plan E6): two more types on the notify side, no model, nothing sent to
10
+ city-hall.
8
11
 
9
12
  ## Commands
10
13
 
@@ -16,12 +19,16 @@ as in a server (plan 5.5, item 39).
16
19
  | `/allow user` | a tracker admin | Admits a person: they can now `/register`. |
17
20
  | `/register [hour] [zone]` | an admitted person | Signs up, or changes the preferred hour (0-23, default 9) and time zone (default `America/New_York`). The reply says an admin can see every task. |
18
21
  | `/remind text [when] [repeat]` | a registered person | A reminder by DM. `when` is docket's `parseWhen` grammar (`in 20 minutes`, `tomorrow 9am`, `fri at 17:30`); `repeat` is once (the default), daily, weekly or monthly. A repeating one with no `when` starts today at the preferred hour. |
22
+ | `/renewal name amount currency renews [unit] [every] [lead] [note]` | registered | A subscription, domain, warranty or membership (docket's `renewal`, a `period` schedule). `renews` is the next renewal or expiry date, `YYYY-MM-DD`, today or later; `unit` yearly (the default), monthly, weekly or daily, `every` how many of those; the ask comes `lead` days before (default 7) at the preferred hour, with Keep, Cancel, Renewed and Snooze. If that ask is already past but the date is not, the first ask comes within a minute (a zone or hour change before it fires drops it for the next period's). Keep and Renewed record what was paid; Cancel ends it. |
23
+ | `/price url [name] [hours] [drop] [baseline] [near]` | registered | A price (docket's `price`, a `poll` schedule every `hours`, default 12, at most 168). The page is read once at once, and nothing is created unless a price is found in it; then the first check, within a minute, DMs the starting price, and a drop of `drop` percent or more (default 10) from the `baseline` -- the last (default), first or highest price seen -- DMs the owner once per crossing. `near` is the words just before the price, for a page with no structured price. At most 20 per person. |
19
24
  | `/tasks` | registered | Your active tasks and the ones you receive, each with its next run; then your paused ones. |
20
25
  | `/task done task` / `/task snooze task [until]` | the task's owner | Answers the task's latest reminder (snooze: an hour, or until `until`). |
21
- | `/task history task` | the owner, an accepted recipient, or an admin | Every run -- due, status, how it was answered, text replies -- and the task's changes. |
26
+ | `/task decide task choice [amount]` | the renewal's owner | Answers the renewal's latest ask -- keep, cancel or renewed -- with the amount actually paid when it changed (a button cannot carry one). Use it instead of the button, not after it: a run is answered once. |
27
+ | `/task history task` | the owner, an accepted recipient, or an admin | Every run -- due, status, how it was answered, text replies -- and the task's changes; for a renewal, what each period cost and the total; for a price, the last check and the readings with the low and the high. |
22
28
  | `/task share task user` | the task's owner | Invites an admitted, registered member who can be DMed: they get one consent DM with accept and decline. |
23
29
  | `/task resume task` | the task's owner | Resumes a task paused because a recipient could not be DMed, taking that recipient off it. |
24
30
  | `/settings hour hour` | registered | The preferred hour; recurring reminders with no time of their own move to it. |
31
+ | `/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. |
25
32
 
26
33
  **Gates.** Every command and button checks, in order: membership of the `TRACKER_GUILD_ID`
27
34
  server (when set), admission (in the tracker's store), then what the action needs (an admin for
@@ -42,17 +49,78 @@ resumes when the recipient next uses the tracker, or `/task resume` goes on with
42
49
  paused for their own DMs is told, and resumed, the next time they use a command or button. A DM
43
50
  that goes through clears the count.
44
51
 
52
+ ## Web area
53
+
54
+ Served under `/tracker/` on the bot's own HTTP (it needs `HTTP_PORT`) and reached from outside
55
+ only through the instance's tunnel, at `TRACKER_WEB_URL`. Server-rendered HTML with forms and no
56
+ script, styled with `@rackbops/styles`' rackbops-noir theme (bundled into `dist/plugin.js` and
57
+ served at a hashed path, cached for a year).
58
+
59
+ | Path | What |
60
+ |---|---|
61
+ | `/` | My tasks: the same list as `/tasks` (active tasks owned and received, next run in your zone; paused ones and why), each linking to its history. |
62
+ | `/tasks/<id>` | A task's history: the same as `/task history`, for the owner, an accepted recipient or an admin. Anyone else gets the same 404 as an unknown id. |
63
+ | `/settings` | Preferred hour and time zone, checked as `/register` checks them. |
64
+ | `/signin` | Where a request that is not signed in is sent: says to run `/web`. |
65
+ | `/login?t=...` | The link `/web` gives. |
66
+
67
+ **Signing in** (plan item 41; the one-time link is the method for now, kept in
68
+ `src/web/signin-link.ts` so another, such as Discord OAuth2, can sit beside it and feed the same
69
+ sessions). `/web` answers, ephemerally, `<TRACKER_WEB_URL>/tracker/login?t=<token>`. Opening it
70
+ does not use it up -- a link preview or a prefetcher would otherwise burn it -- it shows a Sign in
71
+ button; pressing it uses the token (once, within 10 minutes), starts a session and redirects to
72
+ `/tracker/` so the token leaves the address bar. The login pages send `Referrer-Policy:
73
+ same-origin` and `Cache-Control: no-store` (every page does). Not `no-referrer`: under it a browser
74
+ sends a form post's `Origin` as `null`, which the Origin check refuses, so no form would work; the
75
+ pages link nowhere else, so the token never leaves in a Referer either way.
76
+
77
+ **Sessions and forms.** The session cookie is `__Secure-tracker-session`, `HttpOnly; Secure;
78
+ SameSite=Lax; Path=/tracker/`, for 7 days from sign-in. The store keeps only SHA-256 hashes of
79
+ link tokens and session ids. Every request re-reads the person: one no longer in the tracker's
80
+ store, or no longer registered, is signed out of every session. Every state change is a POST
81
+ carrying the session's CSRF token (compared in constant time), and a POST with an `Origin` other
82
+ than `TRACKER_WEB_URL`'s is refused. The sign-in post has no session yet, so it carries a
83
+ double-submit token from a `SameSite=Strict` cookie the link's page sets: another site cannot sign
84
+ you in as someone else. Sign out is a POST too.
85
+
86
+ **Headers.** `Content-Security-Policy: default-src 'none'; style-src 'self'; img-src 'none';
87
+ form-action 'self'; frame-ancestors 'none'; base-uri 'none'`, `X-Content-Type-Options: nosniff`,
88
+ `X-Frame-Options: DENY`. Every value on a page is HTML-escaped. An unknown path is 404; a method
89
+ other than GET or POST (or the wrong one of the two for a path) is 405.
90
+
91
+ **Membership on the web.** With `TRACKER_GUILD_ID` set, `/web` issues a link only to a member,
92
+ and the session remembers when that was confirmed. A web request more than 15 minutes after the
93
+ last confirmation re-checks with one member lookup (about 3 seconds at most, outside the write
94
+ queue), through the discord.js Client of an interaction the plugin has handled since it started
95
+ (the host API has no member lookup of its own; the Client is held in memory, never stored).
96
+ Not a member: every session of theirs ends, on a page that says why. A member: the time is
97
+ refreshed. A failed or slow lookup, or no interaction yet since a restart: they stay signed in
98
+ while the last confirmation is under 24 hours old, and are then signed out and told to run `/web`
99
+ again. Concurrent requests share one lookup per person, and after a failed one that person is not
100
+ looked up again for a minute, so an outage or rate limit does not pile up calls. A confirmation
101
+ time in the future (a clock set back) counts as stale. Every lookup asks Discord (`force: true`),
102
+ never discord.js's member cache, which with only the Guilds intent never learns that someone left. There is no way yet to take a person off the tracker (forget-me and the admin view, below).
103
+
104
+ **Choose the web origin's domain with care.** A host under the same parent domain as
105
+ `TRACKER_WEB_URL` that you do not control can set a `__Secure-` cookie on the parent domain
106
+ (cookie tossing), and so plant its own session or sign-in cookie in your browser: a login-CSRF.
107
+ The `__Host-` prefix, which would stop that, requires `Path=/` and so cannot be used with
108
+ `Path=/tracker/`. Put the bot on a host whose sibling subdomains are all yours.
109
+
45
110
  ## What it does
46
111
 
47
112
  | Piece | File | Notes |
48
113
  |---|---|---|
49
114
  | Store | `src/store.ts`, `src/schema.ts` | docket's `Store` port on `bun:sqlite`, in `<dataDir>/tracker/tracker.sqlite` (WAL). A Discord id belongs to at most one user. Schema versioned by `PRAGMA user_version`; a shipped migration is never edited. `store.test.ts` runs docket's `STORE_CONTRACT` against it. |
50
- | Notify lane | `src/notify-lane.ts` | docket's `Lanes.tickNotify` on the host tick `notify` (every 60 s), through a view of the store that skips a paused task's due runs. Registers only `reminder` and `renewal`. The execute lane is not ticked. |
115
+ | Notify lane | `src/notify-lane.ts` | docket's `Lanes.tickNotify` on two host ticks, every 60 s, through a view of the store that skips a paused task's due runs: `notify` runs every type but the page readers, `poll` runs only them (`price`) with the Fetch port, so a slow page never holds up a reminder. Registers `reminder`, `renewal` and `price`. The execute lane is not ticked. |
116
+ | Page reads | `src/fetch.ts` | docket's `Fetch` port for `price`: http or https on the default port, no credentials, every resolved address public (no loopback, private, link-local, CGNAT, multicast or reserved range, IPv4 or IPv6), redirects followed by hand and re-checked (at most 5), 15 s including the name lookup, at most 3 MB kept. The body is then rebuilt in linear time (`src/page.ts`) to just what extraction reads -- JSON-LD, meta tags, and the page with every `<` blanked, so `near` still reads text, attributes and script data -- because docket's extraction patterns take quadratic time on a page of unclosed tags. A read the tick's abort cuts short requeues its run instead of counting a miss. A DNS answer that changes between the check and the read is not caught here. |
117
+ | Renewals, prices | `src/tracked.ts`, `src/series.ts`, `src/page.ts` | `/renewal`, `/price` and `/task decide`; the series lines of `/task history`. The series (docket's `series` table, schema 1) holds a renewal's paid amounts and a price's readings. |
51
118
  | Delivery | `src/notifier.ts`, `src/claims.ts`, `src/buttons.ts` | docket's `Notifier` over `host.dm`, with the buttons. Each (occurrence, person) is claimed in `delivery_claims` before the DM is sent and settled after; a claim never settled is not resent -- it is logged once, as unconfirmed. |
52
119
  | Pause | `src/delivery-health.ts` | The per-person failure count, the pause and the resume. |
53
120
  | People | `src/people.ts`, `src/admissions.ts` | Discord id, time zone, preferred hour, admin flag -- in the tracker's store, never usr; who admitted each person and when they registered. `TRACKER_ADMIN_DISCORD_IDS` only ever grants admin. |
54
121
  | 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. |
55
122
  | Health | `src/health.ts` | `GET /tracker/healthz` (through the bot's HTTP router): `200` `ok`/`starting`, `503` `inactive`/`stale`/`blocked`. |
123
+ | Web area | `src/web/` | `app.ts` routes (pure over the injected store and clock); `pages.ts` and `html.ts` render; `signin-link.ts` and `sessions.ts` hold the sign-in; `theme.ts` the stylesheet; `command.ts` is `/web`. |
56
124
 
57
125
  ## Configuration
58
126
 
@@ -60,14 +128,19 @@ that goes through clears the count.
60
128
  |---|---|---|
61
129
  | `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. |
62
130
  | `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. |
131
+ | `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. |
63
132
 
64
- `/tracker/healthz` needs the bot's `HTTP_PORT` set; without it there is no HTTP at all.
133
+ `/tracker/healthz` and the web area need the bot's `HTTP_PORT` set; without it there is no HTTP at all.
65
134
 
66
135
  ## Not yet
67
136
 
68
- - The web area (E5): editing tasks, the admin view, lifting a decline block, forget-me.
69
- - The `Fetch` port (then `price` is registered) and the city-hall Executor adapter (the execute lane).
137
+ - The rest of the web area (E5): editing tasks, the admin view, lifting a decline block, forget-me,
138
+ and the JSON task API. Until forget-me and the admin view exist, nobody can be taken off the
139
+ tracker, so a web session ends only by sign-out, expiry, or the membership re-check. Discord OAuth2 as a second sign-in method, if chosen (plan item 41).
140
+ - The city-hall Executor adapter (the execute lane).
141
+ - Editing a renewal or a price tracker after it is made (for now: `/task done`, or cancel, and make it again); 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).
70
142
  - An optional Discord-role gate (plan 5.5), and showing unconfirmed deliveries to admins anywhere but the log.
71
143
 
72
- docket-core and docket-types are `devDependencies`: `bun build` bundles them into `dist/plugin.js`,
73
- and the bot loads that file without installing anything.
144
+ docket-core, docket-types and `@rackbops/styles` are `devDependencies`: `bun build` bundles them
145
+ into `dist/plugin.js` (the theme's CSS as text), and the bot loads that file without installing
146
+ anything.