index.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"
  2. interface Env {
  3. DB: D1Database
  4. }
  5. type ArtifactRow = {
  6. channel: string
  7. name: string
  8. distribution: string
  9. version: string
  10. metadata: string
  11. active: number
  12. time_created: number
  13. time_updated: number
  14. }
  15. type Artifact = Omit<ArtifactRow, "metadata" | "active"> & {
  16. metadata: unknown
  17. active: boolean
  18. }
  19. type ArtifactInput = Pick<ArtifactRow, "channel" | "name" | "distribution" | "version"> & {
  20. metadata: unknown
  21. }
  22. const identifier = /^[a-zA-Z0-9._-]{1,64}$/
  23. const version = /^[a-zA-Z0-9.+_-]{1,128}$/
  24. const select = "SELECT channel, name, distribution, version, metadata, active, time_created, time_updated FROM artifact"
  25. const audience = "https://update.opencode.ai"
  26. const githubKeys = createRemoteJWKSet(new URL("https://token.actions.githubusercontent.com/.well-known/jwks"))
  27. export default {
  28. async fetch(request, env): Promise<Response> {
  29. const url = new URL(request.url)
  30. if (url.pathname === "/") return json({ service: "opencode-updates" })
  31. if (url.pathname === "/admin" && request.method === "GET") return admin(request, env)
  32. if (url.pathname === "/admin/activate" && request.method === "POST") return activateArtifact(request, env)
  33. if (url.pathname === "/api/publish" && request.method === "POST") return publishArtifact(request, env)
  34. if (request.method !== "GET") return new Response("Method not allowed", { status: 405 })
  35. const segments = url.pathname.split("/").filter(Boolean)
  36. if (segments.length === 2 && segments[0] === "api" && validIdentifier(segments[1])) {
  37. return channel(env.DB, segments[1])
  38. }
  39. if (
  40. segments.length === 3 &&
  41. segments[0] === "api" &&
  42. validIdentifier(segments[1]) &&
  43. validIdentifier(segments[2])
  44. ) {
  45. return artifactName(env.DB, segments[1], segments[2])
  46. }
  47. if (
  48. segments.length === 4 &&
  49. segments[0] === "api" &&
  50. validIdentifier(segments[1]) &&
  51. validIdentifier(segments[2]) &&
  52. validIdentifier(segments[3])
  53. ) {
  54. return artifactDistribution(env.DB, segments[1], segments[2], segments[3])
  55. }
  56. return new Response("Not found", { status: 404 })
  57. },
  58. } satisfies ExportedHandler<Env>
  59. async function channel(db: D1Database, channel: string) {
  60. const result = await db
  61. .prepare(`${select} WHERE channel = ? AND active = 1 ORDER BY name, distribution`)
  62. .bind(channel)
  63. .all<ArtifactRow>()
  64. if (!result.results.length) return json({ error: "Channel not found" }, 404)
  65. return cached({ channel, artifacts: result.results.map(decodeArtifact) })
  66. }
  67. async function artifactName(db: D1Database, channel: string, name: string) {
  68. const result = await db
  69. .prepare(`${select} WHERE channel = ? AND name = ? AND active = 1 ORDER BY distribution`)
  70. .bind(channel, name)
  71. .all<ArtifactRow>()
  72. if (!result.results.length) return json({ error: "Artifact not found" }, 404)
  73. return cached({ channel, name, artifacts: result.results.map(decodeArtifact) })
  74. }
  75. async function artifactDistribution(db: D1Database, channel: string, name: string, distribution: string) {
  76. const artifact = await db
  77. .prepare(`${select} WHERE channel = ? AND name = ? AND distribution = ? AND active = 1`)
  78. .bind(channel, name, distribution)
  79. .first<ArtifactRow>()
  80. if (!artifact) return json({ error: "Artifact not found" }, 404)
  81. return cached(decodeArtifact(artifact))
  82. }
  83. async function admin(request: Request, env: Env) {
  84. const url = new URL(request.url)
  85. const requestedPage = Number.parseInt(url.searchParams.get("page") ?? "1", 10)
  86. const page = Number.isSafeInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1
  87. const pageSize = 100
  88. const count = await env.DB.prepare("SELECT COUNT(*) AS total FROM artifact").first<{ total: number }>()
  89. const pages = Math.max(1, Math.ceil((count?.total ?? 0) / pageSize))
  90. const currentPage = Math.min(page, pages)
  91. const result = await env.DB
  92. .prepare(`${select} ORDER BY time_created DESC LIMIT ? OFFSET ?`)
  93. .bind(pageSize, (currentPage - 1) * pageSize)
  94. .all<ArtifactRow>()
  95. const rows = result.results
  96. .map(
  97. (artifact) => `<tr>
  98. <td><code>${escape(artifact.channel)}</code></td>
  99. <td><code>${escape(artifact.name)}</code></td>
  100. <td><code>${escape(artifact.distribution)}</code></td>
  101. <td><code>${escape(artifact.version)}</code></td>
  102. <td>${new Date(artifact.time_created).toISOString()}</td>
  103. <td>${artifact.active ? '<span class="badge">Active</span>' : '<span class="badge" data-variant="secondary">Inactive</span>'}</td>
  104. <td>
  105. ${
  106. artifact.active
  107. ? ""
  108. : `<form action="/admin/activate" method="post">
  109. <input type="hidden" name="channel" value="${escape(artifact.channel)}">
  110. <input type="hidden" name="name" value="${escape(artifact.name)}">
  111. <input type="hidden" name="distribution" value="${escape(artifact.distribution)}">
  112. <input type="hidden" name="version" value="${escape(artifact.version)}">
  113. <button class="btn" data-size="sm" data-variant="outline" type="submit">Activate</button>
  114. </form>`
  115. }
  116. </td>
  117. </tr>`,
  118. )
  119. .join("")
  120. return new Response(
  121. `<!doctype html>
  122. <html lang="en" class="dark">
  123. <head>
  124. <meta charset="utf-8">
  125. <meta name="viewport" content="width=device-width, initial-scale=1">
  126. <title>OpenCode Updates</title>
  127. <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/basecoat-css@1.0.2/dist/basecoat.cdn.min.css">
  128. <style>
  129. body { min-height: 100vh; background: var(--background); }
  130. main { width: min(1180px, calc(100% - 2rem)); margin: 0 auto; padding: 4rem 0; }
  131. .masthead { display: flex; align-items: end; justify-content: space-between; gap: 1rem; margin-bottom: 2rem; }
  132. .masthead h1 { font-size: clamp(2.25rem, 6vw, 4.5rem); line-height: .95; letter-spacing: -.055em; }
  133. .masthead p { color: var(--muted-foreground); }
  134. .table-wrap { overflow-x: auto; }
  135. table { width: 100%; border-collapse: collapse; }
  136. th, td { padding: .8rem 1rem; border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; }
  137. th { color: var(--muted-foreground); font-size: .75rem; font-weight: 500; text-transform: uppercase; letter-spacing: .08em; }
  138. tbody tr:last-child td { border-bottom: 0; }
  139. td form { margin: 0; }
  140. .pagination { display: flex; align-items: center; justify-content: space-between; gap: 1rem; border-top: 1px solid var(--border); padding: 1rem; }
  141. .pagination p { color: var(--muted-foreground); font-size: .875rem; }
  142. .pagination nav { display: flex; gap: .5rem; }
  143. @media (max-width: 760px) { main { padding: 2rem 0; } .masthead { align-items: start; flex-direction: column; } }
  144. </style>
  145. </head>
  146. <body>
  147. <main>
  148. <header class="masthead">
  149. <div><p>Release control</p><h1>Artifacts</h1></div>
  150. <span class="badge" data-variant="outline">${escape(request.headers.get("Cf-Access-Authenticated-User-Email") ?? "Cloudflare Access pending")}</span>
  151. </header>
  152. <article class="card">
  153. <header><h2>Published builds</h2><p>Every build received from the trusted publishing workflow, newest first.</p></header>
  154. <section class="table-wrap">
  155. <table>
  156. <thead><tr><th>Channel</th><th>Name</th><th>Distribution</th><th>Version</th><th>Created</th><th>Status</th><th></th></tr></thead>
  157. <tbody>${rows || '<tr><td colspan="7">No builds have been published yet.</td></tr>'}</tbody>
  158. </table>
  159. </section>
  160. <footer class="pagination">
  161. <p>Page ${currentPage} of ${pages} · ${count?.total ?? 0} builds</p>
  162. <nav aria-label="Pagination">
  163. ${currentPage > 1 ? `<a class="btn" data-size="sm" data-variant="outline" href="/admin?page=${currentPage - 1}">Previous</a>` : ""}
  164. ${currentPage < pages ? `<a class="btn" data-size="sm" data-variant="outline" href="/admin?page=${currentPage + 1}">Next</a>` : ""}
  165. </nav>
  166. </footer>
  167. </article>
  168. </main>
  169. </body>
  170. </html>`,
  171. { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } },
  172. )
  173. }
  174. async function publishArtifact(request: Request, env: Env) {
  175. const claims = await verifyGitHub(request)
  176. if (claims instanceof Response) return claims
  177. const input: unknown = await request.json().catch(() => undefined)
  178. const artifact = parseArtifact(isRecord(input) ? input : {})
  179. if (artifact instanceof Response) return artifact
  180. if (!channelsForRef(claims.ref).includes(artifact.channel)) return json({ error: "Channel is not allowed" }, 403)
  181. if (!isRecord(artifact.metadata)) return json({ error: "Metadata must be an object" }, 400)
  182. await activate(env.DB, [
  183. {
  184. ...artifact,
  185. metadata: {
  186. ...artifact.metadata,
  187. github: {
  188. sha: claims.sha,
  189. run_id: claims.run_id,
  190. run_attempt: claims.run_attempt,
  191. actor: claims.actor,
  192. ref: claims.ref,
  193. },
  194. },
  195. },
  196. ])
  197. return json({ published: true })
  198. }
  199. async function activateArtifact(request: Request, env: Env) {
  200. const invalid = validMutation(request)
  201. if (invalid) return invalid
  202. const form = await request.formData()
  203. const key = parseKey({
  204. channel: form.get("channel"),
  205. name: form.get("name"),
  206. distribution: form.get("distribution"),
  207. version: form.get("version"),
  208. })
  209. if (key instanceof Response) return key
  210. const exists = await env.DB.prepare(
  211. "SELECT 1 FROM artifact WHERE channel = ? AND name = ? AND distribution = ? AND version = ?",
  212. )
  213. .bind(key.channel, key.name, key.distribution, key.version)
  214. .first()
  215. if (!exists) return json({ error: "Artifact not found" }, 404)
  216. await env.DB.batch([
  217. deactivateStatement(env.DB, key),
  218. env.DB
  219. .prepare(
  220. "UPDATE artifact SET active = 1, time_updated = ? WHERE channel = ? AND name = ? AND distribution = ? AND version = ?",
  221. )
  222. .bind(Date.now(), key.channel, key.name, key.distribution, key.version),
  223. ])
  224. return Response.redirect(new URL("/admin", request.url), 303)
  225. }
  226. function activate(db: D1Database, artifacts: ArtifactInput[]) {
  227. return db.batch(
  228. artifacts.flatMap((artifact) => {
  229. const time = Date.now()
  230. return [
  231. deactivateStatement(db, artifact),
  232. db
  233. .prepare(
  234. `INSERT INTO artifact (channel, name, distribution, version, metadata, active, time_created, time_updated)
  235. VALUES (?, ?, ?, ?, ?, 1, ?, ?)
  236. ON CONFLICT (channel, name, distribution, version) DO UPDATE SET
  237. metadata = excluded.metadata, active = 1, time_updated = excluded.time_updated`,
  238. )
  239. .bind(
  240. artifact.channel,
  241. artifact.name,
  242. artifact.distribution,
  243. artifact.version,
  244. JSON.stringify(artifact.metadata),
  245. time,
  246. time,
  247. ),
  248. ]
  249. }),
  250. )
  251. }
  252. function deactivateStatement(db: D1Database, artifact: Pick<ArtifactInput, "channel" | "name" | "distribution">) {
  253. return db
  254. .prepare("UPDATE artifact SET active = 0 WHERE channel = ? AND name = ? AND distribution = ? AND active = 1")
  255. .bind(artifact.channel, artifact.name, artifact.distribution)
  256. }
  257. function parseArtifact(input: Record<string, unknown>): ArtifactInput | Response {
  258. const key = parseKey(input)
  259. if (key instanceof Response) return key
  260. const metadata = typeof input.metadata === "string" ? parseMetadata(input.metadata) : input.metadata
  261. if (metadata === undefined) return json({ error: "Metadata must be valid JSON" }, 400)
  262. return { ...key, metadata }
  263. }
  264. function parseKey(input: Record<string, unknown>): Omit<ArtifactInput, "metadata"> | Response {
  265. if (
  266. !validIdentifier(input.channel) ||
  267. !validIdentifier(input.name) ||
  268. !validIdentifier(input.distribution) ||
  269. !validVersion(input.version)
  270. )
  271. return json({ error: "Invalid artifact" }, 400)
  272. return {
  273. channel: input.channel,
  274. name: input.name,
  275. distribution: input.distribution,
  276. version: input.version,
  277. }
  278. }
  279. function decodeArtifact(row: ArtifactRow): Artifact {
  280. return { ...row, metadata: decodeMetadata(row.metadata), active: row.active === 1 }
  281. }
  282. function decodeMetadata(input: string) {
  283. return parseMetadata(input) ?? null
  284. }
  285. function parseMetadata(input: string): unknown | undefined {
  286. try {
  287. return JSON.parse(input)
  288. } catch {
  289. return
  290. }
  291. }
  292. async function verifyGitHub(request: Request) {
  293. const authorization = request.headers.get("Authorization")
  294. if (!authorization?.startsWith("Bearer ")) return json({ error: "Unauthorized" }, 401)
  295. const result = await jwtVerify(authorization.slice("Bearer ".length), githubKeys, {
  296. issuer: "https://token.actions.githubusercontent.com",
  297. audience,
  298. }).catch(() => undefined)
  299. if (!result || !validGitHubClaims(result.payload)) return json({ error: "Unauthorized" }, 401)
  300. return result.payload
  301. }
  302. type GitHubClaims = JWTPayload & {
  303. repository: string
  304. repository_id: string
  305. repository_owner_id: string
  306. workflow_ref: string
  307. ref: string
  308. sha: string
  309. run_id: string
  310. run_attempt: string
  311. actor: string
  312. }
  313. export function validGitHubClaims(claims: JWTPayload): claims is GitHubClaims {
  314. if (claims.repository !== "anomalyco/opencode") return false
  315. if (claims.repository_id !== "975734319") return false
  316. if (claims.repository_owner_id !== "66570915") return false
  317. if (typeof claims.workflow_ref !== "string" || typeof claims.ref !== "string") return false
  318. if (claims.workflow_ref !== `anomalyco/opencode/.github/workflows/publish.yml@${claims.ref}`) return false
  319. if (!channelsForRef(claims.ref).length) return false
  320. return [claims.sha, claims.run_id, claims.run_attempt, claims.actor].every((value) => typeof value === "string")
  321. }
  322. export function channelsForRef(ref: string) {
  323. if (ref === "refs/heads/dev") return ["dev", "latest"]
  324. if (ref === "refs/heads/v2") return ["next"]
  325. if (ref === "refs/heads/beta") return ["beta"]
  326. if (ref === "refs/heads/ci") return ["ci"]
  327. if (ref === "refs/heads/fix/npm-native-binary-install") return ["fix/npm-native-binary-install"]
  328. const snapshot = ref.match(/^refs\/heads\/(snapshot-[a-zA-Z0-9._-]+)$/)?.[1]
  329. return snapshot ? [snapshot] : []
  330. }
  331. function validMutation(request: Request) {
  332. const origin = request.headers.get("Origin")
  333. if (origin && origin !== new URL(request.url).origin) return json({ error: "Invalid origin" }, 403)
  334. }
  335. function validIdentifier(input: unknown): input is string {
  336. return typeof input === "string" && identifier.test(input)
  337. }
  338. function validVersion(input: unknown): input is string {
  339. return typeof input === "string" && version.test(input)
  340. }
  341. function isRecord(input: unknown): input is Record<string, unknown> {
  342. return typeof input === "object" && input !== null && !Array.isArray(input)
  343. }
  344. function cached(value: unknown) {
  345. return json(value, 200, { "Cache-Control": "public, max-age=60" })
  346. }
  347. function json(value: unknown, status = 200, headers?: HeadersInit) {
  348. return Response.json(value, { status, headers })
  349. }
  350. function escape(value: string) {
  351. return value.replace(/[&<>"']/g, (character) => {
  352. if (character === "&") return "&amp;"
  353. if (character === "<") return "&lt;"
  354. if (character === ">") return "&gt;"
  355. if (character === '"') return "&quot;"
  356. return "&#39;"
  357. })
  358. }