@rackbops/plugin-tracker 0.19.0 → 0.23.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 +51 -5
  2. package/dist/plugin.js +630 -22
  3. package/package.json +22 -1
package/README.md CHANGED
@@ -37,8 +37,8 @@ as in a server (plan 5.5, item 39).
37
37
 
38
38
  | Command | Who | What |
39
39
  |---|---|---|
40
- | `/allow user` | a tracker admin | Admits a person: they can now `/register`. |
41
- | `/register [hour] [zone]` | an admitted person | Signs up, or changes the preferred hour (0-23, default 9) and time zone (default `America/New_York`). The reply says an admin can see every task. |
40
+ | `/allow user` | a tracker admin | Admits a person: they can now `/register`. With the usr link on (0.21.0), run in a server, it also allows them in usr as `tracker:member` and keeps their usr user id. The admin must be linked in usr and hold `tracker:register` and `tracker:member` there. Run again, it asks usr again (usr only ever adds), so it links someone not yet linked and relinks someone whose usr link was made anew. A usr refusal or outage leaves the admission standing and says what to do; in a DM it says to run it in the server. |
41
+ | `/register [hour] [zone]` | an admitted person | Signs up, or changes the preferred hour (0-23, default 9) and time zone (default `America/New_York`). The reply says an admin can see every task. With the usr link on (0.21.0), run in a server, it also carries a linked person's one-time usr sign-up link, until usr says they have signed up; in a DM it says to run it in the server. If usr says they are not allowed yet (a usr admin removed their link), it forgets their link and says to ask for `/allow` again. See **First admin and usr** below. |
42
42
  | `/remind text [when] [repeat]` | a registered person | A reminder by DM. `when` is docket's `parseWhen` grammar (`in 20 minutes`, `tomorrow 9am`, `fri at 17:30`); `repeat` is once (the default), daily, weekly or monthly. A repeating one with no `when` starts today at the preferred hour. |
43
43
  | `/renewal name amount currency renews [unit] [every] [lead] [note]` | registered | A subscription, domain, warranty or membership (docket's `renewal`, a `period` schedule). `renews` is the next renewal or expiry date, `YYYY-MM-DD`, today or later; `unit` yearly (the default), monthly, weekly or daily, `every` how many of those; the ask comes `lead` days before (default 7) at the preferred hour, with Keep, Cancel, Renewed and Snooze. If that ask is already past but the date is not, the first ask comes within a minute (a zone or hour change before it fires drops it for the next period's). Keep and Renewed record what was paid; Cancel ends it. |
44
44
  | `/price url [name] [hours] [drop] [baseline] [near]` | registered | A price (docket's `price`, a `poll` schedule every `hours`, default 12, at most 168). The page is read once at once, and nothing is created unless a price is found in it; then the first check, within a minute, DMs the starting price, and a drop of `drop` percent or more (default 10) from the `baseline` -- the last (default), first or highest price seen -- DMs the owner once per crossing. `near` is the words just before the price, for a page with no structured price. At most 20 per person. |
@@ -208,9 +208,10 @@ served at a hashed path, cached for a year).
208
208
  | `/admin/tasks` | Admins only: every task in the store, with its owner, status and how many receive it, each linking to its page. |
209
209
  | `/admin/deliveries` | Admins only: the DMs that did not arrive (below), newest first. |
210
210
  | `/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. |
211
+ | `/admin/usr-link` | Admins only, POST only, with the usr link on: Link everyone to usr (below). |
211
212
  | `/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. |
212
213
  | `/api/v1/...` | The JSON task API (below): bearer tokens only, never the cookie. |
213
- | `/signin` | Where a request that is not signed in is sent: says to run `/web`. |
214
+ | `/signin` | Where a request that is not signed in is sent (a POST, or any request with the usr link off): says to run `/web`, and with the usr link on offers Sign in with usr. |
214
215
  | `/login?t=...` | The link `/web` gives. |
215
216
 
