@rackbops/plugin-tracker 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +13 -13
  2. package/dist/plugin.js +32 -18
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -33,10 +33,10 @@ as in a server (plan 5.5, item 39).
33
33
  | `/settings hour hour` | registered | The preferred hour; recurring reminders with no time of their own move to it. |
34
34
  | `/web` | registered | A one-time link to sign in to the web area (below), good for 10 minutes and one use. Says so when `TRACKER_WEB_URL` is unset. |
35
35
 
36
- **Gates.** Every command and button checks, in order: membership of the `TRACKER_GUILD_ID`
37
- server (when set), admission (in the tracker's store), then what the action needs (an admin for
38
- `/allow`, admission for `/register`, registration for the rest). A person nobody admitted is told
39
- to ask an admin to `/allow` them. Decline and opt-out skip the membership and registration gates,
36
+ **Gates.** Every command and button checks, in order: membership of a `TRACKER_GUILD_ID`
37
+ server (when set; a member of any listed server passes), admission (in the tracker's store), then
38
+ what the action needs (an admin for `/allow`, admission for `/register`, registration for the
39
+ rest). A person nobody admitted is told to ask an admin to `/allow` them. Decline and opt-out skip the membership and registration gates,
40
40
  so anyone can always stop the messages; the Reply button checks membership when its modal is sent.
41
41
 
42
42
  **Buttons.** The owner's DM carries Done, Snooze 1h and Reply; a recipient's copy carries the
@@ -131,10 +131,10 @@ other than GET or POST (or the wrong one of the two for a path) is 405.
131
131
 
132
132
  **Membership on the web.** With `TRACKER_GUILD_ID` set, `/web` issues a link only to a member,
133
133
  and the session remembers when that was confirmed. A web request more than 15 minutes after the
134
- last confirmation re-checks with one member lookup (about 3 seconds at most, outside the write
135
- queue), through the discord.js Client of an interaction the plugin has handled since it started
136
- (the host API has no member lookup of its own; the Client is held in memory, never stored).
137
- Not a member: every session of theirs ends, on a page that says why. A member: the time is
134
+ last confirmation re-checks membership (one lookup per listed server, all at once, the first yes enough; about 3
135
+ seconds at most, outside the write queue), through the discord.js Client of an interaction the
136
+ plugin has handled since it started (the host API has no member lookup of its own; the Client is
137
+ held in memory, never stored). Not a member of any listed server: every session of theirs ends, on a page that says why. A member: the time is
138
138
  refreshed. A failed or slow lookup, or no interaction yet since a restart: they stay signed in
139
139
  while the last confirmation is under 24 hours old, and are then signed out and told to run `/web`
140
140
  again. Concurrent requests share one lookup per person, and after a failed one that person is not
@@ -256,9 +256,9 @@ API could be called by any script on it. A token acts as its owner with the owne
256
256
  and only on the owner's own tasks -- an admin's token included; an admin's wider reads stay on the
257
257
  signed-in web pages. Every request re-reads the owner, as the web does a session: a person no
258
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).
259
+ is re-checked on the web's schedule (after 15 minutes, one check, shared with the web), and one who
260
+ has left every listed server loses every token and every session; a lookup that keeps failing
261
+ lets them on for 24 hours from the last confirmation, then answers 503 until one succeeds (the token is kept).
262
262
  A token is looked up by the SHA-256 of what was sent, so the comparison is over a hash the sender
263
263
  cannot steer.
264
264
 
@@ -321,7 +321,7 @@ and a `PATCH` naming it is refused (`unknown_field`). A write answers
321
321
  | 401 | `unauthorized` | No `Authorization` header. With `WWW-Authenticate: Bearer realm="tracker"`. |
322
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
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. |
324
+ | 403 | `not_member` | The owner has left every `TRACKER_GUILD_ID` server; their tokens are revoked. |
325
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
326
  | 405 | `method_not_allowed` | With `Allow`. |
327
327
  | 409 | `conflict` | A finished task edited, or a pause of a task not active (a resume of one not paused). |
@@ -378,7 +378,7 @@ $ curl -s https://clerk.example.com/tracker/api/v1/tasks/t9 -H "Authorization: B
378
378
  | Env key | Secret | Meaning |
379
379
  |---|---|---|
380
380
  | `TRACKER_ADMIN_DISCORD_IDS` | no | Comma-separated Discord user ids (spaces around commas allowed; an empty entry, as from a trailing comma, is refused and the plugin does not load) made admin at start. Unset = none. Removing an id does not revoke it. |
