@rackbops/plugin-tracker 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +229 -8
  2. package/dist/plugin.js +1647 -290
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -7,8 +7,10 @@ on a rackbops-discord-bot instance. Plan of record: Rackbops/Tooling
7
7
  command, delivered by DM, answered by button, with history -- the first slice of the web area
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
- city-hall -- and 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.
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 -- 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).
12
14
 
13
15
  ## Commands
14
16
 
@@ -64,7 +66,15 @@ served at a hashed path, cached for a year).
64
66
  | `/new/reminder`, `/new/renewal`, `/new/price` | The editor's new-task forms (GET), and making one (POST). |
65
67
  | `/tasks/<id>/edit` | The owner's edit form (GET) and saving it (POST). |
66
68
  | `/tasks/<id>/pause`, `/resume`, `/delete` | POST only. Delete answers a confirmation first; only a second post carrying `confirm=yes` deletes. |
67
- | `/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. |
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. |
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. |
74
+ | `/admin/tasks` | Admins only: every task in the store, with its owner, status and how many receive it, each linking to its page. |
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. |
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. |
68
78
  | `/signin` | Where a request that is not signed in is sent: says to run `/web`. |
69
79
  | `/login?t=...` | The link `/web` gives. |
70
80
 
@@ -130,7 +140,89 @@ while the last confirmation is under 24 hours old, and are then signed out and t
130
140
  again. Concurrent requests share one lookup per person, and after a failed one that person is not
131
141
  looked up again for a minute, so an outage or rate limit does not pile up calls. A confirmation
132
142
  time in the future (a clock set back) counts as stale. Every lookup asks Discord (`force: true`),
133
- 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).
143
+ never discord.js's member cache, which with only the Guilds intent never learns that someone left.
144
+
145
+ **The admin view** (slice 3, plan 5.8, 5.10). An admin is a person whose row in the tracker's store
146
+ has the admin flag -- the one admin definition; `/allow` checks the same flag. `TRACKER_ADMIN_DISCORD_IDS`
147
+ grants it at start; admins grant and revoke it here. The flag is read from the store on every
148
+ request, and again inside the write queue before any admin act, so an admin whose flag is revoked is
149
+ refused on their very next request. To a signed-in person who is not an admin, every `/admin` path --
150
+ any method -- answers the same 404, byte for byte, as an unknown page; a visitor who is not signed in
151
+ is sent to sign in, as on every other signed-in path. An admin sees every person, every task
152
+ and every task's page and history, read-only: an admin never edits, pauses or deletes another
153
+ person's task (the owner's acts answer them 404, as in slice 2). What an admin can do, each through
154
+ the function the command path runs:
155
+
156
+ - **Allow** a person by Discord id: `/allow`'s own `allowPerson`, with the same server-membership
157
+ check when `TRACKER_GUILD_ID` is set (one bounded lookup; no Discord client yet since a restart
158
+ refuses, as an unknown answer does). A form cannot tell a bot's id from a person's; a bot's row can
159
+ never `/register`.
160
+ - **Make admin / Revoke admin** (plan 5.8). The last admin can be neither revoked nor removed. An
161
+ admin who revokes themselves lands on My tasks. Each change is logged (`u1 made u2 an admin`).
162
+ An admin named in `TRACKER_ADMIN_DISCORD_IDS` is made an admin again at every start, so their page
163
+ offers neither Revoke nor Remove, and both are refused: take them out of the configuration first.
164
+ Such an admin may still forget themselves (if another admin is left); the pages say that the next
165
+ start makes them again, as a new person, unless the configuration no longer names them.
166
+ - **Resume delivery** of a person paused after failed DMs: `DeliveryHealth.resume`, what the
167
+ person's own next command does -- the count clears and the tasks nothing else holds go back on.
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.
171
+ - **Remove from the tracker**: forget-me for that person (below), with the same confirmation.
172
+
173
+ Not here yet, though plan 5.10 lists them: grants, budgets and retry (they belong to the execute
174
+ lane, which is not built) and an admin pause of someone else's task.
175
+
176
+ **Forget me** (plan 5.8). A signed-in person erases themselves from `/forget` (web only: the plan
177
+ asks for no Discord command, so without `TRACKER_WEB_URL` the only path is an admin with the web
178
+ area). Two posts: the first asks, the second must carry `confirm=yes` and the word `forget`. Then,
179
+ in one SQLite transaction in the write queue, **deleted, not archived**:
180
+
181
+ - every task they own, archived ones too, with everything under it: runs, run events, replies
182
+ (anyone's), history, series, recipients, delivery claims and pauses;
183
+ - on everyone else's tasks: their recipient rows, their replies and answers, the history rows they
184
+ made or that name them (an invitation of them, a pause for them), the run events and delivery
185
+ claims of DMs to them, and their pause rows;
186
+ - every decline block they are either side of;
187
+ - their admission, delivery health, web sessions, unused sign-in links and API tokens, and their
188
+ person row.
189
+
190
+ What stays, and why: another person's task that they received stays that owner's; one paused only
191
+ because DMs to them failed goes back on. Another person's row keeps its own audit fact but not their
192
+ id -- `admitted_by` of someone they admitted, and `lifted_by` of a block they lifted, become
193
+ `forgotten` ("an admin since forgotten"; null already means "the configuration"). Text is matched
194
+ only in the forms the code writes a person's id into -- docket's consent rows (`u5`, `u5 24h`), the
195
+ tracker's `u5: ...` removals and `delivery to {u5} paused` pauses, docket's `u5 <message>` delivery
196
+ events, and the notifier's error phrases (`recipient u5 cannot be messaged`, ...) -- never as any
197
+ id-like word, so an address with `/u5/` in someone else's text is left alone. A history row or a
198
+ delivery event of theirs is deleted; the error of someone else's run keeps its row and has the id
199
+ replaced with `(forgotten)`. A pause for a recipient now names them by id and shows their current
200
+ name when read; a pause row written before 0.6.0 names the recipient's display name of the time,
201
+ and is deleted when it is on a task they were a recipient of, names their current name, and no
202
+ other recipient of that task has that name. A row written under an older name of theirs, or shared
203
+ with a same-named recipient, is kept: the one known gap. A reply other people wrote on the erased
204
+ person's own tasks is deleted with those tasks. Outside the store, nothing is touched: the DMs the
205
+ bot sent stay in the person's Discord DMs, and the host's log may hold their tracker id.
206
+
207
+ Bytes: the database runs with `secure_delete` on, so a deleted row is overwritten, and the first
208
+ start of 0.6.0 on an existing database runs one `VACUUM`, so pages freed before that are rewritten
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
211
+ reader holds the file), the start goes on, a warning is logged, and the next start tries again.
212
+ Not a VACUUM per erasure: it rewrites the whole file under an
213
+ exclusive lock, and `secure_delete` already covers every later delete. After an erasure the
214
+ write-ahead log is checkpointed without waiting; when a reader keeps it busy, that is logged and
215
+ SQLite's next checkpoint copies it back.
216
+
217
+ Timing: forget-me first waits, outside the write queue, until no notify or poll tick is running (at
218
+ most 20 seconds; then it answers 503, try again, and deletes nothing), so a reminder DM to them
219
+ already in flight cannot be recorded after they are gone -- and no one else's command waits behind
220
+ it. Inside the queue it only checks that no tick started meanwhile (else the same 503). A DM sent
221
+ outside any tick -- an invitation's -- that fails after they are gone writes nothing: the failure
222
+ count is not kept for a person no longer in the store, and the withdrawn invitation is not
223
+ recorded. A `/price` whose page is read while its person is forgotten makes nothing. Their sessions
224
+ are among the rows, so they are signed out everywhere at once, and the browser's cookie is cleared. They can come back only as someone new: an admin
225
+ `/allow`s them again, and the tracker never reuses an id.
134
226
 
135
227
  **Choose the web origin's domain with care.** A host under the same parent domain as
136
228
  `TRACKER_WEB_URL` that you do not control can set a `__Secure-` cookie on the parent domain
@@ -138,6 +230,131 @@ never discord.js's member cache, which with only the Guilds intent never learns
138
230
  The `__Host-` prefix, which would stop that, requires `Path=/` and so cannot be used with
139
231
  `Path=/tracker/`. Put the bot on a host whose sibling subdomains are all yours.
140
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
+
141
358
  ## What it does
142
359
 
143
360
  | Piece | File | Notes |
@@ -151,7 +368,9 @@ The `__Host-` prefix, which would stop that, requires `Path=/` and so cannot be
151
368
  | 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. |
152
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. |
153
370
  | Health | `src/health.ts` | `GET /tracker/healthz` (through the bot's HTTP router): `200` `ok`/`starting`, `503` `inactive`/`stale`/`blocked`. |
154
- | 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`. |
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. |
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. |
155
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. |
156
375
 
157
376
  ## Configuration
@@ -166,9 +385,11 @@ The `__Host-` prefix, which would stop that, requires `Path=/` and so cannot be
166
385
 
167
386
  ## Not yet
168
387
 
169
- - The rest of the web area (E5): the admin view, lifting a decline block, forget-me (which would
170
- erase a deleted task), sharing a task from the web, and the JSON task API. Until forget-me and the admin view exist, nobody can be taken off the
171
- 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).
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
392
+ method, if chosen (plan item 41).
172
393
  - The city-hall Executor adapter (the execute lane).
173
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).
174
395
  - An optional Discord-role gate (plan 5.5), and showing unconfirmed deliveries to admins anywhere but the log.