216
217
  **Signing in** (plan item 41; the one-time link is the method for now, kept in
@@ -223,6 +224,27 @@ same-origin` and `Cache-Control: no-store` (every page does). Not `no-referrer`:
223
224
  sends a form post's `Origin` as `null`, which the Origin check refuses, so no form would work; the
224
225
  pages link nowhere else, so the token never leaves in a Referer either way.
225
226
 
227
+ **Signing in through usr** (0.22.0, while the usr link is on: `TRACKER_USR_URL`). A GET with no
228
+ session reads usr's `nz_id` cookie, which usr sets on the parent domain named by its
229
+ `USR_SSO_COOKIE_DOMAIN`. That domain must cover `TRACKER_WEB_URL`'s host: otherwise usr refuses the
230
+ return address and keeps the browser on its own site. The token is checked offline, as usr's
231
+ `docs/sso-verifier.md` says: an ES256 signature against usr's `/.well-known/jwks.json` (cached five
232
+ minutes, fetched again on an unknown key at most every 30 seconds), `iss` "usr", not expired, and
233
+ never a delegation token. A valid token whose account holds `tracker:member` and is linked to a
234
+ registered person (`users.usr_subject`) opens a session as the one-time link does, once server
235
+ membership is checked (as `/web` checks it), and the browser goes on to the page and query it asked
236
+ for. With no valid token the browser is sent to usr's `/api/auth/sso/refresh` once, returning to
237
+ the same address marked `?usr=1`; coming back still without one (usr's keys could not be fetched,
238
+ the clocks disagree) says the sign-in could not be confirmed, instead of going round again. A token
239
+ without the member role, or for an account linked to no one registered here, shows what to ask an
240
+ admin for. While signed in, a usr cookie for the person's own account that no longer carries
241
+ `tracker:member` signs them out of every session and revokes their API tokens. Sign out ends the
242
+ tracker's session only, not usr's: for 12 hours after it, a plain GET does not sign back in from
243
+ usr's cookie; the sign-in page's "Sign in with usr" button (and usr's trip back from it) or a `/web`
244
+ link does. Any site under usr's cookie domain can set an
245
+ `nz_id` of its own, so every host under that domain must be trusted. The one-time `/web` link stays
246
+ beside it. `src/usr-identity.ts` uses WebCrypto only.
247
+
226
248
  **Sessions and forms.** The session cookie is `__Secure-tracker-session`, `HttpOnly; Secure;
227
249
  SameSite=Lax; Path=/tracker/`, for 7 days from sign-in. The store keeps only SHA-256 hashes of
228
250
  link tokens and session ids. Every request re-reads the person: one no longer in the tracker's
@@ -553,7 +575,7 @@ $ curl -s https://clerk.example.com/tracker/api/v1/tasks/t9 -H "Authorization: B
553
575
 
554
576
  | Piece | File | Notes |
555
577
  |---|---|---|
556
- | Store | `src/store.ts`, `src/schema.ts` | docket's `Store` port on `bun:sqlite`, in `<dataDir>/tracker/tracker.sqlite` (WAL). A Discord id belongs to at most one user. Schema versioned by `PRAGMA user_version`; a shipped migration is never edited. Schema 5 (0.9.0) is docket 0.4.0's: a run's `record`, a series point's `key`, `deliveries` (from `delivery_claims`), `usage` and `notices`. Schema 6 (0.11.0) is docket 0.5.0's, all additive: `findings`, a charge's `usage.key`, and the Executor's `executor_jobs`. Forget-me erases a person's findings and their runs' Job records too. Schema 8 (0.19.0) adds `want_inbox`, the listings sent in for an inbox watch. `store.test.ts` runs docket's `STORE_CONTRACT` against it. |
578
+ | Store | `src/store.ts`, `src/schema.ts` | docket's `Store` port on `bun:sqlite`, in `<dataDir>/tracker/tracker.sqlite` (WAL). A Discord id belongs to at most one user. Schema versioned by `PRAGMA user_version`; a shipped migration is never edited. Schema 5 (0.9.0) is docket 0.4.0's: a run's `record`, a series point's `key`, `deliveries` (from `delivery_claims`), `usage` and `notices`. Schema 6 (0.11.0) is docket 0.5.0's, all additive: `findings`, a charge's `usage.key`, and the Executor's `executor_jobs`. Forget-me erases a person's findings and their runs' Job records too. Schema 8 (0.19.0) adds `want_inbox`, the listings sent in for an inbox watch. Schema 9 (0.20.0) adds `users.usr_subject`, a person's usr user id, null until linked, unique when set. `store.test.ts` runs docket's `STORE_CONTRACT` against it. |
557
579
  | Notify lane | `src/notify-lane.ts` | docket's `Lanes.tickNotify` on two host ticks, every 60 s, one pass per task with work (a due run, a run in flight, a DM owed or claimed), each under that task's lock through a view of the store limited to that task: `notify` runs every type but the page readers, `poll` runs only them (`price`, `wantlist`) with the Fetch port, so a slow page never holds up a reminder. A task the execute tick holds is left for the next notify tick, never waited on. Registers `reminder`, `renewal`, `price`, `research`, `scout`, `wantlist` and `wantjudge`. |
558
580
  | Want-list watcher | `src/want.ts`, `src/inbox.ts`, `src/web/api-listings.ts`; the types and sources are `@rackbops/docket-types`' (0.7.0: `wantlist`, `wantjudge`, `pageSource`, `bggSource`, `inboxSource`) | `/want`'s rules; the `wantlist` type over a `Source` port; the `inbox` source over the `want_inbox` table (schema 8, 0.19.0: the listings sent in per watch, keyed `ebay-` or `bgg-` and 24 random hex digits, the newest 100 kept, as many as one look reads; forget-me erases them with the person's tasks), filled by the task API's listings route; the `page` source (JSON-LD listings through the fenced Fetch port, no HTML guessing) and the `bgg` source (BGG's XML API with the Bearer token, on `boardgamegeek.com`, never `www.`, at least 5 s between requests (a read due sooner is put back for the next tick, never waited on), read by a bounded `indexOf` parser, never a pattern over the whole body; a 401 or 403 is the token refused, a 202, 429 or 5xx a miss). The BGG fixture is hand-written to BGG's documented shape until a real response is captured with the token. The judged watch (`wantjudge`) is an execute-lane type: its `prepare` reads the source in plain code (an uncharged `NoJob` when nothing is new), keeps new listings in the task's state as `pending` and asks for a follow-up, whose `prepare` makes the judge Job; `finish` maps the verdicts back by number, so a Job collected after a restart still has its listings. On this lane a BGG read due too soon waits out the spacing once. |
559
581
  | Execute lane | `src/execute-lane.ts`, `src/executor.ts`, `src/research.ts`, `src/scout.ts` (the `scout` type is docket-types') | Only when the city-hall Executor is configured: a third host tick, `execute`, that starts docket's `Lanes.tickExecute` in the background (one at a time, at most once a minute) over every task with a due execute-lane run, reserving those tasks and then holding their locks in id order, through one docket `Lanes` kept while the plugin is active (docket keeps its usage-limit pause on it). The Executor is city-hall#18's source pair (above), its I/O injected; `executor_jobs` keeps each Job key's city-hall id. `/research`'s and `/scout`'s rules, and the scout type. |
@@ -564,7 +586,7 @@ $ curl -s https://clerk.example.com/tracker/api/v1/tasks/t9 -H "Authorization: B
564
586
  | Daily digest | `src/digest.ts` | Plan 5.5's "today and overdue" DM, on the `notify` tick after the runs due now, no model. For each person whose preferred hour has come in their zone, the day is claimed first (docket's `claimNotice`, key `digest:<user>:<YYYY-MM-DD>`, research-triage's exactly-once pattern; forget-me erases it), then their own active reminders and renewals are read: overdue is the latest fired run before today with no done or decision from the owner, due today is a run not yet fired today or the latest fired run today still unanswered. A snoozed run, an older run behind a newer one, other types and received tasks are left out. Nothing listed, no DM; a paused person is passed by unclaimed; a failed send is not retried. |
565
587
  | Task lock | `src/locks.ts` | One task at a time: its runs, answers and edits never overlap (docket 0.4.0 asks the host to serialize per task). |
566
588
  | Pause | `src/delivery-health.ts` | The per-person failure count, the pause and the resume. |
567
- | 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. |
589
+ | People | `src/people.ts`, `src/admissions.ts` | Discord id, time zone, preferred hour, admin flag -- in the tracker's store, with each person's usr user id once the usr link links them (`src/usr.ts`, 0.20.0); who admitted each person and when they registered. `TRACKER_ADMIN_DISCORD_IDS` only ever grants admin. |
568
590
  | 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. |
569
591
  | Health | `src/health.ts` | `GET /tracker/healthz` (through the bot's HTTP router): `200` `ok`/`starting`, `503` `inactive`/`stale`/`blocked`. |
570
592
  | 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. |
@@ -586,6 +608,9 @@ $ curl -s https://clerk.example.com/tracker/api/v1/tasks/t9 -H "Authorization: B
586
608
  | `TRACKER_CITY_HALL_CAPABILITY` | no | The capability tag every Job names, one only docket-runner carries (plan item 71), e.g. `claude-cli:subscription`. No default: a guessed tag could send the tracker's Jobs to another agent. |
587
609
  | `TRACKER_CITY_HALL_ACCESS_CLIENT_ID`, `TRACKER_CITY_HALL_ACCESS_CLIENT_SECRET` | yes | A Cloudflare Access service token for city-hall's edge (`CF-Access-Client-Id` / `-Secret`), when one is in front. Both or neither. |
588
610
  | `TRACKER_BGG_TOKEN` | yes | BoardGameGeek's XML API Bearer token for the tracker's registered application. Set = `/want source: bgg` watches a game's BGG marketplace; unset or empty = `/want source: bgg` makes an inbox watch instead, whose listings are sent in (as for eBay). Never logged. To get one: register an application at boardgamegeek.com/applications (roshne applied 2026-09-29, non-commercial; approval takes a week or more), then put the token in the instance's `.env` and recreate the bot. BGG's terms ask for attribution: every BGG line in a DM says "via BoardGameGeek". |
611
+ | `TRACKER_USR_URL` | no | The https origin of our usr (Rackbops/usr), e.g. `https://id.example.com` (no path). With `TRACKER_USR_KEY` it turns the usr link on (0.20.0): people stay in the tracker, and each is linked to a usr account by usr's user id (`users.usr_subject`). Unset = off, and the tracker behaves as before (migration 9 still adds the empty column). `/allow` and `/register` use it since 0.21.0, and the web sign-in since 0.22.0 (Signing in through usr, above). |
612
+ | `TRACKER_USR_KEY` | yes | The tracker's usr API key, with the role `usr:discord` only (usr's `/api/discord/allow` and `/register-link`), never `tracker:admin` or `tracker:register`, and a usr Discord service (`PUT /api/discord/services/<keyId>`) set to the tracker's app. Required with `TRACKER_USR_URL`; set without it, or with whitespace in it, refuses to load. Never logged. |
613
+ | `TRACKER_USR_APP` | no | The usr app the tracker's roles live under (`tracker:member`, `tracker:register`): lowercase words joined by `-`. It must match the app on the key's usr Discord service row, which is what usr goes by. Unset = `tracker`. Needs `TRACKER_USR_URL`. |
589
614
 
590
615
  **The execute lane is off until all three of `TRACKER_CITY_HALL_URL`, `_KEY` and `_CAPABILITY` are
591
616
  set**: no `execute` tick, and `/research` answers that research is not available, so nobody can
@@ -600,6 +625,27 @@ key>`, so two databases never collide.
600
625
 
601
626
  `/tracker/healthz` and the web area need the bot's `HTTP_PORT` set; without it there is no HTTP at all.
602
627
 
628
+ **Link everyone to usr** (0.23.0, the admin page, with the usr link on). People admitted before the
629
+ link was on are not linked to usr. One press starts a run in the background (the bot answers a web
630
+ request it waited on for 10 seconds with a 504, so the press does not wait) and goes back to the
631
+ admin page, which shows how far the run has got and then how it ended; a second press while it runs
632
+ starts nothing. For each person on the list with a Discord id and no usr link, one at a time, it
633
+ checks they are a member of the server with its role, as `/allow` does; re-reads them just before asking
634
+ usr, so someone forgotten before then is never sent; allows them in usr as `tracker:member`, with the pressing
635
+ admin as the one who allowed them; then links them. usr records each link against
636
+ `TRACKER_GUILD_ID`'s first server, so with no gate it refuses and says to `/allow` in the server
637
+ instead. Only a 400 from usr about the person's own details is theirs, listed by name while the rest go on; any other
638
+ refusal or failure (the admin not linked in usr or missing `tracker:register` or `tracker:member`,
639
+ the key, usr unreachable) stops the run, since it would fail everyone the same way. It never starts
640
+ on its own.
641
+
642
+ **First admin and usr.** usr's `/api/discord/allow` needs the admin running `/allow` to be linked in
643
+ usr already, so the first admin cannot be `/allow`ed by anyone. With the usr link on, a configured
644
+ admin (`TRACKER_ADMIN_DISCORD_IDS`) who is not linked gets usr's open sign-up link from `/register`,
645
+ run in a server. They finish signing up on usr, a usr admin gives them `tracker:register` and
646
+ `tracker:member` there, and they run `/allow` on themselves, which links their tracker account.
647
+ From then on they `/allow` everyone else. (usr's own "Setup" step 3, in `docs/discord-registration.md`, has the owner `/register` through the bot; the tracker uses the open link for that one step only, since its `/register` otherwise asks with the allow policy.)
648
+
603
649
  ## Not yet
604
650
 
605
651
  - The rest of the web area (E5): sharing a task from the web, and the admin view's grants and
package/dist/plugin.js CHANGED
@@ -5604,6 +5604,10 @@ var MIGRATIONS = [
5604
5604
  );
5605
5605
  CREATE INDEX want_inbox_key ON want_inbox (key, seq);
5606
5606
  CREATE INDEX want_inbox_task ON want_inbox (task_id);
5607
+ `,
5608
+ `
5609
+ ALTER TABLE users ADD COLUMN usr_subject TEXT;
5610
+ CREATE UNIQUE INDEX users_usr_subject ON users (usr_subject) WHERE usr_subject IS NOT NULL;
5607
5611
  `
