All files / src/auth grantStore.ts

100% Statements 188/188
100% Branches 71/71
100% Functions 14/14
100% Lines 188/188

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 18912x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 1x 65x 65x 65x 1x 1x 59x 59x 59x 59x 2x 2x 54x 59x 1x 1x 160x 160x 160x 160x 144x 144x 144x 2x 160x 1x 1x 67x 67x 67x 67x 65x 6x 6x 67x 1x 1x 46x 46x 46x 22x 3x 3x 3x 3x 3x 3x 9x 9x 8x 46x 1x 1x 8x 8x 8x 1x 1x 7x 7x 7x 7x 11x 11x 11x 11x 5x 5x 4x 11x 7x 8x 8x 8x 5x 3x 3x 2x 8x 7x 1x 1x 56x 56x 1x 1x 43x 43x 43x 43x 46x 46x 46x 5x 5x 5x 5x 5x 5x 5x 5x 1x 1x 1x 5x 3x 46x 43x 41x 41x 41x 41x 41x 41x 43x 1x 1x 1x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 27x 1x 35x 35x 35x 35x 35x 35x 35x 35x 35x 35x 35x 35x 35x 33x 35x 35x 10x 10x 10x 10x 10x 10x 30x 6x 12x 12x 12x 12x 35x 35x  
import { lstat, open, unlink } from "node:fs/promises"
import { setTimeout as delay } from "node:timers/promises"
import { z } from "zod"
 
import {
  TwitchApiService,
  TwitchFailure,
  type BotToken,
} from "../services/TwitchApiService"
import type { TwitchCredentials } from "@workspace/env/twitch"
import {
  readPrivateJson,
  writePrivateJson,
  syncPrivateDirectory,
} from "./privateFile"
 
const grantSchema = z.object({
  version: z.literal(1),
  clientId: z.string().min(1),
  botUserId: z.string().min(1),
  accessToken: z.string().min(1),
  refreshToken: z.string().min(1),
  expiresAt: z.number().finite().positive(),
})
export type BotGrant = z.infer<typeof grantSchema>
 
export class GrantStore {
  constructor(
    readonly path: string,
    private readonly lockTimeoutMs = 10000
  ) {}
 
  async read(): Promise<BotGrant> {
    const rotated = `${this.path}.rotated`
    if (await this.exists(rotated)) return this.readGrant(rotated)
    if (await this.exists(`${this.path}.refreshing`))
      throw new Error(
        "Bot refresh was interrupted before recovery was saved. Reauthorize the bot; the old grant must not be reused."
      )
    return this.readGrant(this.path)
  }
 
  private async exists(path: string): Promise<boolean> {
    try {
      await lstat(path)
      return true
    } catch (error) {
      if (error instanceof Error && "code" in error && error.code === "ENOENT")
        return false
      throw error
    }
  }
 
  private async readGrant(path: string): Promise<BotGrant> {
    try {
      const result = grantSchema.safeParse(await readPrivateJson(path, 16384))
      if (!result.success) throw new Error("Invalid bot grant file.")
      return result.data
    } catch {
      throw new Error("Cannot read a valid private bot grant file.")
    }
  }
 
  async recoverRotation(): Promise<void> {
    const rotated = `${this.path}.rotated`
    if (!(await this.exists(rotated))) return
    await this.write(await this.readGrant(rotated))
    await unlink(`${this.path}.refreshing`).catch((error: unknown) => {
      if (!(
        error instanceof Error &&
        "code" in error &&
        error.code === "ENOENT"
      ))
        throw error
    })
    await unlink(rotated)
    await syncPrivateDirectory(this.path)
  }
 
  async prepareRotation(grant: BotGrant): Promise<void> {
    // Reserve a durable interruption marker before making the remote refresh.
    await new GrantStore(`${this.path}.refreshing`).write(grant, false)
  }
 
  async persistRotation(grant: BotGrant): Promise<void> {
    // A separate, durable record survives failures replacing the primary file.
    // Retry local persistence only; never repeat the remote refresh request.
    const recovery = new GrantStore(`${this.path}.rotated`)
    for (let attempt = 0; ; attempt++) {
      try {
        await recovery.write(grant, false)
        break
      } catch (error) {
        if (attempt === 2) throw error
        await delay(50)
      }
    }
    for (let attempt = 0; ; attempt++) {
      try {
        await this.recoverRotation()
        return
      } catch (error) {
        if (attempt === 2) throw error
        await delay(50)
      }
    }
  }
 
  async write(grant: BotGrant, overwrite = true): Promise<void> {
    await writePrivateJson(this.path, grantSchema.parse(grant), overwrite)
  }
 
  async locked<T>(operation: () => Promise<T>): Promise<T> {
    const lock = `${this.path}.lock`
    const deadline = Date.now() + this.lockTimeoutMs
    let handle
    while (!handle) {
      try {
        handle = await open(lock, "wx", 0o600)
      } catch (error) {
        if (!(
          error instanceof Error &&
          "code" in error &&
          error.code === "EEXIST"
        ))
          throw new Error("Cannot acquire bot grant lock.", { cause: error })
        if (Date.now() >= deadline)
          throw new Error(
            "Bot grant is locked. Check for an active operator/runtime before removing a stale lock.",
            { cause: error }
          )
        await delay(50)
      }
    }
    try {
      await handle.writeFile(String(process.pid))
      return await operation()
    } finally {
      await handle.close()
      await unlink(lock)
    }
  }
}
 
export function createGrant(
  token: BotToken,
  config: TwitchCredentials
): BotGrant {
  return {
    version: 1,
    clientId: config.TWITCH_CLIENT_ID,
    botUserId: config.TWITCH_BOT_USER_ID,
    accessToken: token.access_token,
    refreshToken: token.refresh_token,
    expiresAt: Date.now() + token.expires_in * 1000,
  }
}
 
export async function ensureBotGrant(
  api: TwitchApiService,
  store: GrantStore,
  config: TwitchCredentials
): Promise<string> {
  return store.locked(async () => {
    await store.recoverRotation()
    const grant = await store.read()
    if (grant.clientId !== config.TWITCH_CLIENT_ID)
      throw new TwitchFailure("wrongClient")
    if (grant.botUserId !== config.TWITCH_BOT_USER_ID)
      throw new TwitchFailure("wrongBot")
    try {
      await api.validateBotToken(grant.accessToken)
      return grant.accessToken
    } catch (error) {
      if (!(
        error instanceof TwitchFailure &&
        (error.code === "unauthorized" || error.code === "expiredToken")
      ))
        throw error
    }
    await store.prepareRotation(grant)
    const token = await api.refreshBotToken(grant.refreshToken)
    // Preserve refresh rotation even if the following validation request fails.
    await store.persistRotation(createGrant(token, config))
    await api.validateBotToken(token.access_token)
    return token.access_token
  })
}