import { env } from "cloudflare:workers"; import type { RepoRow } from "~/lib/types"; import { artifactsCall } from "./artifacts-call"; import { artifactsBinding } from "./artifacts-shards"; const TOKEN_TTL_SECONDS = 3600; const TOKEN_CACHE_SECONDS = 3000; /** A cached token must outlive its longest use (a git request) by this much. */ const TOKEN_EXPIRY_MARGIN_SECONDS = 600; /** KV's minimum expirationTtl. */ const MIN_KV_TTL_SECONDS = 60; export type TokenScope = "read" | "write"; export function tokenSecret(plaintext: string): string { return plaintext.split("?expires=")[0]; } /** Seconds until the `?expires=` suffix of a token plaintext, or null when it has none. */ export function tokenLifetimeSeconds(plaintext: string, now = Date.now()): number | null { const expires = Number(plaintext.split("?expires=")[1]); return Number.isFinite(expires) && expires > 0 ? Math.floor(expires - now / 1000) : null; } function tokenCacheKey(artifactsName: string, scope: TokenScope): string { return `artifacts-token:${artifactsName}:${scope}`; } async function cacheToken(artifactsName: string, scope: TokenScope, plaintext: string): Promise { const lifetime = tokenLifetimeSeconds(plaintext) ?? TOKEN_TTL_SECONDS; const ttl = Math.min(TOKEN_CACHE_SECONDS, lifetime - TOKEN_EXPIRY_MARGIN_SECONDS); if (ttl < MIN_KV_TTL_SECONDS) return; await env.KV.put(tokenCacheKey(artifactsName, scope), tokenSecret(plaintext), { expirationTtl: ttl }); } /** * `create()` and `fork()` return a write token; caching it saves the `get()` + `createToken()` pair the * first push (e.g. the initial README commit) would otherwise make. Best effort. */ export async function rememberInitialToken(artifactsName: string, plaintext: string | undefined): Promise { if (!plaintext) return; await cacheToken(artifactsName, "write", plaintext).catch((error: unknown) => console.warn("caching initial Artifacts token failed", artifactsName, error), ); } export function basicAuthHeader(secret: string): string { return `Basic ${btoa(`x:${secret}`)}`; } export function authenticatedRemote(remote: string, secret: string): string { const url = new URL(remote); url.username = "x"; url.password = secret; return url.toString(); } export async function openArtifactsRepo(artifactsName: string): Promise { return artifactsCall("open repository", artifactsName, () => artifactsBinding(artifactsName).get(artifactsName), { idempotent: true, }); } /** Mints (or reuses a cached) Artifacts token for git protocol access. */ export async function repoTokenSecret(repo: Pick, scope: TokenScope): Promise { const cached = await env.KV.get(tokenCacheKey(repo.artifacts_name, scope)); if (cached) return cached; const handle = await openArtifactsRepo(repo.artifacts_name); try { const token = await artifactsCall("mint an access token", repo.artifacts_name, () => handle.createToken(scope, TOKEN_TTL_SECONDS), ); await cacheToken(repo.artifacts_name, scope, token.plaintext); return tokenSecret(token.plaintext); } finally { handle[Symbol.dispose](); } } export type MintedToken = { artifactsName: string; id: string; secret: string }; /** * Mints a dedicated short-lived token for one task (CI job, merge, container checkout). Revoke it with * `revokeRepoToken` once the task is done so it cannot be replayed for the rest of its TTL. */ export async function mintRepoToken( artifactsName: string, scope: TokenScope, ttlSeconds: number, ): Promise { const handle = await openArtifactsRepo(artifactsName); try { const token = await artifactsCall("mint an access token", artifactsName, () => handle.createToken(scope, ttlSeconds), ); return { artifactsName, id: token.id, secret: tokenSecret(token.plaintext) }; } finally { handle[Symbol.dispose](); } } /** Best-effort revocation; an unrevoked token still expires at its TTL. */ export async function revokeRepoToken(token: Pick): Promise { try { const handle = await openArtifactsRepo(token.artifactsName); try { await artifactsCall("revoke an access token", token.artifactsName, () => handle.revokeToken(token.id), { idempotent: true, }); } finally { handle[Symbol.dispose](); } } catch (error) { console.warn("Artifacts token revocation failed", token.artifactsName, error); } } export async function revokeRepoTokens(tokens: Array>): Promise { const unique = new Map(tokens.map((token) => [`${token.artifactsName}:${token.id}`, token])); await Promise.all([...unique.values()].map(revokeRepoToken)); } function heldTokenKey(owner: string): string { return `artifacts-held-token:${owner}`; } /** Remembers a task's token (e.g. keyed by CI job id) so whoever finishes the task can revoke it. */ export async function holdRepoToken(owner: string, token: MintedToken, ttlSeconds: number): Promise { await env.KV.put(heldTokenKey(owner), JSON.stringify({ artifactsName: token.artifactsName, id: token.id }), { expirationTtl: Math.max(ttlSeconds, MIN_KV_TTL_SECONDS), }); } /** `mintRepoToken` + `holdRepoToken`: a task token that whoever finishes `owner` revokes. */ export async function mintHeldRepoToken( owner: string, artifactsName: string, scope: TokenScope, ttlSeconds: number, ): Promise { const token = await mintRepoToken(artifactsName, scope, ttlSeconds); await holdRepoToken(owner, token, ttlSeconds); return token; } export async function releaseRepoToken(owner: string): Promise { const held = await env.KV.get<{ artifactsName: string; id: string }>(heldTokenKey(owner), "json"); if (!held) return; await revokeRepoToken(held); await env.KV.delete(heldTokenKey(owner)); } export function isArtifactsError(error: unknown): error is ArtifactsError { return error instanceof Error && error.name === "ArtifactsError" && "code" in error; } export { artifactsNameFor } from "./artifacts-shards"; /** Drops a cached token, e.g. after Artifacts rejected it, so the next call mints a fresh one. */ export async function forgetRepoToken(artifactsName: string, scope: TokenScope): Promise { await env.KV.delete(tokenCacheKey(artifactsName, scope)); }