| 1 | --- |
| 2 | title: "Part 6: Protect a branch" |
| 3 | description: Authorize parsed Git mutations in ordinary HTTP handlers, using the request's user before committing changes. |
| 4 | --- |
| 5 | |
| 6 | import { Code, Tabs, TabItem } from "@astrojs/starlight/components"; |
| 7 | |
| 8 | The middleware decides who may push. Now add a rule about what an authorized |
| 9 | push may do: it may update `main`, but it may not delete it. |
| 10 | |
| 11 | Your handler decodes the Git commands, calls an application policy, then prepares |
| 12 | and commits the push. It runs in the same request as the middleware from Part 4, |
| 13 | so `Session` is available. Git's binary body does not require a separate middleware |
| 14 | system. |
| 15 | |
| 16 | ## Define the branch rule |
| 17 | |
| 18 | Create `src/branch-policy.ts`: |
| 19 | |
| 20 | ```typescript |
| 21 | // src/branch-policy.ts |
| 22 | import * as Git from "alchemy/Git"; |
| 23 | import * as Effect from "effect/Effect"; |
| 24 | import { Session } from "./session.ts"; |
| 25 | |
| 26 | export const checkRefChanges = (repo: Git.RepoMetaData, updates: ReadonlyArray<Git.RefUpdate>) => |
| 27 | Effect.gen(function* () { |
| 28 | const { user } = yield* Session; |
| 29 | for (const update of updates) { |
| 30 | if (user === null || user.id !== repo.owner) { |
| 31 | return yield* new Git.PushDenied({ ref: update.ref, reason: "only the owner may change refs" }); |
| 32 | } |
| 33 | if (update.ref === "refs/heads/main" && /^0+$/.test(update.newOid)) { |
| 34 | return yield* new Git.PushDenied({ ref: update.ref, reason: "main cannot be deleted" }); |
| 35 | } |
| 36 | } |
| 37 | }); |
| 38 | ``` |
| 39 | |
| 40 | A deletion uses an all-zero `newOid`. This ordinary function can live in its own |
| 41 | module. More policies are more Effect-returning functions; call them in sequence. |
| 42 | A function may require a database service and query it with `user.id`, just like |
| 43 | any other application effect. |
| 44 | |
| 45 | ## Implement the push endpoint |
| 46 | |
| 47 | Create `src/protocol.ts`: |
| 48 | |
| 49 | ```typescript |
| 50 | // src/protocol.ts |
| 51 | import * as Git from "alchemy/Git"; |
| 52 | import * as GitHttp from "alchemy/Git/Http"; |
| 53 | import * as Effect from "effect/Effect"; |
| 54 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 55 | import { checkRefChanges } from "./branch-policy.ts"; |
| 56 | |
| 57 | export const ProtocolLive = HttpApiBuilder.group(Git.Api, "protocol", (h) => Effect.gen(function* () { |
| 58 | const git = yield* Git.Engine; |
| 59 | const defaults = yield* Git.Handlers; |
| 60 | return h |
| 61 | .handleRaw("infoRefs", defaults.protocol.infoRefs) |
| 62 | .handleRaw("uploadPack", defaults.protocol.uploadPack) |
| 63 | .handleRaw("receivePack", ({ params, request }) => Effect.scoped(Effect.gen(function* () { |
| 64 | const repo = yield* git.repositories.get(params).pipe(Effect.catchTag("StoreError", Effect.die)); |
| 65 | const push = yield* GitHttp.ReceivePack.decode(request); |
| 66 | if (push._tag === "Probe") return GitHttp.ReceivePack.probeResponse(); |
| 67 | return yield* Effect.gen(function* () { |
| 68 | yield* checkRefChanges(repo, push.updates); |
| 69 | const prepared = yield* git.preparePush(repo, push.input); |
| 70 | // Additional application validation can read prepared.readObject(oid) here. |
| 71 | return GitHttp.ReceivePack.response(push, yield* git.commitPush(prepared)); |
| 72 | }).pipe(Effect.catchTag("PushDenied", error => Effect.succeed(GitHttp.ReceivePack.reject(push, error.reason)))); |
| 73 | })).pipe( |
| 74 | Effect.catchTag("StoreError", error => Effect.succeed(GitHttp.ReceivePack.failure(error.reason))), |
| 75 | Effect.catchTag(["WireProtocolError", "PackIngestError"], error => Effect.succeed(GitHttp.ReceivePack.failure(error.reason))), |
| 76 | )); |
| 77 | })); |
| 78 | |
| 79 | ``` |
| 80 | |
| 81 | `decode` reads a bounded command header and leaves the pack streaming. |
| 82 | `preparePush` stages and validates objects without moving refs. `commitPush` |
| 83 | changes refs using Git's expected-old-OID checks and atomic capability. Leaving |
| 84 | the scope without committing cleans up staging. A policy failure becomes Git's |
| 85 | report-status, so `git push` prints your rejection reason. |
| 86 | |
| 87 | For content validation, call `prepared.readObject(oid)` between prepare and commit. |
| 88 | It reads this push's incoming objects and existing live objects, with a 1 MiB |
| 89 | per-object limit by default. [Engine operations](/git/blocks/engine) covers this boundary. |
| 90 | |
| 91 | ## Apply the same rule to REST writes |
| 92 | |
| 93 | The host also exposes a REST endpoint for deleting refs. Protect it with the |
| 94 | same function so it cannot bypass the push policy. Create `src/ref-writes.ts`: |
| 95 | |
| 96 | ```typescript |
| 97 | // src/ref-writes.ts |
| 98 | import * as Git from "alchemy/Git"; |
| 99 | import * as Effect from "effect/Effect"; |
| 100 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 101 | import { checkRefChanges } from "./branch-policy.ts"; |
| 102 | |
| 103 | export const RefsLive = HttpApiBuilder.group(Git.Api, "refs", (h) => Effect.gen(function* () { |
| 104 | const git = yield* Git.Engine; |
| 105 | const defaults = yield* Git.Handlers; |
| 106 | return h.handleAll({ ...defaults.refs, |
| 107 | update: ({ params, query, payload }) => Effect.scoped(Effect.gen(function* () { |
| 108 | if (/^0+$/.test(payload.newOid)) return yield* new Git.PushDenied({ ref: query.name, reason: "use the DELETE endpoint to remove a ref" }); |
| 109 | const repo = yield* git.repositories.get(params); |
| 110 | const prepared = yield* git.prepareRefUpdate(repo, { ref: query.name, ...payload }); |
| 111 | yield* checkRefChanges(repo, prepared.updates); |
| 112 | const result = yield* prepared.commit; |
| 113 | if (result === undefined) return yield* new Git.PushDenied({ ref: query.name, reason: "use the DELETE endpoint to remove a ref" }); |
| 114 | return new Git.Ref({ name: result.name, oid: result.oid as Git.Oid }); |
| 115 | })).pipe(Effect.catchTag("StoreError", Effect.die)), |
| 116 | remove: ({ params, query, payload }) => Effect.scoped(Effect.gen(function* () { |
| 117 | const repo = yield* git.repositories.get(params); |
| 118 | const prepared = yield* git.prepareRefRemoval(repo, { ref: query.name, ...payload }); |
| 119 | yield* checkRefChanges(repo, prepared.updates); |
| 120 | yield* prepared.commit; |
| 121 | })).pipe(Effect.catchTag("StoreError", Effect.die)), |
| 122 | }); |
| 123 | })); |
| 124 | ``` |
| 125 | |
| 126 | The prepared ref operation pins the state you inspected. If another request moves |
| 127 | the ref before commit, the commit fails with `RefConflict`. |
| 128 | |
| 129 | This tutorial's new rule prevents deletion. Merges cannot delete a branch. For |
| 130 | policies that restrict other ref changes, also authorize REST and GitHub merges; |
| 131 | `prepareMerge` captures both branch tips, and the [application example](https://github.com/alchemy-run/alchemy/tree/main/examples/cloudflare-git-service/src/api/ref-writes.ts) |
| 132 | shares its policy across those paths. |
| 133 | |
| 134 | ## Register your implementations |
| 135 | |
| 136 | Add these imports to `src/git.ts`: |
| 137 | |
| 138 | ```typescript |
| 139 | // src/git.ts (additional imports) |
| 140 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 141 | import { ProtocolLive } from "./protocol.ts"; |
| 142 | import { RefsLive } from "./ref-writes.ts"; |
| 143 | ``` |
| 144 | |
| 145 | Replace `PublicRoutes` with: |
| 146 | |
| 147 | ```typescript |
| 148 | // src/git.ts |
| 149 | const GitRoutes = HttpApiBuilder.layer(Git.Api).pipe( |
| 150 | Layer.provide(Layer.mergeAll(Git.GroupsLive, ProtocolLive, RefsLive)), |
| 151 | ); |
| 152 | |
| 153 | const PublicRoutes = Layer.mergeAll(AppApiLive, GitRoutes).pipe( |
| 154 | Layer.provide(Authentication.layer), |
| 155 | ); |
| 156 | ``` |
| 157 | |
| 158 | These groups use the existing `Git.Api` contract, so the remaining default groups |
| 159 | can be reused. Your existing `HttpLive` storage layers stay the same. If you add |
| 160 | API middleware or change a prefix on a different `AppApi`, build every affected |
| 161 | group against that actual API instead; see [HTTP routes](/git/blocks/server). |
| 162 | |
| 163 | ## Deploy the rule |
| 164 | |
| 165 | <Tabs syncKey="pkgManager"> |
| 166 | <TabItem label="bun" icon="bun"> |
| 167 | <Code code={`bun alchemy deploy`} lang="sh" /> |
| 168 | </TabItem> |
| 169 | <TabItem label="npm" icon="npm"> |
| 170 | <Code code={`npx alchemy deploy`} lang="sh" /> |
| 171 | </TabItem> |
| 172 | <TabItem label="pnpm" icon="pnpm"> |
| 173 | <Code code={`pnpm alchemy deploy`} lang="sh" /> |
| 174 | </TabItem> |
| 175 | <TabItem label="yarn" icon="seti:yarn"> |
| 176 | <Code code={`yarn alchemy deploy`} lang="sh" /> |
| 177 | </TabItem> |
| 178 | </Tabs> |
| 179 | |
| 180 | Use Dana's `$KEY` when Git prompts during the checks below. Keep the remote |
| 181 | pointed at `$HOST/$OWNER/web.git` from Part 4. |
| 182 | |
| 183 | ## Verify an ordinary update |
| 184 | |
| 185 | ```sh |
| 186 | printf 'Main is protected from deletion.\n' >> work/README.md |
| 187 | git -C work commit -am "Document the branch rule" |
| 188 | git -c credential.helper= -C work push origin main |
| 189 | ``` |
| 190 | |
| 191 | The push should succeed: updating `main` does not match the deletion rule. |
| 192 | |
| 193 | ## Verify that another branch can be deleted |
| 194 | |
| 195 | ```sh |
| 196 | git -c credential.helper= -C work push origin HEAD:feature |
| 197 | git -c credential.helper= -C work push origin --delete feature |
| 198 | ``` |
| 199 | |
| 200 | Both operations should succeed. |
| 201 | |
| 202 | ## Verify that main cannot be deleted |
| 203 | |
| 204 | ```sh |
| 205 | git -c credential.helper= -C work push origin --delete main |
| 206 | ``` |
| 207 | |
| 208 | Expect the push to be rejected with `main cannot be deleted`. Verify that the |
| 209 | remote still has the branch: |
| 210 | |
| 211 | ```sh |
| 212 | git -c credential.helper= -C work ls-remote origin refs/heads/main |
| 213 | ``` |
| 214 | |
| 215 | It should print the current commit ID and `refs/heads/main`. Authentication |
| 216 | accepted the caller; the application handler rejected the proposed change. |
| 217 | |
| 218 | ## Remove the tutorial deployment |
| 219 | |
| 220 | When you finish experimenting: |
| 221 | |
| 222 | <Tabs syncKey="pkgManager"> |
| 223 | <TabItem label="bun" icon="bun"> |
| 224 | <Code code={`bun alchemy destroy`} lang="sh" /> |
| 225 | </TabItem> |
| 226 | <TabItem label="npm" icon="npm"> |
| 227 | <Code code={`npx alchemy destroy`} lang="sh" /> |
| 228 | </TabItem> |
| 229 | <TabItem label="pnpm" icon="pnpm"> |
| 230 | <Code code={`pnpm alchemy destroy`} lang="sh" /> |
| 231 | </TabItem> |
| 232 | <TabItem label="yarn" icon="seti:yarn"> |
| 233 | <Code code={`yarn alchemy destroy`} lang="sh" /> |
| 234 | </TabItem> |
| 235 | </Tabs> |
| 236 | |
| 237 | This removes the Worker, repositories' storage, R2 bucket contents, and account |
| 238 | database from this tutorial stack. Keep your local `work` checkout if you want |
| 239 | to retain its commits. |
| 240 | |
| 241 | ## Build further |
| 242 | |
| 243 | You have deployed Git, controlled access, allowed public reads, introduced user |
| 244 | credentials, added your own API, and enforced a ref rule. Each behavior can now |
| 245 | change independently. |
| 246 | |
| 247 | - [HTTP routes](/git/blocks/server) covers custom Git handlers and API schemas. |
| 248 | - [Authentication and authorization](/git/blocks/auth) describes the two policy boundaries. |
| 249 | - [Recipes](/git/recipes) covers other storage and hashing implementations. |
| 250 | - The [complete application example](https://github.com/alchemy-run/alchemy/tree/main/examples/cloudflare-git-service) |
| 251 | adds a browser UI and serves it on the Git host's origin. |