1import * as HttpRouter from "effect/http/HttpRouter";
2/**
3 * A second application assembly with custom native HTTP handlers: the
4 * suite's middleware, plus one branch-protection rule. This is the
5 * reference for "a rule about refs": an application policy is an ordinary effect
6 * over the parsed updates, it reads what the middleware put in context,
7 * and it never sees a credential.
8 *
9 * Lives in its own module (not `stack.ts`) because a Worker's generated
10 * entry imports its `main` module's DEFAULT export — one Worker class
11 * per entry module.
12 */
13import * as Cloudflare from "@/Cloudflare";
14import * as GitHttp from "@/Git/Http.ts";
15import * as Git from "@/Git/index.ts";
16import {
17 ApiHandlersLive,
18 BlobStoreR2,
19 GIT_WORKER_OPTIONS,
20 HasherInline,
21 RegistryDurableObject,
22 ReposDurableObject,
23} from "@/Git/index.ts";
24import * as Http from "@/Http/index.ts";
25import * as Alchemy from "@/index.ts";
26import * as Effect from "effect/Effect";
27import * as Layer from "effect/Layer";
28import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
29import { GitHubLive } from "./http.ts";
30import { TestAuthLive, TestCaller } from "./test-auth.ts";
31
32/** Ordinary request effects: policies retain the middleware's typed caller. */
33const protectMain = (
34 repo: Git.RepoMetaData,
35 updates: ReadonlyArray<Git.RefUpdate>,
36) =>
37 Effect.gen(function* () {
38 const { user } = yield* TestCaller;
39 for (const update of updates) {
40 if (update.ref === "refs/heads/main" && user?.id !== repo.owner) {
41 return yield* new Git.PushDenied({
42 ref: update.ref,
43 reason: "not permitted: only the owner moves main",
44 });
45 }
46 }
47 });
48
49const ProtocolLive = HttpApiBuilder.group(Git.Api, "protocol", (h) =>
50 Effect.gen(function* () {
51 const handlers = yield* Git.Handlers;
52 const git = yield* Git.Engine;
53 return h
54 .handleRaw("infoRefs", handlers.protocol.infoRefs)
55 .handleRaw("uploadPack", handlers.protocol.uploadPack)
56 .handleRaw("receivePack", ({ params, request }) =>
57 Effect.scoped(
58 Effect.gen(function* () {
59 const repo = yield* git.repositories
60 .get(params)
61 .pipe(Effect.catchTag("StoreError", Effect.die));
62 const push = yield* GitHttp.ReceivePack.decode(request);
63 if (push._tag === "Probe")
64 return GitHttp.ReceivePack.probeResponse();
65 return yield* Effect.gen(function* () {
66 yield* protectMain(repo, push.updates);
67 const prepared = yield* git.preparePush(repo, push.input);
68 for (const update of prepared.updates) {
69 if (/^0+$/.test(update.newOid)) continue;
70 const object = yield* prepared.readObject(update.newOid);
71 if (object?.type === 1) {
72 const commit = yield* Git.parseCommit(object.content).pipe(
73 Effect.orDie,
74 );
75 if (commit.message.includes("[reject-content]"))
76 return yield* new Git.PushDenied({
77 ref: update.ref,
78 reason: "commit rejected by content policy",
79 });
80 }
81 }
82 return GitHttp.ReceivePack.response(
83 push,
84 yield* git.commitPush(prepared),
85 );
86 }).pipe(
87 Effect.catchTag("PushDenied", (error) =>
88 Effect.succeed(GitHttp.ReceivePack.reject(push, error.reason)),
89 ),
90 );
91 }),
92 ).pipe(
93 Effect.catchTag("StoreError", (error) =>
94 Effect.succeed(GitHttp.ReceivePack.failure(error.reason)),
95 ),
96 Effect.catchTag(["WireProtocolError", "PackIngestError"], (error) =>
97 Effect.succeed(GitHttp.ReceivePack.failure(error.reason)),
98 ),
99 ),
100 );
101 }),
102);
103
104const RefsLive = HttpApiBuilder.group(Git.Api, "refs", (h) =>
105 Effect.gen(function* () {
106 const handlers = yield* Git.Handlers;
107 const git = yield* Git.Engine;
108 return h.handleAll({
109 ...handlers.refs,
110 update: (input) =>
111 Effect.gen(function* () {
112 const repo = yield* git.repositories
113 .get(input.params)
114 .pipe(Effect.catchTag("StoreError", Effect.die));
115 const current = yield* git.refs.get(repo, input.query.name).pipe(
116 Effect.map((ref) => ref.oid),
117 Effect.catchTag("RefNotFound", () =>
118 Effect.succeed("0".repeat(40)),
119 ),
120 Effect.catchTag("StoreError", Effect.die),
121 );
122 yield* protectMain(repo, [
123 {
124 ref: input.query.name,
125 oldOid: current,
126 newOid: input.payload.newOid,
127 },
128 ]);
129 return yield* handlers.refs.update({
130 ...input,
131 payload: {
132 ...input.payload,
133 expectedOid:
134 input.payload.expectedOid !== undefined
135 ? input.payload.expectedOid
136 : current === "0".repeat(40)
137 ? null
138 : (current as Git.Oid),
139 },
140 });
141 }),
142 remove: (input) =>
143 Effect.gen(function* () {
144 const repo = yield* git.repositories
145 .get(input.params)
146 .pipe(Effect.catchTag("StoreError", Effect.die));
147 const current = yield* git.refs
148 .get(repo, input.query.name)
149 .pipe(Effect.catchTag("StoreError", Effect.die));
150 yield* protectMain(repo, [
151 {
152 ref: input.query.name,
153 oldOid: current.oid,
154 newOid: "0".repeat(40),
155 },
156 ]);
157 return yield* handlers.refs.remove({
158 ...input,
159 payload: {
160 expectedOid:
161 input.payload.expectedOid ?? (current.oid as Git.Oid),
162 },
163 });
164 }),
165 });
166 }),
167);
168
169const PullsLive = HttpApiBuilder.group(Git.Api, "pulls", (h) =>
170 Effect.gen(function* () {
171 const handlers = yield* Git.Handlers;
172 const git = yield* Git.Engine;
173 return h.handleAll({
174 ...handlers.pulls,
175 merge: (input) =>
176 Effect.gen(function* () {
177 const repo = yield* git.repositories
178 .get(input.params)
179 .pipe(Effect.catchTag("StoreError", Effect.die));
180 const pull = yield* git.pulls
181 .get(repo, input.params.number)
182 .pipe(Effect.catchTag("StoreError", Effect.die));
183 yield* protectMain(repo, [
184 {
185 ref: pull.baseRef,
186 oldOid: pull.baseOid ?? "0".repeat(40),
187 newOid: pull.headOid ?? "0".repeat(40),
188 },
189 ]);
190 return yield* handlers.pulls.merge(input);
191 }),
192 });
193 }),
194);
195
196const ProtectedRoutes = Layer.mergeAll(
197 HttpApiBuilder.layer(Git.Api).pipe(
198 Layer.provide(
199 Layer.mergeAll(
200 Git.GroupsLive,
201 GitHubLive,
202 ProtocolLive,
203 RefsLive,
204 PullsLive,
205 ),
206 ),
207 Layer.provide(TestAuthLive),
208 ),
209 Git.InternalApiLive,
210).pipe(Layer.provide(Http.Platform));
211
212/** This assembly's bucket (its own stack, so no clash with `stack.ts`). */
213const GitObjects = Cloudflare.R2.Bucket("GitObjects");
214
215const ProtectedGitLive = ProtectedRoutes.pipe(
216 Layer.provide(ApiHandlersLive),
217 Layer.provide(ReposDurableObject),
218 Layer.provide(RegistryDurableObject),
219 Layer.provide(HasherInline),
220 Layer.provide(BlobStoreR2(GitObjects)),
221);
222
223/** Identical to the shared fixture's host except for the policy. */
224export default class ProtectedGitHost extends Cloudflare.Worker<ProtectedGitHost>()(
225 "GitProtectedWorker",
226 {
227 main: import.meta.url,
228 ...GIT_WORKER_OPTIONS,
229 },
230 Effect.gen(function* () {
231 const fetch = yield* HttpRouter.toHttpEffect(ProtectedGitLive);
232 return { fetch };
233 }),
234) {}
235
236/** Deployable stack for the protected-branch test. */
237export const makeProtectedStack = (name: string) =>
238 Alchemy.Stack(
239 name,
240 { providers: Cloudflare.providers(), state: Alchemy.localState() },
241 Effect.gen(function* () {
242 const host = yield* ProtectedGitHost;
243 return { url: host.url.as<string>() };
244 }),
245 );