381
- | `TRACKER_GUILD_ID` | no | The Discord server whose members may use the tracker. Checked through the interaction's client with a single-member lookup (no privileged intent). Unset = no membership gate, and a warning is logged each time the plugin activates; a malformed value refuses to load. |
381
+ | `TRACKER_GUILD_ID` | no | The Discord server whose members may use the tracker, or a comma-separated list of them (spaces around commas allowed; a repeated id counts once): a member of any listed server passes. Checked through the interaction's client with a single-member lookup per server (no privileged intent), all asked at once; a yes from any server is a member, a no from every server is not, and otherwise the answer is unknown (refused, never revoked). Inside a listed server, the person running a command needs no lookup. Unset = no membership gate, and a warning is logged each time the plugin activates; a malformed or empty entry anywhere in the list refuses to load, naming it. One store and one admission list serve every listed server; there are no per-server admins. |
382
382
  | `TRACKER_WEB_URL` | no | The https origin the bot's HTTP is reached at through its tunnel, e.g. `https://clerk.example.com` (no path). `/web` links and the allowed `Origin` come from it, never from a request's `Host`. Unset = no web area (`/web` says so, the pages answer 404); anything but a bare https origin refuses to load. |
383
383
 
384
384
  `/tracker/healthz` and the web area need the bot's `HTTP_PORT` set; without it there is no HTTP at all.
package/dist/plugin.js CHANGED
@@ -1841,13 +1841,18 @@ function decideAccess(input) {
1841
1841
  return NOT_REGISTERED;
1842
1842
  return null;
1843
1843
  }
