oauthLinks.ts
@file Every statement this Worker runs against oauth_links. Same rule…
Overview
@file Every statement this Worker runs against oauth_links. Same rule
as every other D1 module here: no SQL built by concatenation, every
value a bound parameter. Token encryption itself lives in
tokenCipher.ts — this file only ever handles already-encrypted blobs.
depends on tokenCipher.ts · used by linkedRoles.ts scheduled.ts
API
Fexport async function saveDiscordAccessToken(db: D1Database | undefined, discordUserId: string, accessToken: string, encryptionKey: string, now: number = Date.now()): Promise<void>
The Discord leg of the flow: parks the encrypted access token where the GitHub leg can pick it up, leaving any already-linked GitHub identity untouched (only the Discord columns are in the UPDATE branch). The access token is the only piece of the grant kept. Discord also returns a refresh token and an expiry, and both used to be stored; the only thing that ever read them was a refresh branch guarding against a token expiring between the two legs, which cannot happen — the legs are one redirect apart and the grant lasts days. Storing a long-lived refresh token to guard an impossible case was the worst trade in this file.
handleDiscordCallback · calls encryptToken, need, runFexport async function saveGithubLink(db: D1Database | undefined, discordUserId: string, github: { githubId: number; githubLogin: string }, linkedAt: number): Promise<boolean>
The GitHub leg: the row must already exist from saveDiscordTokens —
this only ever UPDATEs, never INSERTs, since a GitHub identity with no
Discord tokens behind it is not a state this flow can act on. Returns
whether a row was actually updated.
handleGithubCallback · calls need, runFexport async function decryptedAccessToken(row: OAuthLinkRow, encryptionKey: string): Promise<string | null>
Decrypts the parked access token for a row, or null once it has been
scrubbed. Split out from getLink so a caller that only needs to know
*whether* someone is linked (no token material) never has to touch the
cipher at all.
pushMetadataNow · calls decryptTokenFexport async function scrubLinkSecrets(db: D1Database | undefined, discordUserId: string): Promise<void>
Empty every credential-bearing column, leaving who linked and when.
Called once the metadata push succeeds, which is the last moment anything
needs the token or the GitHub login: Discord keeps the pushed metadata on
its own side, and re-running /linked-role re-authorises from scratch
rather than reusing what is here. github_id stays because it is an opaque
number that identifies nobody on its own and is what a future re-push would
key on; the login, which is a username, does not.
pushMetadataNow · calls need, runFexport async function purgeAbandonedLinks(db: D1Database | undefined, olderThan: number): Promise<number>
Delete rows abandoned between the two OAuth legs. Somebody who authorises Discord and then closes the tab leaves a live token behind that no completion path will ever scrub. Without this the "in-flight only" claim on the table would hold for every user who finishes and for none who does not, which is the wrong way round: an abandoned flow is exactly the case where nobody is watching. Returns how many rows went.
runAbandonedLinkPurge · calls need, runUndocumented (6)
OAuthLinksUnavailable, OAuthLinksUnavailable.constructor, need, run, getLink, markMetadataPushed