@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.
- package/README.md +13 -13
- package/dist/plugin.js +32 -18
- 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
|
|
37
|
-
server (when set), admission (in the tracker's store), then
|
|
38
|
-
`/allow`, admission for `/register`, registration for the
|
|
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
|
|
135
|
-
queue), through the discord.js Client of an interaction the
|
|
136
|
-
(the host API has no member lookup of its own; the Client is
|
|
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
|
|
260
|
-
has left
|
|
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
|
|
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
|
|
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
|
|
1844
|
+
function parseGuildIds(raw) {
|
|
1845
1845
|
if (raw === undefined || raw.trim() === "")
|
|
1846
1846
|
return null;
|
|
1847
|
-
const
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
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,
|
|
2376
|
-
if (
|
|
2380
|
+
async function lookupMembership(interaction, guildIds, discordId, log) {
|
|
2381
|
+
if (guildIds === null)
|
|
2377
2382
|
return "not-checked";
|
|
2378
|
-
if (interaction.guildId
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
8057
|
+
guildIds,
|
|
8044
8058
|
log: host.log,
|
|
8045
8059
|
queue,
|
|
8046
|
-
web: webOrigin ? { origin: webOrigin, name: host.name, gated:
|
|
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 },
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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),
|
|
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",
|