5608
5612
  ];
5609
5613
  var BUSY_TIMEOUT_MS = 5000;
@@ -6231,6 +6235,152 @@ async function restoreHeldRun(store, task, held, before, after, now) {
6231
6235
  return store.createOccurrence({ taskId: task.id, lane: task.lane, dueAt: due.toISOString(), dedupeKey: scheduledKey(task.id, due), at: now.toISOString() });
6232
6236
  }
6233
6237
 
6238
+ // plugins/tracker/src/usr.ts
6239
+ var USR_ENV = {
6240
+ url: "TRACKER_USR_URL",
6241
+ key: "TRACKER_USR_KEY",
6242
+ app: "TRACKER_USR_APP"
6243
+ };
6244
+ var USR_URL_FORMAT = "^https://[A-Za-z0-9.-]+(:[0-9]{1,5})?/?$";
6245
+ var USR_APP_FORMAT = "^[a-z0-9]+(-[a-z0-9]+)*$";
6246
+ var APP = new RegExp(USR_APP_FORMAT);
6247
+ var DEFAULT_USR_APP = "tracker";
6248
+ var USR_TIMEOUT_MS = 1e4;
6249
+ var SNOWFLAKE2 = /^[0-9]{17,20}$/;
6250
+ function value2(env, key) {
6251
+ const v = env[key]?.trim();
6252
+ return v === undefined || v === "" ? undefined : v;
6253
+ }
6254
+ function parseUsrConfig(env) {
6255
+ const url = value2(env, USR_ENV.url);
6256
+ const key = value2(env, USR_ENV.key);
6257
+ const app = value2(env, USR_ENV.app);
6258
+ if (url === undefined) {
6259
+ if (key !== undefined || app !== undefined)
6260
+ throw new Error(`${USR_ENV.key} and ${USR_ENV.app} need ${USR_ENV.url}: set it, or unset them`);
6261
+ return null;
6262
+ }
6263
+ let parsed = null;
6264
+ try {
6265
+ parsed = new URL(url);
6266
+ } catch {
6267
+ parsed = null;
6268
+ }
6269
+ if (url.includes("@") || parsed && (parsed.username !== "" || parsed.password !== "")) {
6270
+ throw new Error(`${USR_ENV.url} must not contain credentials: give the bare origin, and the key in ${USR_ENV.key}`);
6271
+ }
6272
+ if (!new RegExp(USR_URL_FORMAT).test(url) || !parsed || parsed.protocol !== "https:") {
6273
+ throw new Error(`${USR_ENV.url} is not an https origin such as https://id.example.com (no path, query or credentials)`);
6274
+ }
6275
+ if (key === undefined)
6276
+ throw new Error(`${USR_ENV.url} is set but ${USR_ENV.key} is not: the usr link needs both`);
6277
+ if (/\s/.test(key))
6278
+ throw new Error(`${USR_ENV.key} has whitespace in it`);
6279
+ if (app !== undefined && !APP.test(app)) {
6280
+ throw new Error(`${USR_ENV.app}: "${app}" is not a usr app name (lowercase words joined by "-", e.g. tracker)`);
6281
+ }
6282
+ return { url: parsed.origin, key, app: app ?? DEFAULT_USR_APP };
6283
+ }
6284
+
6285
+ class UsrError extends Error {
6286
+ status;
6287
+ reason;
6288
+ constructor(message, status, reason = "") {
6289
+ super(message);
6290
+ this.status = status;
6291
+ this.reason = reason;
6292
+ this.name = "UsrError";
6293
+ }
6294
+ }
6295
+ function reason2(body) {
6296
+ const e = body && typeof body === "object" ? body.error : undefined;
6297
+ if (typeof e !== "string")
6298
+ return "";
6299
+ return e.replace(/\s*\u2014\s*/g, " -- ").replace(/[^\x20-\x7e]/g, "").slice(0, 200).trim();
6300
+ }
6301
+ function createUsrClient(o) {
6302
+ const fetchImpl = o.fetchImpl ?? fetch;
6303
+ const timeoutMs = o.timeoutMs ?? USR_TIMEOUT_MS;
6304
+ async function post(path, body, ok409 = false) {
6305
+ let res;
6306
+ try {
6307
+ res = await fetchImpl(`${o.config.url}${path}`, {
6308
+ method: "POST",
6309
+ headers: { Authorization: `Bearer ${o.config.key}`, Accept: "application/json", "Content-Type": "application/json" },
6310
+ body: JSON.stringify(body),
6311
+ redirect: "manual",
6312
+ signal: AbortSignal.timeout(timeoutMs)
6313
+ });
6314
+ } catch (err) {
6315
+ const why = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError") ? "timed out" : "could not be reached";
6316
+ throw new UsrError(`usr ${why}`, null);
6317
+ }
6318
+ let parsed;
6319
+ try {
6320
+ parsed = await res.json();
6321
+ } catch {
6322
+ parsed = null;
6323
+ }
6324
+ const status = res.status;
6325
+ if (status === 401)
6326
+ throw new UsrError(`usr refused the tracker's key (HTTP 401): check ${USR_ENV.key}`, status);
6327
+ if (status >= 300 && status < 400)
6328
+ throw new UsrError(`usr answered with a redirect (HTTP ${status}), as an edge login does`, status);
6329
+ if (status === 409 && ok409)
6330
+ return { status, body: parsed };
6331
+ if (status < 200 || status >= 300) {
6332
+ const why = reason2(parsed);
6333
+ throw new UsrError(`usr answered HTTP ${status}${why ? `: ${why}` : ""}`, status, why);
6334
+ }
6335
+ return { status, body: parsed };
6336
+ }
6337
+ function snowflake(name, id) {
6338
+ if (!SNOWFLAKE2.test(id))
6339
+ throw new UsrError(`${name} is not a Discord id`, null);
6340
+ }
6341
+ return {
6342
+ async allow(req) {
6343
+ snowflake("the person", req.discordId);
6344
+ snowflake("the server", req.guildId);
6345
+ snowflake("the admin", req.invokerDiscordId);
6346
+ const { body } = await post("/api/discord/allow", {
6347
+ discord_user_id: req.discordId,
6348
+ guild_id: req.guildId,
6349
+ invoker_discord_user_id: req.invokerDiscordId,
6350
+ roles: [...req.roles],
6351
+ ...req.displayName ? { display_name: req.displayName } : {}
6352
+ });
6353
+ const b = body ?? {};
6354
+ if (typeof b.user_id !== "string" || b.user_id === "" || typeof b.created !== "boolean" || !Array.isArray(b.roles) || !b.roles.every((r) => typeof r === "string")) {
6355
+ throw new UsrError(`usr's answer to allow was not one the tracker could read: is ${USR_ENV.url} usr?`, 200);
6356
+ }
6357
+ return { userId: b.user_id, created: b.created, roles: b.roles };
6358
+ },
6359
+ async registerLink(req) {
6360
+ snowflake("the person", req.discordId);
6361
+ snowflake("the server", req.guildId);
6362
+ const { status, body } = await post("/api/discord/register-link", { discord_user_id: req.discordId, guild_id: req.guildId, policy: req.policy ?? "allow", ...req.displayName ? { display_name: req.displayName } : {} }, true);
6363
+ if (status === 409)
6364
+ return null;
6365
+ const b = body ?? {};
6366
+ if (typeof b.url !== "string" || typeof b.expires_at !== "string") {
6367
+ throw new UsrError(`usr's answer to register-link was not one the tracker could read: is ${USR_ENV.url} usr?`, status);
6368
+ }
6369
+ let link;
6370
+ try {
6371
+ link = new URL(b.url);
6372
+ } catch {
6373
+ throw new UsrError("usr's registration link is not a URL", status);
6374
+ }
6375
+ if (link.protocol !== "https:")
6376
+ throw new UsrError("usr's registration link is not https: check usr's USR_PUBLIC_URL", status);
6377
+ if (link.username !== "" || link.password !== "")
6378
+ throw new UsrError("usr's registration link has credentials in it: check usr's USR_PUBLIC_URL", status);
6379
+ return { url: link.href, expiresAt: b.expires_at };
6380
+ }
6381
+ };
6382
+ }
6383
+
6234
6384
  // plugins/tracker/src/actions.ts
6235
6385
  var MAX_ANSWER = 2000;
6236
6386
  var NO_LONGER_LISTED = "You are no longer on this tracker's list.";
@@ -6247,7 +6397,7 @@ async function enter(d, discordId, membership, need) {
6247
6397
  const resumed = await d.health.resume(person.id, d.clock.now());
6248
6398
  return { ok: true, user: person, notice: resumed ? resumedNotice(resumed) : null };
6249
6399
  }
