| 1 | import * as Schema from "effect/Schema"; |
| 2 | import * as HttpApi from "effect/http-api/HttpApi"; |
| 3 | import * as HttpApiEndpoint from "effect/http-api/HttpApiEndpoint"; |
| 4 | import * as HttpApiGroup from "effect/http-api/HttpApiGroup"; |
| 5 | import * as HttpApiSchema from "effect/http-api/HttpApiSchema"; |
| 6 | import { PackageName } from "./Manifest.ts"; |
| 7 | |
| 8 | /** |
| 9 | * The HTTP contract between the `pkg` CLI and the registry Worker, as an |
| 10 | * `HttpApi`. The Worker implements it with `HttpApiBuilder`; the CLI calls |
| 11 | * it through `HttpApiClient`. Importing this file pulls in no runtime code. |
| 12 | */ |
| 13 | |
| 14 | /** |
| 15 | * The GitHub Actions run a request comes from. It is a lookup hint, not a |
| 16 | * credential: the registry resolves the run through the GitHub API and |
| 17 | * trusts only what GitHub says about it. |
| 18 | */ |
| 19 | export const RunRef = Schema.Struct({ |
| 20 | repo: Schema.String, |
| 21 | runId: Schema.Number, |
| 22 | attempt: Schema.Number, |
| 23 | }); |
| 24 | export type RunRef = typeof RunRef.Type; |
| 25 | |
| 26 | /** |
| 27 | * Name of the artifact a job uploads to its own run to vouch for a manifest. |
| 28 | * Only the job holds the runtime token that can add artifacts to the run, so |
| 29 | * an artifact carrying the manifest's hash is GitHub's record that this run |
| 30 | * approved exactly these package hashes. |
| 31 | */ |
| 32 | export const manifestArtifactName = (sha256: string) => |
| 33 | `pkg-manifest-${sha256}`; |
| 34 | |
| 35 | /** Lowercase hex SHA-256. */ |
| 36 | export const Sha256 = Schema.String.pipe( |
| 37 | Schema.check(Schema.isPattern(/^[a-f0-9]{64}$/)), |
| 38 | ); |
| 39 | |
| 40 | export const TarballRef = Schema.Struct({ |
| 41 | name: PackageName, |
| 42 | sha256: Sha256, |
| 43 | }); |
| 44 | export type TarballRef = typeof TarballRef.Type; |
| 45 | |
| 46 | /** The request names no usable run, or carries an invalid body. */ |
| 47 | export class BadRequest extends Schema.TaggedError<BadRequest>()( |
| 48 | "BadRequest", |
| 49 | { message: Schema.String }, |
| 50 | { httpApiStatus: 400 }, |
| 51 | ) {} |
| 52 | |
| 53 | /** The repository may not publish, or the run has not vouched for the manifest. */ |
| 54 | export class Forbidden extends Schema.TaggedError<Forbidden>()( |
| 55 | "Forbidden", |
| 56 | { message: Schema.String }, |
| 57 | { httpApiStatus: 403 }, |
| 58 | ) {} |
| 59 | |
| 60 | /** Requests come from inside a run, so a finished run cannot publish. */ |
| 61 | export class RunNotInProgress extends Schema.TaggedError<RunNotInProgress>()( |
| 62 | "RunNotInProgress", |
| 63 | { message: Schema.String }, |
| 64 | { httpApiStatus: 409 }, |
| 65 | ) {} |
| 66 | |
| 67 | /** Upload these, then publish again. */ |
| 68 | export class MissingTarballs extends Schema.TaggedError<MissingTarballs>()( |
| 69 | "MissingTarballs", |
| 70 | { missing: Schema.Array(TarballRef) }, |
| 71 | { httpApiStatus: 409 }, |
| 72 | ) {} |
| 73 | |
| 74 | export class PackageTooLarge extends Schema.TaggedError<PackageTooLarge>()( |
| 75 | "PackageTooLarge", |
| 76 | { message: Schema.String }, |
| 77 | { httpApiStatus: 413 }, |
| 78 | ) {} |
| 79 | |
| 80 | /** GitHub could not be reached or answered with an error. */ |
| 81 | export class Upstream extends Schema.TaggedError<Upstream>()( |
| 82 | "Upstream", |
| 83 | { message: Schema.String }, |
| 84 | { httpApiStatus: 502 }, |
| 85 | ) {} |
| 86 | |
| 87 | export const PublishedPackage = Schema.Struct({ |
| 88 | name: Schema.String, |
| 89 | group: Schema.String, |
| 90 | /** Install URL pinned to the commit. */ |
| 91 | url: Schema.String, |
| 92 | tags: Schema.Array(Schema.String), |
| 93 | }); |
| 94 | export type PublishedPackage = typeof PublishedPackage.Type; |
| 95 | |
| 96 | export const PublishResponse = Schema.Struct({ |
| 97 | packages: Schema.Array(PublishedPackage), |
| 98 | }); |
| 99 | export type PublishResponse = typeof PublishResponse.Type; |
| 100 | |
| 101 | /** |
| 102 | * `manifest` is the exact `pkg-manifest.json` text the job vouched for; its |
| 103 | * SHA-256 must match an artifact on the run. Idempotent: the registry |
| 104 | * either fails with {@link MissingTarballs} or writes the tags. |
| 105 | */ |
| 106 | export const publish = HttpApiEndpoint.post("publish", "/api/publish", { |
| 107 | payload: Schema.Struct({ run: RunRef, manifest: Schema.String }), |
| 108 | success: PublishResponse, |
| 109 | error: [ |
| 110 | BadRequest, |
| 111 | Forbidden, |
| 112 | RunNotInProgress, |
| 113 | MissingTarballs, |
| 114 | PackageTooLarge, |
| 115 | Upstream, |
| 116 | ], |
| 117 | }); |
| 118 | |
| 119 | export const TarballResponse = Schema.Struct({ |
| 120 | name: Schema.String, |
| 121 | sha256: Sha256, |
| 122 | size: Schema.Number, |
| 123 | uploaded: Schema.Boolean, |
| 124 | }); |
| 125 | export type TarballResponse = typeof TarballResponse.Type; |
| 126 | |
| 127 | /** |
| 128 | * Content-addressed upload. Any in-progress run of an allowed repository |
| 129 | * may upload; bytes only become reachable once a vouched manifest tags |
| 130 | * them. The run travels in the query because the body is the tarball. |
| 131 | */ |
| 132 | export const uploadTarball = HttpApiEndpoint.put( |
| 133 | "uploadTarball", |
| 134 | "/api/tarballs/:name/:sha256", |
| 135 | { |
| 136 | params: TarballRef, |
| 137 | query: RunRef, |
| 138 | payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), |
| 139 | success: TarballResponse, |
| 140 | error: [BadRequest, Forbidden, RunNotInProgress, PackageTooLarge, Upstream], |
| 141 | }, |
| 142 | ); |
| 143 | |
| 144 | export const health = HttpApiEndpoint.get("health", "/api/health", { |
| 145 | success: Schema.Struct({ ok: Schema.Boolean }), |
| 146 | }); |
| 147 | |
| 148 | export class Registry extends HttpApiGroup.make("Registry") |
| 149 | .add(publish) |
| 150 | .add(uploadTarball) {} |
| 151 | |
| 152 | export class Health extends HttpApiGroup.make("Health").add(health) {} |
| 153 | |
| 154 | export class PkgApi extends HttpApi.make("Pkg").add(Registry).add(Health) {} |
| 155 | |
| 156 | /** |
| 157 | * Immutable tarball URL on the registry. Dependencies between packed |
| 158 | * packages link to these, so a tarball's bytes depend only on its own |
| 159 | * source and its dependencies' bytes, never on a commit, and identical |
| 160 | * builds deduplicate across commits, pull requests, and repositories. |
| 161 | */ |
| 162 | export const tarballUrl = (registry: string, name: string, sha256: string) => |
| 163 | `${registry.replace(/\/+$/, "")}/${name}/-/${sha256}.tgz`; |