1844
- function parseGuildId(raw) {
1844
+ function parseGuildIds(raw) {
1845
1845
  if (raw === undefined || raw.trim() === "")
1846
1846
  return null;
1847
- const id = raw.trim();
1848
- if (!/^[0-9]{17,20}$/.test(id))
1849
- throw new Error(`TRACKER_GUILD_ID: "${id}" is not a Discord server id`);
1850
- return id;
1847
+ const ids = [];
1848
+ for (const entry of raw.split(",")) {
1849
+ const id = entry.trim();
1850
+ if (!/^[0-9]{17,20}$/.test(id))
1851
+ throw new Error(`TRACKER_GUILD_ID: "${id}" is not a Discord server id`);
1852
+ if (!ids.includes(id))
1853
+ ids.push(id);
1854
+ }
1855
+ return ids;
1851
1856
  }
1852
1857
 
1853
1858
  // plugins/tracker/src/admissions.ts
@@ -2372,13 +2377,22 @@ var EPHEMERAL = { flags: MessageFlags.Ephemeral };
2372
2377
  var NO_MENTIONS = { allowedMentions: { parse: [] } };
2373
2378
  var UNKNOWN_MEMBER = 10007;
2374
2379
  var UNKNOWN_USER = 10013;
2375
- async function lookupMembership(interaction, guildId, discordId, log) {
2376
- if (guildId === null)
2380
+ async function lookupMembership(interaction, guildIds, discordId, log) {
2381
+ if (guildIds === null)
2377
2382
  return "not-checked";
2378
- if (interaction.guildId === guildId && interaction.user.id === discordId)
2383
+ if (interaction.guildId !== null && guildIds.includes(interaction.guildId) && interaction.user.id === discordId)
2379
2384
  return "member";
2385
+ const lookups = guildIds.map((guildId) => memberOf(interaction.client, guildId, discordId, log));
2386
+ const firstYes = new Promise((resolve) => {
2387
+ for (const l of lookups)
2388
+ l.then((a) => a === "member" && resolve("member"));
2389
+ });
2390
+ const all = Promise.all(lookups).then((answers) => answers.includes("unknown") ? "unknown" : answers.includes("member") ? "member" : "not-member");
2391
+ return Promise.race([firstYes, all]);
2392
+ }
2393
+ async function memberOf(client, guildId, discordId, log) {
2380
2394
  try {
2381
- const guild = await interaction.client.guilds.fetch(guildId);
2395
+ const guild = await client.guilds.fetch(guildId);
2382
2396
  await guild.members.fetch({ user: discordId, force: true });
2383
2397
  return "member";
2384
2398
  } catch (err) {
@@ -3687,7 +3701,7 @@ function createSurface(w) {
3687
3701
  const ctx = {
3688
3702
  deps: w.deps,
3689
3703
  queue: w.queue ?? serial(),
3690
- membershipOf: (interaction, discordId) => w.membership ? w.membership(interaction, discordId) : lookupMembership(interaction, w.guildId, discordId, w.log),
3704
+ membershipOf: (interaction, discordId) => w.membership ? w.membership(interaction, discordId) : lookupMembership(interaction, w.guildIds, discordId, w.log),
3691
3705
  log: w.log
3692
3706
  };
3693
3707
  const membershipOf = (interaction, discordId) => ctx.membershipOf(interaction, discordId);
@@ -7778,7 +7792,7 @@ function createWebHandler(w) {
7778
7792
  return lookupWithin(() => shared, MEMBER_CHECK_TIMEOUT_MS);
7779
7793
  }
7780
7794
  async function recheck(d, user, checkedAt) {
7781
- if (w.guildId === null)
7795
+ if (w.guildIds === null)
7782
7796
  return "ok";
7783
7797
  const now = d.clock.now().getTime();
7784
7798
  const age = checkedAt ? now - Date.parse(checkedAt) : Number.POSITIVE_INFINITY;
@@ -7818,7 +7832,7 @@ function createWebHandler(w) {
7818
7832
  return { kind: "signed-out", note: RECHECK_FAILED };
7819
7833
  }
7820
7834
  async function memberOf(discordId) {
7821
- if (w.guildId === null)
7835
+ if (w.guildIds === null)
7822
7836
  return "not-checked";
7823
7837
  return await lookupWithin(() => w.membership(discordId), MEMBER_CHECK_TIMEOUT_MS) ?? "unknown";
7824
7838
  }
@@ -8024,7 +8038,7 @@ var NO_DM = {
8024
8038
  };
8025
8039
  function createPlugin(host, options = {}) {
8026
8040
  const adminIds = parseAdminIds(host.env.TRACKER_ADMIN_DISCORD_IDS);
8027
- const guildId = parseGuildId(host.env.TRACKER_GUILD_ID);
8041
+ const guildIds = parseGuildIds(host.env.TRACKER_GUILD_ID);
8028
8042
  const webOrigin = parseWebUrl(host.env.TRACKER_WEB_URL);
8029
8043
  const clock = options.clock ?? { now: () => new Date };
8030
8044
  const types = options.types ?? TRACKER_TYPES;
@@ -8040,10 +8054,10 @@ function createPlugin(host, options = {}) {
8040
8054
  const queue = serial();
8041
8055
  const surface = createSurface({
8042
8056
  deps: () => deps,
8043
- guildId,
8057
+ guildIds,
8044
8058
  log: host.log,
8045
8059
  queue,
8046
- web: webOrigin ? { origin: webOrigin, name: host.name, gated: guildId !== null } : null,
8060
+ web: webOrigin ? { origin: webOrigin, name: host.name, gated: guildIds !== null } : null,
8047
8061
  ...options.membership ? { membership: options.membership } : {}
8048
8062
  });
8049
8063
  async function tick(kind, signal) {
@@ -8105,14 +8119,14 @@ function createPlugin(host, options = {}) {
8105
8119
  const clientMembership = async (discordId) => {
8106
8120
  if (!discordClient)
8107
8121
  return null;
8108
- return lookupMembership({ guildId: null, user: { id: discordId }, client: discordClient }, guildId, discordId, host.log);
8122
+ return lookupMembership({ guildId: null, user: { id: discordId }, client: discordClient }, guildIds, discordId, host.log);
8109
8123
  };
8110
8124
  const web = createWebHandler({
8111
8125
  name: host.name,
8112
8126
  origin: webOrigin,
8113
8127
  deps: () => deps,
8114
8128
  queue,
8115
- guildId,
8129
+ guildIds,
8116
8130
  membership: options.webMembership ?? clientMembership
8117
8131
  });
8118
8132
  const interactions = surface.interactions;
@@ -8190,7 +8204,7 @@ function createPlugin(host, options = {}) {
8190
8204
  lanes,
8191
8205
  webEditor: webOrigin !== null
8192
8206
  };
8193
- if (guildId === null)
8207
+ if (guildIds === null)
8194
8208
  host.log.warn("TRACKER_GUILD_ID is unset: no membership gate, only the admission list");
8195
8209
  health.blocked = typeof host.dm === "function" ? null : "this bot has no host.dm (it predates rackbops-discord-bot#736)";
8196
8210
  health.activatedAt = clock.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rackbops/plugin-tracker",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
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",
@@ -32,10 +32,10 @@
32
32
  },
33
33
  {
34
34
  "key": "TRACKER_GUILD_ID",
35
- "format": "^[0-9]{17,20}$",
35
+ "format": "^ *[0-9]{17,20}( *, *[0-9]{17,20})* *$",
36
36
  "required": false,
37
37
  "secret": false,
38
- "description": "The Discord server id whose members may use the tracker (the membership gate), checked on every command and button, in DMs too. Unset = no membership gate: only the admission list (/allow) applies."
38
+ "description": "The Discord server id whose members may use the tracker (the membership gate), or a comma-separated list of server ids (spaces around commas allowed): a member of any listed server passes. Checked on every command and button, in DMs too. Unset = no membership gate: only the admission list (/allow) applies."
39
39
  },
40
40
  {
41
41
  "key": "TRACKER_WEB_URL",