6250
- async function allowPerson(d, admin, target) {
6400
+ async function allowPerson(d, admin, target, guildId = null) {
6251
6401
  if (target.bot)
6252
6402
  return "A bot cannot use the tracker.";
6253
6403
  if (target.membership === "not-member")
@@ -6258,9 +6408,65 @@ async function allowPerson(d, admin, target) {
6258
6408
  const now = d.clock.now();
6259
6409
  const user = await admit(d.store, target.discordId, now);
6260
6410
  d.admissions.record(user.id, admin.id, now.toISOString());
6261
- if (before)
6262
- return `<@${target.discordId}> is already on the list.`;
6263
- return `Allowed <@${target.discordId}>: they can now use \`/register\`.`;
6411
+ const text = before ? `<@${target.discordId}> is already on the list.` : `Allowed <@${target.discordId}>: they can now use \`/register\`.`;
6412
+ const usr = d.usr;
6413
+ if (!usr)
6414
+ return text;
6415
+ if (guildId === null || admin.discordId === null) {
6416
+ return usr.links.subjectOf(user.id) !== null ? text : `${text}
6417
+
6418
+ Not linked to usr: run \`/allow\` in the server to link them.`;
6419
+ }
6420
+ const invoker = admin.discordId;
6421
+ return {
6422
+ outside: () => usr.client.allow({ discordId: target.discordId, guildId, invokerDiscordId: invoker, roles: [`${usr.app}:${MEMBER_ROLE}`], ...target.displayName ? { displayName: target.displayName } : {} }).catch((err) => err),
6423
+ finish: async (result) => `${text}
6424
+
6425
+ ${usrAllowed(d, usr, user.id, result)}`
6426
+ };
6427
+ }
6428
+ var MEMBER_ROLE = "member";
6429
+ function usrAllowed(d, usr, userId, result) {
6430
+ const app = usr.app;
6431
+ if (result instanceof UsrError) {
6432
+ const why = result.reason;
6433
+ if (result.status === 403 && why.includes("invoker is not linked")) {
6434
+ return `Not linked to usr: you are not linked to usr yourself yet. Run \`/register\` here and finish signing up on usr, have a usr admin give you \`${app}:register\` and \`${app}:${MEMBER_ROLE}\` there, then run \`/allow\` on yourself and on them.`;
6435
+ }
6436
+ if (result.status === 403 && (why.includes(`lacks ${app}:register`) || why.includes("cannot grant roles"))) {
6437
+ return `Not linked to usr: you need both \`${app}:register\` and \`${app}:${MEMBER_ROLE}\` in usr; a usr admin gives them. Then run \`/allow\` again. They are on the tracker's list.`;
6438
+ }
6439
+ if (result.status === 403 && why.includes("outside the")) {
6440
+ return `Not linked to usr: usr's Discord service for the tracker's key is set to another app than \`${app}\` (TRACKER_USR_APP). Fix one to match; nothing was granted.`;
6441
+ }
6442
+ return `Not linked to usr: ${result.message}. They are on the tracker's list; run \`/allow\` again once it is fixed.`;
6443
+ }
6444
+ if (result instanceof Error) {
6445
+ d.log.error("usr allow failed", result);
6446
+ return "Not linked to usr: something went wrong. They are on the tracker's list; run `/allow` again to retry.";
6447
+ }
6448
+ const answer = result;
6449
+ const member = `${app}:${MEMBER_ROLE}`;
6450
+ if (!answer.roles.includes(member)) {
6451
+ return `Not linked to usr: usr did not give them \`${member}\`. Check that the tracker's usr key's Discord service is set to app \`${app}\` (TRACKER_USR_APP).`;
6452
+ }
6453
+ let outcome;
6454
+ try {
6455
+ outcome = usr.links.link(userId, answer.userId);
6456
+ } catch (err) {
6457
+ d.log.error("usr link failed", err);
6458
+ if (err instanceof Error && /UNIQUE constraint failed/.test(err.message))
6459
+ outcome = "taken";
6460
+ else
6461
+ return "Not linked to usr: something went wrong keeping their usr link. They are on the tracker's list; run `/allow` again to retry.";
6462
+ }
6463
+ if (outcome === "gone")
6464
+ return "Not linked to usr: they left the tracker while usr was being asked.";
6465
+ if (outcome === "taken")
6466
+ return "Not linked to usr: usr's account for them is already linked to someone else here.";
6467
+ if (outcome === "unchanged")
6468
+ return "Linked to usr.";
6469
+ return "Linked to usr: they can finish their usr sign-up with `/register`.";
6264
6470
  }
6265
6471
  function registeredText(user, first) {
6266
6472
  return registrationText(user, {
@@ -6268,7 +6474,53 @@ function registeredText(user, first) {
6268
6474
  notes: [`Keep DMs from this server open: after ${PAUSE_AFTER} DMs in a row that cannot be delivered, your reminders pause until you next use a command.`]
6269
6475
  });
6270
6476
  }
6271
- async function registerPerson(d, user, input) {
6477
+ async function registerPerson(d, user, input, guildId = null) {
6478
+ const { text, saved } = await registerSettings(d, user, input);
6479
+ const usr = d.usr;
6480
+ if (!usr || !saved || user.discordId === null || usr.links.isSignedUp(user.id))
6481
+ return text;
6482
+ const linked = usr.links.subjectOf(user.id) !== null;
6483
+ const firstAdmin = !linked && d.configuredAdmins.has(user.discordId);
6484
+ if (!linked && !firstAdmin)
6485
+ return text;
6486
+ if (guildId === null)
6487
+ return `${text}
6488
+
6489
+ Run \`/register\` in the server to get your usr sign-up link.`;
6490
+ const discordId = user.discordId;
6491
+ const policy = firstAdmin ? "open" : "allow";
6492
+ return {
6493
+ outside: () => usr.client.registerLink({ discordId, guildId, policy }).catch((err) => err),
6494
+ finish: async (result) => {
6495
+ if (result === null) {
6496
+ usr.links.markSignedUp(user.id);
6497
+ return firstAdmin ? `${text}
6498
+
6499
+ You are signed up on usr. Once a usr admin gives you \`${usr.app}:register\` and \`${usr.app}:${MEMBER_ROLE}\` there, run \`/allow\` on yourself to link your tracker account.` : text;
6500
+ }
6501
+ if (result instanceof UsrError && result.status === 403 && linked && result.reason.includes("not allowed yet")) {
6502
+ usr.links.unlink(user.id);
6503
+ return `${text}
6504
+
6505
+ usr no longer has you on its list: ask an admin to run \`/allow\` for you again.`;
6506
+ }
6507
+ if (result instanceof Error) {
6508
+ d.log.warn(`usr register-link failed: ${result instanceof UsrError ? result.message : String(result)}`);
6509
+ const detail = user.admin && result instanceof UsrError ? ` (${result.message})` : "";
6510
+ return `${text}
6511
+
6512
+ I could not get your usr sign-up link${detail}. Try \`/register\` again later.`;
6513
+ }
6514
+ const link = result;
6515
+ const after = firstAdmin ? ` Then have a usr admin give you \`${usr.app}:register\` and \`${usr.app}:${MEMBER_ROLE}\` there, and run \`/allow\` on yourself to link your tracker account.` : "";
6516
+ return `${text}
6517
+
6518
+ Finish signing up on usr, where you will sign in to the web area: ${link.url}
6519
+ The link works once and expires soon; \`/register\` gives a new one.${after}`;
6520
+ }
6521
+ };
6522
+ }
6523
+ async function registerSettings(d, user, input) {
6272
6524
  let updated;
6273
6525
  try {
6274
6526
  updated = await setPreferences(d.store, user.id, {
@@ -6277,7 +6529,7 @@ async function registerPerson(d, user, input) {
6277
6529
  });
6278
6530
  } catch (err) {
6279
6531
  if (err instanceof PeopleError)
6280
- return err.message;
6532
+ return { text: err.message, saved: false };
6281
6533
  throw err;
6282
6534
  }
6283
6535
  updated = await d.store.updateUser(user.id, { displayName: clip2(input.displayName, 100) });
@@ -6286,7 +6538,7 @@ async function registerPerson(d, user, input) {
6286
6538
  const zoneChanged = updated.timeZone !== user.timeZone;
6287
6539
  if (zoneChanged || updated.preferredHour !== user.preferredHour)
6288
6540
  await rescheduleOwned(d, updated, zoneChanged, user);
6289
- return registeredText(updated, first);
6541
+ return { text: registeredText(updated, first), saved: true };
6290
6542
  }
6291
6543
  async function saveSettings(d, user, input) {
6292
6544
  let updated;
@@ -8047,7 +8299,7 @@ ${body}` : body);
8047
8299
  build: (b) => b.setDescription("Admin: let a person use the tracker").addUserOption((o) => o.setName("user").setDescription("Who to let in").setRequired(true)),
8048
8300
  handle: (interaction) => {
8049
8301
  const target = interaction.options.getUser("user", true);
8050
- return run(interaction, "admin", (d, admin, membership) => allowPerson(d, admin, { discordId: target.id, bot: target.bot, membership }), () => membershipOf(interaction, target.id));
8302
+ return run(interaction, "admin", (d, admin, membership) => allowPerson(d, admin, { discordId: target.id, bot: target.bot, membership, displayName: displayName(target) }, interaction.guildId), () => membershipOf(interaction, target.id));
8051
8303
  }
8052
8304
  },
8053
8305
  {
@@ -8060,7 +8312,7 @@ ${body}` : body);
8060
8312
  displayName: displayName(interaction.user),
8061
8313
  ...hour !== null ? { hour } : {},
8062
8314
  ...zone !== null ? { zone: zone.trim() } : {}
8063
- });
8315
+ }, interaction.guildId);
8064
8316
  })
