| 1 | import * 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 | */ |
| 13 | import * as Cloudflare from "@/Cloudflare"; |
| 14 | import * as GitHttp from "@/Git/Http.ts"; |
| 15 | import * as Git from "@/Git/index.ts"; |
| 16 | import { |
| 17 | ApiHandlersLive, |
| 18 | BlobStoreR2, |
| 19 | GIT_WORKER_OPTIONS, |
| 20 | HasherInline, |
| 21 | RegistryDurableObject, |
| 22 | ReposDurableObject, |
| 23 | } from "@/Git/index.ts"; |
| 24 | import * as Http from "@/Http/index.ts"; |
| 25 | import * as Alchemy from "@/index.ts"; |
| 26 | import * as Effect from "effect/Effect"; |
| 27 | import * as Layer from "effect/Layer"; |
| 28 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 29 | import { GitHubLive } from "./http.ts"; |
| 30 | import { TestAuthLive, TestCaller } from "./test-auth.ts"; |
| 31 | |
| 32 | /** Ordinary request effects: policies retain the middleware's typed caller. */ |
| 33 | const 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 | |
| 49 | const 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 | |
| 104 | const 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 | |
| 169 | const 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 | |
| 196 | const 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`). */ |
| 213 | const GitObjects = Cloudflare.R2.Bucket("GitObjects"); |
| 214 | |
| 215 | const 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. */ |
| 224 | export 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. */ |
| 237 | export 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 | ); |