Mr Folio turns a supporter code into a supporter role - #152
Conversation
/redeem checks a code with the Ko-fi worker's own checker, hands out Coffee, Backer or Builder, and the daily sweep takes the role back the day after the code ends. The reply is ephemeral, so a code never lands in a channel. Every code minted so far is tier 1, so the code cannot tell Coffee from Backer. The thanks scope marks Builder, a tier-2 code will mark Backer, and everything else is Coffee. One code, one person, through discord_roles in the shared D1 database. Ownership is checked before the full check, because the full check starts a months-code's clock and a stranger pasting someone else's code must not touch it. A 403 from Discord says the role order needs fixing and records nothing. Live: the table is in folio-codes, DISCORD_BOT_TOKEN is set, the sweep is scheduled for 06:17 UTC, and all seven commands are registered in the Folio server. 86 tests across the bot, the announcer and the Ko-fi worker, with the redeem ones signing real codes with a throwaway key against real SQLite loaded from the actual schema.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fc548d90a2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const existing = await env.DB.prepare('SELECT user_id, removed_at FROM discord_roles WHERE serial = ?') | ||
| .bind(code.serial) | ||
| .first() |
There was a problem hiding this comment.
Reserve the serial before granting the role
When two users submit the same previously unused code concurrently, both requests can complete this SELECT before either inserts a row, and both Discord addRole calls therefore succeed. The later upsert deliberately does not update user_id, so only one user is recorded while the other keeps an untracked role that the sweep can never remove; claim the serial atomically before granting the role.
Useful? React with 👍 / 👎.
| const { results: due } = await env.DB.prepare( | ||
| 'SELECT serial, user_id, guild_id, role_id FROM discord_roles WHERE removed_at IS NULL AND ends_on IS NOT NULL AND ends_on < ?', | ||
| ) |
There was a problem hiding this comment.
Include withdrawn codes in the expiry sweep
If a serial is added to WITHDRAWN after it has been redeemed, new checks reject it but this query never selects its existing role. A permanent withdrawn code therefore retains the role forever, and a timed code retains it until its original expiry, defeating withdrawal of a shared or refunded code; the sweep should also treat withdrawn serials as due.
Useful? React with 👍 / 👎.
| return 'That code has already been redeemed by someone else. If it was yours, message McCal and it will be sorted.' | ||
| } | ||
|
|
||
| const check = await checkBetaCode(env, text, now) |
There was a problem hiding this comment.
Start the code window only after a successful grant
For a previously unseen months-code, checkBetaCode inserts beta_seen here before Discord is asked to add the role. If the bot lacks permission, its role is ordered incorrectly, Discord is unavailable, or the later database write fails, the command reports failure but the paid validity window has already begun, so repeated infrastructure failures consume the supporter's access without granting the requested role.
Useful? React with 👍 / 👎.
| await env.DB.prepare( | ||
| `INSERT INTO discord_roles (serial, user_id, guild_id, role_id, granted_at, ends_on, removed_at) | ||
| VALUES (?, ?, ?, ?, ?, ?, NULL) | ||
| ON CONFLICT(serial) DO UPDATE SET role_id = excluded.role_id, ends_on = excluded.ends_on, removed_at = NULL`, |
There was a problem hiding this comment.
Undo a role grant when persistence fails
If Discord's role grant succeeds but this D1 write throws, the caller converts the exception into the generic unavailable reply while leaving the role assigned with no discord_roles row. That role is then invisible to the expiry sweep and can remain indefinitely; reserve/persist the claim before the external grant or remove the role as compensation when recording fails.
Useful? React with 👍 / 👎.
|
Brought up to date with main (2cb6c28 and #228), no conflicts: the bot's 44 tests and the Ko-fi worker's 36 pass. This is the code the live Mr Folio ran from 25 Sep, and it was never merged. The bot was redeployed on 2 Oct from main, which has no /redeem, no D1 binding, no supporter-key and role variables and no daily sweep, so merging this is what puts /redeem back. After the merge the bot has to be redeployed from main once more (tools/folio-bot, wrangler deploy). |
/redeem <code>is live in the Folio server. It checks the code, hands out Coffee, Backer or Builder, and a daily sweep takes the role back the day after the code ends. Only the person who ran it sees the reply, so a code never lands in a channel.It checks codes with the Ko-fi worker's own checker, not a copy.
redeem.mjsimportscheckBetaCodefromtools/kofi-worker/beta.js: the same signature againstBetaKeys.SUPPORTER, the same withdrawn list, and the same first-seen day inbeta_seenfor a months-code. So the role ends on the day the code ends everywhere else. The signing key still never leaves the Mac.Which role. I decoded all 200 minted codes to check, rather than assuming. Every one is tier 1, so a code cannot tell Coffee from Backer. The
thanksscope marks Builder (the Builder tier and tips of $15 and up), a code minted with--tier 2will mark Backer, and anything else is Coffee. That isroleFor, one function, if you want it mapped differently.One code, one person, through a new
discord_rolestable in the sharedfolio-codesdatabase. Ownership is checked before the full check, because the full check starts a months-code's clock, and a stranger pasting someone else's code must not touch it.A role Discord will not hand out gets a plain answer, that Mr Folio's role has to sit above that role, and nothing is recorded, so the person can try again once it is fixed.
Checked:
schema.sql. One of the phase 1 tests caught/redeembeing registered without a handler, which is routed on purpose, and now says so.discord_rolesis infolio-codes(created withIF NOT EXISTSonly),DISCORD_BOT_TOKENis set, the sweep is scheduled for 06:17 UTC, all seven commands are registered, and the Worker still refuses unsigned requests.Not checked yet: a real
/redeemend to end, because the role order is stillCoffee > Backer > Builder > Mr Folioon Discord's side. Until Mr Folio sits above Builder,/redeemwill answer with the role-order message instead of a role.