8065
8317
  },
8066
8318
  {
@@ -10385,14 +10637,15 @@ function name3(u) {
10385
10637
  function framed(v, title, body) {
10386
10638
  return page({ base: v.base, title, signedIn: { name: name3(v.user), csrf: v.csrf, admin: v.user.admin }, body });
10387
10639
  }
10388
- function signInHelpPage(base, note) {
10640
+ function signInHelpPage(base, note, usr = false) {
10389
10641
  return page({
10390
10642
  base,
10391
10643
  title: "Sign in",
10392
10644
  body: html`<section class="rb-card">
10393
10645
  <h1>Sign in</h1>
10394
10646
  ${note ? html`<p class="rb-muted">${note}</p>` : null}
10395
- <p>Run <code>/web</code> in Discord: the bot answers you alone with a sign-in link, good for ten minutes and one use.</p>
10647
+ ${usr ? html`<p><a class="rb-btn rb-btn--primary" href="${base}/?usr=go">Sign in with usr</a></p>` : null}
10648
+ <p>${usr ? "Or run" : "Run"} <code>/web</code> in Discord: the bot answers you alone with a sign-in link, good for ten minutes and one use.</p>
10396
10649
  </section>`
10397
10650
  });
10398
10651
  }
@@ -11103,6 +11356,12 @@ ${hidden(v)}
11103
11356
  <div><button class="rb-btn rb-btn--primary" type="submit">Allow</button></div>
11104
11357
  </form>
11105
11358
  </section>
11359
+ ${o.usrUnlinked === undefined || o.usrUnlinked === null ? null : html`<section>
11360
+ <h2>Link everyone to usr</h2>
11361
+ <p class="rb-muted">${o.usrUnlinked === 0 ? "Everyone on the list is linked to usr." : `${o.usrUnlinked} on the list ${o.usrUnlinked === 1 ? "is" : "are"} not linked to usr yet.`} Each one is checked as a member of the server and allowed in usr as a member, with you as the admin who allowed them, as <code>/allow</code> does in the server.</p>
11362
+ ${o.usrRun?.running ? html`<p>Linking everyone to usr: ${String(o.usrRun.done)} of ${String(o.usrRun.total)} done. Reload this page to see how far it has got.</p>` : o.usrRun?.result ? html`<div class="rb-alert ${o.usrRun.result.ok ? "rb-alert--success" : "rb-alert--warning"}" role="status"><p>Last run: ${o.usrRun.result.text}</p></div>` : null}
11363
+ ${o.usrUnlinked === 0 || o.usrRun?.running ? null : html`<form method="post" action="${v.base}/admin/usr-link">${hidden(v)}<button class="rb-btn rb-btn--primary" type="submit">Link everyone to usr</button></form>`}
11364
+ </section>`}
11106
11365
  <section>
11107
11366
  <h2>Decline blocks</h2>
11108
11367
  ${blocks}
@@ -11283,8 +11542,12 @@ function confirmed(form) {
11283
11542
  }
11284
11543
  var WRONG_WORD = `Type ${CONFIRM_WORD} to confirm. Nothing was deleted.`;
11285
11544
  async function peoplePage(a, result, status, allow) {
11286
- const data = { people: a.d.roster.people(), blocks: a.d.roster.activeBlocks(a.d.clock.now()), now: a.d.clock.now() };
11287
- return htmlResponse(adminPage(a.v, data, { result, unlimited: a.d.budgetUnlimited === true, ...allow !== undefined ? { allow } : {} }), status);
11545
+ const people = a.d.roster.people();
11546
+ const data = { people, blocks: a.d.roster.activeBlocks(a.d.clock.now()), now: a.d.clock.now() };
11547
+ const usr = a.d.usr;
11548
+ const usrUnlinked = usr ? people.filter((p) => p.discordId !== null && usr.links.subjectOf(p.id) === null).length : null;
11549
+ const usrRun = usr ? usrLinkRuns.get(usr) : undefined;
11550
+ return htmlResponse(adminPage(a.v, data, { result, unlimited: a.d.budgetUnlimited === true, usrUnlinked, ...usrRun ? { usrRun } : {}, ...allow !== undefined ? { allow } : {} }), status);
11288
11551
  }
11289
11552
  async function onePerson(a, id, result = null) {
11290
11553
  const everyone = a.d.roster.people();
@@ -11334,12 +11597,28 @@ async function adminPost(a, r, form) {
11334
11597
  if (!/^[0-9]{17,20}$/.test(raw))
11335
11598
  return peoplePage(a, { ok: false, text: "That is not a Discord user id (17 to 20 digits)." }, 400, raw.slice(0, 20));
11336
11599
  const membership = await a.memberOf(raw);
11337
- const text = await fresh(a, true, (me) => allowPerson(a.d, me, { discordId: raw, bot: false, membership }));
11600
+ const text = await fresh(a, true, async (me) => {
11601
+ const answer = await allowPerson(a.d, me, { discordId: raw, bot: false, membership }, null);
11602
+ if (typeof answer !== "string")
11603
+ throw new Error("allowPerson called out without a server");
11604
+ return answer;
11605
+ });
11338
11606
  if (text === null)
11339
11607
  return unknownPage(base);
11340
11608
  const ok = text.startsWith("Allowed") || text.includes("already on the list");
11341
11609
  return peoplePage(a, { ok, text }, ok ? 200 : 400);
11342
11610
  }
11611
+ if (r.kind === "admin-usr-link") {
11612
+ if (!a.d.usr)
11613
+ return unknownPage(base);
11614
+ const usr = a.d.usr;
11615
+ const result = await fresh(a, true, async (me) => startLinkEveryone(a, usr, me));
11616
+ if (result === null)
11617
+ return unknownPage(base);
11618
+ if (result === "started")
11619
+ return redirect(`${base}/admin`);
11620
+ return peoplePage(a, result, result.ok ? 200 : 400);
11621
+ }
11343
11622
  if (r.kind === "admin-lift") {
11344
11623
  const done = await fresh(a, true, (me) => liftDeclineBlock(a.d, me, r.id));
11345
11624
  if (done === null)
@@ -11402,6 +11681,106 @@ async function adminPost(a, r, form) {
11402
11681
  return redirect(`${base}/`);
11403
11682
  return onePerson(a, r.id, said2(done));
11404
11683
  }
11684
+ var usrLinkRuns = new WeakMap;
11685
+ var usrLinkPending = new Set;
11686
+ function startLinkEveryone(a, usr, me) {
11687
+ if (a.usrGuildId === null) {
11688
+ return { ok: false, text: "usr needs a server for each link, and TRACKER_GUILD_ID is unset: run /allow on each person in the server instead." };
11689
+ }
11690
+ if (me.discordId === null)
11691
+ return { ok: false, text: "You have no Discord id here, and usr asks who is allowing them by it." };
11692
+ const current = usrLinkRuns.get(usr);
11693
+ if (current?.running)
11694
+ return { ok: false, text: `Already linking everyone to usr: ${current.done} of ${current.total} done.` };
11695
+ const waiting = a.d.roster.people().filter((p) => p.discordId !== null && usr.links.subjectOf(p.id) === null);
11696
+ if (waiting.length === 0)
11697
+ return { ok: true, text: "Everyone on the list is already linked to usr." };
11698
+ const run = { running: true, done: 0, total: waiting.length, result: null };
11699
+ usrLinkRuns.set(usr, run);
11700
+ const pending = linkEveryone(a, usr, me.discordId, nameOf(me), a.usrGuildId, waiting.map((p) => p.id), run).catch((err) => {
11701
+ a.d.log.error("usr link run failed", err);
11702
+ run.result = { ok: false, text: `Linking to usr stopped: something went wrong. Linked ${run.done} so far; press again to go on.` };
11703
+ }).finally(() => {
11704
+ run.running = false;
11705
+ usrLinkPending.delete(pending);
11706
+ });
11707
+ usrLinkPending.add(pending);
11708
+ return "started";
11709
+ }
11710
+ async function linkEveryone(a, usr, invoker, by, guildId, ids, run) {
11711
+ const member = `${usr.app}:${MEMBER_ROLE}`;
11712
+ let linked = 0;
11713
+ const problems = [];
11714
+ const end = (ok, stopped) => {
11715
+ const parts = [stopped ?? `Linked ${linked} of ${ids.length} to usr.`];
11716
+ if (problems.length > 0)
11717
+ parts.push(`Not linked: ${problems.join("; ")}.`);
11718
+ run.result = { ok: ok && problems.length === 0, text: parts.join(" ") };
11719
+ };
11720
+ const notAdmin = () => end(false, `Linking to usr stopped: ${by}, who started it, is no longer an admin here. Linked ${linked} so far.`);
11721
+ const stillWaiting = (id) => fresh(a, true, async () => {
11722
+ const p = await a.d.store.getUser(id);
11723
+ return p && p.discordId !== null && usr.links.subjectOf(id) === null ? p : "skip";
11724
+ });
11725
+ for (const id of ids) {
11726
+ const first = await stillWaiting(id);
11727
+ if (first === null)
11728
+ return notAdmin();
11729
+ if (first === "skip") {
11730
+ run.done++;
11731
+ continue;
11732
+ }
11733
+ const membership = await a.memberOf(first.discordId);
11734
+ if (membership === "not-member" || membership === "unknown") {
11735
+ problems.push(`${nameOf(first)}: ${membership === "not-member" ? "not a member of the server, or lacks its role" : "could not check they are a member of the server"}`);
11736
+ run.done++;
11737
+ continue;
11738
+ }
11739
+ const p = await stillWaiting(id);
11740
+ if (p === null)
11741
+ return notAdmin();
11742
+ if (p === "skip") {
11743
+ run.done++;
11744
+ continue;
11745
+ }
11746
+ const answer = await usr.client.allow({ discordId: p.discordId, guildId, invokerDiscordId: invoker, roles: [member], ...p.displayName ? { displayName: p.displayName } : {} }).catch((err) => err);
11747
+ if (answer instanceof UsrError && answer.status === 400 && !/guild_id|invoker|roles|role "/.test(answer.reason)) {
11748
+ problems.push(`${nameOf(p)}: ${answer.message}`);
11749
+ run.done++;
11750
+ continue;
11751
+ }
11752
+ if (answer instanceof Error) {
11753
+ if (!(answer instanceof UsrError))
11754
+ a.d.log.error("usr allow failed", answer);
11755
+ const why = answer instanceof UsrError ? answer.message : "something went wrong";
11756
+ return end(false, `Linking to usr stopped, since it would fail for everyone: ${why}. Linked ${linked} so far; fix that, then press again.`);
11757
+ }
11758
+ const allowed = answer;
11759
+ if (!allowed.roles.includes(member)) {
11760
+ return end(false, `Linking to usr stopped: usr did not give ${nameOf(p)} ${member}. Check that the tracker's usr key's Discord service is set to app ${usr.app} (TRACKER_USR_APP). Linked ${linked} so far.`);
11761
+ }
11762
+ const outcome = await fresh(a, true, async () => {
11763
+ try {
11764
+ return usr.links.link(id, allowed.userId);
11765
+ } catch (err) {
11766
+ a.d.log.error("usr link failed", err);
11767
+ return err instanceof Error && /UNIQUE constraint failed/.test(err.message) ? "taken" : "failed";
11768
+ }
11769
+ });
11770
+ if (outcome === null)
11771
+ return notAdmin();
11772
+ run.done++;
11773
+ if (outcome === "linked" || outcome === "relinked" || outcome === "unchanged")
11774
+ linked++;
11775
+ else if (outcome === "taken")
11776
+ problems.push(`${nameOf(p)}: their usr account is already linked to someone else here`);
11777
+ else if (outcome === "gone")
11778
+ problems.push(`${nameOf(p)}: left the tracker meanwhile`);
11779
+ else
11780
+ problems.push(`${nameOf(p)}: something went wrong keeping their usr link`);
11781
+ }
11782
+ end(true);
11783
+ }
11405
11784
  async function forgetRoute(a, method, form) {
11406
11785
  const base = a.v.base;
11407
11786
  const note = isConfiguredAdmin(a.d, a.v.user) ? CONFIGURED_SELF : undefined;
@@ -12319,6 +12698,8 @@ function route2(path) {
12319
12698
  return { kind: "admin-usage" };
12320
12699
  if (path === "/admin/allow")
12321
12700
  return { kind: "admin-allow" };
12701
+ if (path === "/admin/usr-link")
12702
+ return { kind: "admin-usr-link" };
12322
12703
  const ceiling = PERSON_CEILING.exec(path);
12323
12704
  if (ceiling)
12324
12705
  return { kind: "admin-ceiling", id: segment2(ceiling[1]) };
@@ -12348,7 +12729,7 @@ function route2(path) {
12348
12729
  return { kind: "act", id, action };
12349
12730
  }
12350
12731
  function methodsOf(r) {
12351
- if (r.kind === "logout" || r.kind === "act" || r.kind === "admin-allow" || r.kind === "admin-act" || r.kind === "admin-ceiling" || r.kind === "admin-lift" || r.kind === "token-revoke" || r.kind === "admin-token-revoke") {
12732
+ if (r.kind === "logout" || r.kind === "act" || r.kind === "admin-allow" || r.kind === "admin-usr-link" || r.kind === "admin-act" || r.kind === "admin-ceiling" || r.kind === "admin-lift" || r.kind === "token-revoke" || r.kind === "admin-token-revoke") {
12352
12733
  return "POST";
12353
12734
  }
12354
12735
  if (r.kind === "settings" || r.kind === "edit" || r.kind === "new" || r.kind === "forget" || r.kind === "tokens")
@@ -12390,12 +12771,118 @@ class Sessions {
12390
12771
  }
12391
12772
  }
12392
12773
 
12774
+ // plugins/tracker/src/usr-identity.ts
12775
+ var SSO_COOKIE = "nz_id";
12776
+ var JWKS_TTL_MS = 5 * 60 * 1000;
12777
+ var JWKS_RETRY_MS = 30 * 1000;
12778
+ var JWKS_TIMEOUT_MS = 5000;
12779
+ var MAX_TOKEN = 8192;
12780
+ var MAX_JWKS = 64 * 1024;
12781
+ function decode(part) {
12782
+ try {
12783
+ return JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
12784
+ } catch {
12785
+ return null;
12786
+ }
12787
+ }
12788
+
12789
+ class UsrVerifier {
12790
+ o;
12791
+ keys = [];
12792
+ fetchedAt = Number.NEGATIVE_INFINITY;
12793
+ pending = null;
12794
+ constructor(o) {
12795
+ this.o = o;
12796
+ }
12797
+ async verify(token) {
12798
+ if (!token || token.length > MAX_TOKEN)
12799
+ return null;
12800
+ const parts = token.split(".");
12801
+ if (parts.length !== 3)
12802
+ return null;
12803
+ const [h, p, s] = parts;
12804
+ const header = decode(h);
12805
+ if (!header || header.alg !== "ES256" || header.typ === "dlg+jwt" || typeof header.kid !== "string")
12806
+ return null;
12807
+ const key = await this.keyFor(header.kid, false) ?? await this.keyFor(header.kid, true);
12808
+ if (!key)
12809
+ return null;
12810
+ const signature = Buffer.from(s, "base64url");
12811
+ if (signature.length !== 64)
12812
+ return null;
12813
+ const ok = await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, signature, new TextEncoder().encode(`${h}.${p}`));
12814
+ if (!ok)
12815
+ return null;
12816
+ const claims = decode(p);
12817
+ if (!claims || claims.iss !== "usr" || claims.act !== undefined)
12818
+ return null;
12819
+ if (typeof claims.exp !== "number" || claims.exp * 1000 <= this.o.now())
12820
+ return null;
12821
+ if (typeof claims.sub !== "string" || claims.sub === "")
12822
+ return null;
12823
+ const roles = Array.isArray(claims.roles) ? claims.roles.filter((r) => typeof r === "string") : [];
12824
+ return { sub: claims.sub, roles };
12825
+ }
12826
+ async keyFor(kid, force) {
12827
+ const age = this.o.now() - this.fetchedAt;
12828
+ if (age < 0 || age >= JWKS_TTL_MS || force && age >= JWKS_RETRY_MS)
12829
+ await this.refresh();
12830
+ return this.keys.find((k) => k.kid === kid)?.key ?? null;
12831
+ }
12832
+ refresh() {
12833
+ if (!this.pending) {
12834
+ this.pending = this.load().finally(() => {
12835
+ this.pending = null;
12836
+ });
12837
+ }
12838
+ return this.pending;
12839
+ }
12840
+ async load() {
12841
+ this.fetchedAt = this.o.now();
12842
+ try {
12843
+ const res = await (this.o.fetchImpl ?? fetch)(`${this.o.url}/.well-known/jwks.json`, {
12844
+ headers: { Accept: "application/json" },
12845
+ redirect: "manual",
12846
+ signal: AbortSignal.timeout(JWKS_TIMEOUT_MS)
12847
+ });
12848
+ if (!res.ok)
12849
+ return;
12850
+ const text = await readBody(res, MAX_JWKS);
12851
+ if (text === null)
12852
+ return;
12853
+ const body = JSON.parse(text);
12854
+ if (!Array.isArray(body.keys))
12855
+ return;
12856
+ const keys = [];
12857
+ for (const k of body.keys) {
12858
+ if (typeof k.kid !== "string" || k.kty !== "EC" || k.crv !== "P-256")
12859
+ continue;
12860
+ try {
12861
+ const jwk = { kty: "EC", crv: "P-256", ...k.x !== undefined ? { x: k.x } : {}, ...k.y !== undefined ? { y: k.y } : {} };
12862
+ keys.push({ kid: k.kid, key: await crypto.subtle.importKey("jwk", jwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]) });
12863
+ } catch {}
12864
+ }
12865
+ this.keys = keys;
12866
+ } catch {}
12867
+ }
12868
+ }
12869
+ function refreshUrl(usrUrl, returnTo) {
12870
+ return `${usrUrl}/api/auth/sso/refresh?return=${encodeURIComponent(returnTo)}`;
12871
+ }
12872
+
12393
12873
  // plugins/tracker/src/web/app.ts
12394
12874
  var MEMBER_RECHECK_MS = 15 * 60 * 1000;
12395
12875
  var MEMBER_GRACE_MS = 24 * 60 * 60 * 1000;
12396
12876
  var MEMBER_CHECK_TIMEOUT_MS = 3000;
12397
12877
  var MEMBER_RETRY_MS = 60 * 1000;
12398
12878
  var LEFT_SERVER = `You are signed out. ${NOT_MEMBER}`;
12879
+ var USR_NO_COOKIE = "I could not confirm your usr sign-in. Try again in a minute, or run /web in Discord for a one-time link. If it keeps happening, tell an admin.";
12880
+ var USR_RECHECK_FAILED = "I could not check that you are a member of this tracker's server. Try again in a minute.";
12881
+ var USR_NOT_MEMBER = "Your usr account does not have this tracker's member role yet: ask an admin to run /allow for you in the server.";
12882
+ var USR_NOT_LINKED = "Your usr account is not linked to anyone on this tracker yet: ask an admin to run /allow for you in the server, then run /register.";
12883
+ var USR_BACK = "usr";
12884
+ var USR_GO = "go";
12885
+ var SIGNED_OUT_MS = 12 * 60 * 60 * 1000;
12399
12886
  var RECHECK_FAILED = "You are signed out: I could not check that you are still a member of this tracker's server.";
12400
12887
  var MAX_FORM_BYTES = 32 * 1024;
12401
12888
  function plain(status, text, extra = {}) {
@@ -12424,6 +12911,7 @@ function createWebHandler(w) {
12424
12911
  const base = `/${w.name}`;
12425
12912
  const SESSION_COOKIE = `__Secure-${w.name}-session`;
12426
12913
  const LOGIN_COOKIE = `__Secure-${w.name}-login`;
12914
+ const OUT_COOKIE = `__Secure-${w.name}-out`;
12427
12915
  const clear = (name) => cookie(name, "", { base, maxAgeSeconds: 0, sameSite: "Lax" });
12428
12916
  const inFlight = new Map;
12429
12917
  const failedAt = new Map;
@@ -12545,7 +13033,51 @@ function createWebHandler(w) {
12545
13033
  return htmlResponse(signInHelpPage(base, "That sign-in link has expired or was already used."), 400, { "Set-Cookie": clear(LOGIN_COOKIE) });
12546
13034
  }
12547
13035
  const { id } = d.sessions.create(user.id, now, link?.memberCheckedAt ?? null);
12548
- return redirect(`${base}/`, [cookie(SESSION_COOKIE, id, { base, maxAgeSeconds: SESSION_TTL_MS / 1000, sameSite: "Lax" }), clear(LOGIN_COOKIE)]);
13036
+ return redirect(`${base}/`, [cookie(SESSION_COOKIE, id, { base, maxAgeSeconds: SESSION_TTL_MS / 1000, sameSite: "Lax" }), clear(LOGIN_COOKIE), clear(OUT_COOKIE)]);
13037
+ }
13038
+ async function usrSignIn(d, usr, request, path, url, origin, stale) {
13039
+ const asked = url.searchParams.get(USR_BACK);
13040
+ if (readCookie(request, OUT_COOKIE) !== null && asked !== USR_GO && asked !== "1")
13041
+ return redirect(`${base}/signin?out=1`, stale);
13042
+ const identity = await usr.verifier.verify(readCookie(request, SSO_COOKIE));
13043
+ const help = (note) => htmlResponse(signInHelpPage(base, note), 403, stale[0] ? { "Set-Cookie": stale[0] } : {});
13044
+ const query = new URLSearchParams(url.searchParams);
13045
+ query.delete(USR_BACK);
13046
+ const rest = query.toString();
13047
+ if (!identity) {
13048
+ if (asked === "1")
13049
+ return help(USR_NO_COOKIE);
13050
+ const back = refreshUrl(usr.url, `${origin}${base}${path}?${rest ? `${rest}&` : ""}${USR_BACK}=1`);
13051
+ const h = new Headers({ Location: back, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" });
13052
+ for (const c of stale)
13053
+ h.append("Set-Cookie", c);
13054
+ return new Response(null, { status: 303, headers: h });
13055
+ }
13056
+ if (!identity.roles.includes(`${usr.app}:${MEMBER_ROLE}`))
13057
+ return help(USR_NOT_MEMBER);
13058
+ const userId = usr.links.userOfSubject(identity.sub);
13059
+ const user = userId ? await d.store.getUser(userId) : null;
13060
+ if (!user || !d.admissions.isRegistered(user.id))
13061
+ return help(USR_NOT_LINKED);
13062
+ const member = await recheck(d, user, null);
13063
+ if (member === "not-member")
13064
+ return help(NOT_MEMBER);
13065
+ if (member !== "ok")
13066
+ return help(USR_RECHECK_FAILED);
13067
+ const now = d.clock.now();
13068
+ const { id } = d.sessions.create(user.id, now, w.guildIds === null ? null : now.toISOString());
13069
+ return redirect(`${base}${path}${rest ? `?${rest}` : ""}`, [cookie(SESSION_COOKIE, id, { base, maxAgeSeconds: SESSION_TTL_MS / 1000, sameSite: "Lax" }), clear(OUT_COOKIE)]);
13070
+ }
13071
+ async function usrRevoked(d, request, user) {
13072
+ const usr = d.usr;
13073
+ if (!usr)
13074
+ return false;
13075
+ const identity = await usr.verifier.verify(readCookie(request, SSO_COOKIE));
13076
+ if (!identity || identity.sub !== usr.links.subjectOf(user.id) || identity.roles.includes(`${usr.app}:${MEMBER_ROLE}`))
13077
+ return false;
13078
+ d.sessions.deleteForUser(user.id);
13079
+ d.apiTokens.deleteForUser(user.id);
13080
+ return true;
12549
13081
  }
12550
13082
  async function settingsPost(d, v, form) {
12551
13083
  const hour = (form.get("hour") ?? "").trim();
@@ -12596,7 +13128,7 @@ function createWebHandler(w) {
12596
13128
  });
12597
13129
  }
12598
13130
  if (path === "/signin")
12599
- return only("GET") ?? htmlResponse(signInHelpPage(base, signInNote(url.searchParams)));
13131
+ return only("GET") ?? htmlResponse(signInHelpPage(base, signInNote(url.searchParams), Boolean(d.usr)));
12600
13132
  if (path === "/login")
12601
13133
  return method === "GET" ? loginGet(d, url) : loginPost(d, request);
12602
13134
  const r = route2(path);
@@ -12609,15 +13141,21 @@ function createWebHandler(w) {
12609
13141
  const auth = await authenticate(d, request);
12610
13142
  if (auth.kind === "signed-out")
12611
13143
  return htmlResponse(signInHelpPage(base, auth.note), 403, { "Set-Cookie": clear(SESSION_COOKIE) });
12612
- if (auth.kind !== "ok")
12613
- return redirect(`${base}/signin`, auth.kind === "stale" ? [clear(SESSION_COOKIE)] : []);
13144
+ if (auth.kind !== "ok") {
13145
+ const stale = auth.kind === "stale" ? [clear(SESSION_COOKIE)] : [];
13146
+ if (method === "GET" && d.usr && w.origin !== null)
13147
+ return usrSignIn(d, d.usr, request, path, url, w.origin, stale);
13148
+ return redirect(`${base}/signin`, stale);
13149
+ }
13150
+ if (await usrRevoked(d, request, auth.user))
13151
+ return htmlResponse(signInHelpPage(base, USR_NOT_MEMBER), 403, { "Set-Cookie": clear(SESSION_COOKIE) });
12614
13152
  if (admin && !auth.user.admin)
12615
13153
  return unknownPage(base);
12616
13154
  if (admin && !allowed.split(", ").includes(method))
12617
13155
  return plain(405, "Method not allowed", { Allow: allowed });
12618
13156
  const v = { base, user: auth.user, csrf: auth.session.csrf };
12619
13157
  const e = { d, v, queue: w.queue, reading };
12620
- const a = { d, v, queue: w.queue, memberOf: (id) => memberOf(id), signOutCookie: clear(SESSION_COOKIE) };
13158
+ const a = { d, v, queue: w.queue, memberOf: (id) => memberOf(id), signOutCookie: clear(SESSION_COOKIE), usrGuildId: w.guildIds?.[0] ?? null };
12621
13159
  if (method === "POST") {
12622
13160
  const form = await readForm(request);
12623
13161
  if (!form)
@@ -12627,7 +13165,10 @@ function createWebHandler(w) {
12627
13165
  switch (r.kind) {
12628
13166
  case "logout":
12629
13167
  d.sessions.delete(auth.id);
12630
- return redirect(`${base}/signin?out=1`, [clear(SESSION_COOKIE)]);
13168
+ return redirect(`${base}/signin?out=1`, [
13169
+ clear(SESSION_COOKIE),
13170
+ ...d.usr ? [cookie(OUT_COOKIE, "1", { base, maxAgeSeconds: SIGNED_OUT_MS / 1000, sameSite: "Lax" })] : []
13171
+ ]);
12631
13172
  case "settings":
12632
13173
  return settingsPost(d, v, form);
12633
13174
  case "new":
@@ -12643,6 +13184,7 @@ function createWebHandler(w) {
12643
13184
  case "token-revoke":
12644
13185
  return tokenRevokePost(d, v, r.id);
12645
13186
  case "admin-allow":
13187
+ case "admin-usr-link":
12646
13188
  case "admin-act":
12647
13189
  case "admin-ceiling":
12648
13190
  case "admin-lift":
@@ -12702,6 +13244,61 @@ function parseWebUrl(raw) {
12702
13244
  return url.origin;
12703
13245
  }
12704
13246
 
13247
+ // plugins/tracker/src/usr-links.ts
13248
+ function seqOf2(id) {
13249
+ return /^u[1-9][0-9]*$/.test(id) ? Number(id.slice(1)) : null;
13250
+ }
13251
+
13252
+ class UsrLinks {
13253
+ db;
13254
+ signedUp = new Set;
13255
+ constructor(db) {
13256
+ this.db = db;
13257
+ }
13258
+ isSignedUp(userId) {
13259
+ return this.signedUp.has(userId);
13260
+ }
13261
+ markSignedUp(userId) {
13262
+ this.signedUp.add(userId);
13263
+ }
13264
+ subjectOf(userId) {
13265
+ const seq = seqOf2(userId);
13266
+ if (seq === null)
13267
+ return null;
13268
+ const row = this.db.query("SELECT usr_subject FROM users WHERE seq = ?").get(seq);
13269
+ return row?.usr_subject ?? null;
13270
+ }
13271
+ userOfSubject(subject) {
13272
+ const row = this.db.query("SELECT seq FROM users WHERE usr_subject = ?").get(subject);
13273
+ return row ? `u${row.seq}` : null;
13274
+ }
13275
+ link(userId, subject) {
13276
+ const seq = seqOf2(userId);
13277
+ if (seq === null)
13278
+ throw new Error(`not a tracker user id: ${userId}`);
13279
+ return this.db.transaction(() => {
13280
+ const row = this.db.query("SELECT usr_subject FROM users WHERE seq = ?").get(seq);
13281
+ if (!row)
13282
+ return "gone";
13283
+ const holder = this.db.query("SELECT seq FROM users WHERE usr_subject = ?").get(subject);
13284
+ if (holder && holder.seq !== seq)
13285
+ return "taken";
13286
+ if (row.usr_subject === subject)
13287
+ return "unchanged";
13288
+ this.db.query("UPDATE users SET usr_subject = ? WHERE seq = ?").run(subject, seq);
13289
+ this.signedUp.delete(userId);
13290
+ return row.usr_subject === null ? "linked" : "relinked";
13291
+ }).immediate();
13292
+ }
13293
+ unlink(userId) {
13294
+ const seq = seqOf2(userId);
13295
+ if (seq === null)
13296
+ return;
13297
+ this.db.query("UPDATE users SET usr_subject = NULL WHERE seq = ?").run(seq);
13298
+ this.signedUp.delete(userId);
13299
+ }
13300
+ }
13301
+
12705
13302
  // plugins/tracker/src/index.ts
12706
13303
  var DB_DIR = "tracker";
12707
13304
  var DB_FILE = "tracker.sqlite";
@@ -12732,6 +13329,8 @@ function createPlugin(host, options = {}) {
12732
13329
  const roleGate = { roles: parseGuildRoles(host.env.TRACKER_GUILD_ROLES, guildIds), exempt: new Set(adminIds) };
12733
13330
  const webOrigin = parseWebUrl(host.env.TRACKER_WEB_URL);
12734
13331
  const cityHall = parseCityHallConfig(host.env);
13332
+ const usrConfig = parseUsrConfig(host.env);
13333
+ const usrClient = usrConfig ? createUsrClient({ config: usrConfig, ...options.usrFetch ? { fetchImpl: options.usrFetch } : {} }) : null;
12735
13334
  const budgetUnlimited = parseBudgetUnlimited(host.env[BUDGET_UNLIMITED_KEY]);
12736
13335
  const cityHallConfig = cityHall.config;
12737
13336
  const executeOn = cityHallConfig !== null || options.executor !== undefined;
@@ -12957,6 +13556,8 @@ function createPlugin(host, options = {}) {
12957
13556
  host.log.info(`execute lane on: city-hall ${cityHallConfig.url}, capability ${cityHallConfig.capability}`);
12958
13557
  else if ("missing" in cityHall && cityHall.missing.length > 0)
12959
13558
  host.log.warn(`execute lane is off, so /research is unavailable: ${cityHall.missing.join(", ")} not set`);
13559
+ if (usrConfig)
13560
+ host.log.info(`usr link on: ${usrConfig.url}, app ${usrConfig.app}`);
12960
13561
  deps = {
12961
13562
  store: openedStore,
12962
13563
  admissions: new Admissions(opened),
@@ -12979,7 +13580,14 @@ function createPlugin(host, options = {}) {
12979
13580
  webEditor: webOrigin !== null,
12980
13581
  research: executeOn,
12981
13582
  bgg: bgg !== null,
12982
- inbox
13583
+ inbox,
13584
+ usr: usrConfig && usrClient ? {
13585
+ client: usrClient,
13586
+ url: usrConfig.url,
13587
+ verifier: new UsrVerifier({ url: usrConfig.url, now: () => clock.now().getTime(), ...options.usrFetch ? { fetchImpl: options.usrFetch } : {} }),
13588
+ app: usrConfig.app,
13589
+ links: new UsrLinks(opened)
13590
+ } : null
12983
13591
  };
12984
13592
  if (guildIds === null)
12985
13593
  host.log.warn("TRACKER_GUILD_ID is unset: no membership gate, only the admission list");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rackbops/plugin-tracker",
3
- "version": "0.19.0",
3
+ "version": "0.23.0",
4
4
  "description": "The task tracker for Rackbops Clerk: reminders, renewals, price drops, one-off research and an interest scout (through city-hall, with their findings), a want-list watcher (listing pages you paste, BoardGameGeek through its API once its token is set, and eBay and BoardGameGeek listings you send in through the task API, never read by the tracker; with the model checking each new listing and its seller first, through city-hall) by slash command, delivered by DM with buttons, and a daily DM at each person's hour of what is due today and overdue, on docket's SQLite store (/allow, /register, /remind, /renewal, /price, /research, /scout, /want, /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 (research requests too, made but not changed, and scouts, made and changed), an admin view (with the DMs that did not arrive, raising a person's daily model ceiling, and daily model usage per person), an operator switch that turns the daily budgets off for the alpha, forget-me, and a JSON task API with personal API tokens",
5
5
  "type": "module",
6
6
  "main": "dist/plugin.js",
@@ -99,6 +99,27 @@
99
99
  "required": false,
100
100
  "secret": true,
101
101
  "description": "BoardGameGeek's XML API Bearer token for the tracker's registered application (boardgamegeek.com/using_the_xml_api). Set = /want source: bgg watches a game's BGG marketplace listings through BGG's API; unset = /want source: bgg makes a watch whose listings are sent in through the task API instead, as for eBay. Never logged."
102
+ },
103
+ {
104
+ "key": "TRACKER_USR_URL",
105
+ "format": "^https://[A-Za-z0-9.-]+(:[0-9]{1,5})?/?$",
106
+ "required": false,
107
+ "secret": false,
108
+ "description": "The https origin of our usr (Rackbops/usr), e.g. https://id.example.com (no path): turns the usr link on, with TRACKER_USR_KEY. People stay in the tracker; each is linked to a usr account by usr's user id. Unset = the usr link is off and nothing changes."
109
+ },
110
+ {
111
+ "key": "TRACKER_USR_KEY",
112
+ "format": "^\\S+$",
113
+ "required": false,
114
+ "secret": true,
115
+ "description": "The tracker's usr API key, holding usr:discord only (never tracker:admin or tracker:register), with a usr Discord service set to this app. Required with TRACKER_USR_URL; set without it refuses to load. Never logged."
116
+ },
117
+ {
118
+ "key": "TRACKER_USR_APP",
119
+ "format": "^[a-z0-9]+(-[a-z0-9]+)*$",
120
+ "required": false,
121
+ "secret": false,
122
+ "description": "The usr app the tracker's roles live under (tracker:member, tracker:register); it must match the app on the key's usr Discord service row, which is what usr goes by. Unset = tracker. Needs TRACKER_USR_URL."
102
123
  }
103
124
  ]
104
125
  }