1import * as Schema from "effect/Schema";
2import * as HttpApi from "effect/http-api/HttpApi";
3import * as HttpApiEndpoint from "effect/http-api/HttpApiEndpoint";
4import * as HttpApiGroup from "effect/http-api/HttpApiGroup";
5import * as HttpApiSchema from "effect/http-api/HttpApiSchema";
6import { 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 */
19export const RunRef = Schema.Struct({
20 repo: Schema.String,
21 runId: Schema.Number,
22 attempt: Schema.Number,
23});
24export 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 */
32export const manifestArtifactName = (sha256: string) =>
33 `pkg-manifest-${sha256}`;
34
35/** Lowercase hex SHA-256. */
36export const Sha256 = Schema.String.pipe(
37 Schema.check(Schema.isPattern(/^[a-f0-9]{64}$/)),
38);
39
40export const TarballRef = Schema.Struct({
41 name: PackageName,
42 sha256: Sha256,
43});
44export type TarballRef = typeof TarballRef.Type;
45
46/** The request names no usable run, or carries an invalid body. */
47export 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. */
54export 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. */
61export class RunNotInProgress extends Schema.TaggedError<RunNotInProgress>()(
62 "RunNotInProgress",
63 { message: Schema.String },
64 { httpApiStatus: 409 },
65) {}
66
67/** Upload these, then publish again. */
68export class MissingTarballs extends Schema.TaggedError<MissingTarballs>()(
69 "MissingTarballs",
70 { missing: Schema.Array(TarballRef) },
71 { httpApiStatus: 409 },
72) {}
73
74export 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. */
81export class Upstream extends Schema.TaggedError<Upstream>()(
82 "Upstream",
83 { message: Schema.String },
84 { httpApiStatus: 502 },
85) {}
86
87export 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});
94export type PublishedPackage = typeof PublishedPackage.Type;
95
96export const PublishResponse = Schema.Struct({
97 packages: Schema.Array(PublishedPackage),
98});
99export 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 */
106export 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
119export const TarballResponse = Schema.Struct({
120 name: Schema.String,
121 sha256: Sha256,
122 size: Schema.Number,
123 uploaded: Schema.Boolean,
124});
125export 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 */
132export 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
144export const health = HttpApiEndpoint.get("health", "/api/health", {
145 success: Schema.Struct({ ok: Schema.Boolean }),
146});
147
148export class Registry extends HttpApiGroup.make("Registry")
149 .add(publish)
150 .add(uploadTarball) {}
151
152export class Health extends HttpApiGroup.make("Health").add(health) {}
153
154export 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 */
162export const tarballUrl = (registry: string, name: string, sha256: string) =>
163 `${registry.replace(/\/+$/, "")}/${name}/-/${sha256}.tgz`;