@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.
- package/README.md +145 -10
- package/dist/plugin.js +751 -92
- 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 --
|
|
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
|
|
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
|
|
203
|
-
schema
|
|
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; `
|
|
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
|
|
256
|
-
|
|
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 <token></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
|
|
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
|
-
|
|
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
|
|
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) *
|
|
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/
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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" &&
|
|
7173
|
+
if (action === "delete" && form.get("confirm") !== "yes")
|
|
6965
7174
|
return htmlResponse(confirmDeletePage(e.v, task));
|
|
6966
|
-
const
|
|
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
|
|
7607
|
+
function segment2(raw) {
|
|
6986
7608
|
try {
|
|
6987
7609
|
return decodeURIComponent(raw ?? "");
|
|
6988
7610
|
} catch {
|
|
6989
7611
|
return "";
|
|
6990
7612
|
}
|
|
6991
7613
|
}
|
|
6992
|
-
function
|
|
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:
|
|
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:
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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
|
|
7780
|
+
async function recheck(d, user, checkedAt) {
|
|
7147
7781
|
if (w.guildId === null)
|
|
7148
|
-
return
|
|
7782
|
+
return "ok";
|
|
7149
7783
|
const now = d.clock.now().getTime();
|
|
7150
|
-
const age =
|
|
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
|
|
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
|
-
|
|
7800
|
+
d.apiTokens.deleteForUser(user.id);
|
|
7801
|
+
return "not-member";
|
|
7167
7802
|
}
|
|
7168
7803
|
if (found === "member") {
|
|
7169
|
-
|
|
7170
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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,
|
|
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"],
|