| 1 | --- |
| 2 | title: "Part 3: Publish a repository" |
| 3 | description: Allow anonymous clones of public repositories while preserving authenticated writes. |
| 4 | --- |
| 5 | |
| 6 | import { Code, Tabs, TabItem } from "@astrojs/starlight/components"; |
| 7 | |
| 8 | The credential from [Part 2](/git/tutorial/part-2) protects every request. |
| 9 | Now make `acme/web` readable by anyone while keeping writes protected. |
| 10 | |
| 11 | ## Mark the repository public |
| 12 | |
| 13 | ```sh |
| 14 | curl --fail-with-body -u "x:$GIT_SECRET" \ |
| 15 | -X PATCH "$HOST/api/v1/repos/acme/web" \ |
| 16 | -H "Content-Type: application/json" \ |
| 17 | -d '{"public":true}' |
| 18 | ``` |
| 19 | |
| 20 | `public` is stored repository metadata. It does not bypass your middleware. |
| 21 | An anonymous request still receives `401` until you teach the policy to use it. |
| 22 | |
| 23 | ## Look up the repository's visibility |
| 24 | |
| 25 | Create `src/public-read.ts`: |
| 26 | |
| 27 | ```typescript |
| 28 | // src/public-read.ts |
| 29 | import * as Git from "alchemy/Git"; |
| 30 | import * as Effect from "effect/Effect"; |
| 31 | import * as HttpRouter from "effect/http/HttpRouter"; |
| 32 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 33 | |
| 34 | export const PublicRead = Effect.gen(function* () { |
| 35 | const registry = yield* Git.RegistryStore; |
| 36 | return Effect.gen(function* () { |
| 37 | const request = yield* HttpServerRequest; |
| 38 | if (!Git.isRead(request)) return false; |
| 39 | const { owner, repo } = yield* HttpRouter.params; |
| 40 | if (owner === undefined || repo === undefined) return false; |
| 41 | const entry = yield* registry |
| 42 | .resolve(owner.toLowerCase(), repo.toLowerCase().replace(/\.git$/, "")) |
| 43 | .pipe(Effect.catchTag("StoreError", () => Effect.succeed(undefined))); |
| 44 | return entry?.public === true; |
| 45 | }); |
| 46 | }); |
| 47 | ``` |
| 48 | |
| 49 | The outer effect acquires the registry when the routes are built. The inner |
| 50 | effect checks the current request. It permits only reads of a named, public |
| 51 | repository. Listing every repository is not a read of one public repository. |
| 52 | |
| 53 | `Git.isRead` understands Git's protocol: a clone includes a POST to |
| 54 | `git-upload-pack`, while discovery for a push must remain protected. |
| 55 | |
| 56 | ## Permit public reads in the middleware |
| 57 | |
| 58 | Import the check: |
| 59 | |
| 60 | ```diff lang="typescript" |
| 61 | // src/middleware.ts |
| 62 | import { GitSecret } from "./secret.ts"; |
| 63 | +import { PublicRead } from "./public-read.ts"; |
| 64 | ``` |
| 65 | |
| 66 | Acquire it alongside the credential: |
| 67 | |
| 68 | ```diff lang="typescript" |
| 69 | // src/middleware.ts |
| 70 | const secret = yield* (yield* GitSecret).text; |
| 71 | + const publicRead = yield* PublicRead; |
| 72 | ``` |
| 73 | |
| 74 | Before returning `401`, allow requests that pass the public-read check: |
| 75 | |
| 76 | ```diff lang="typescript" |
| 77 | // src/middleware.ts |
| 78 | if (Redacted.value(password) === Redacted.value(yield* secret)) { |
| 79 | return yield* httpEffect; |
| 80 | } |
| 81 | + if (yield* publicRead) return yield* httpEffect; |
| 82 | return HttpServerResponse.empty({ |
| 83 | ``` |
| 84 | |
| 85 | The existing `Git.RegistryDurableObject` layer supplies this new dependency. |
| 86 | The shared credential still allows all operations; anonymous callers only get |
| 87 | the public reads you just defined. |
| 88 | |
| 89 | ## Deploy the policy |
| 90 | |
| 91 | <Tabs syncKey="pkgManager"> |
| 92 | <TabItem label="bun" icon="bun"> |
| 93 | <Code code={`bun alchemy deploy`} lang="sh" /> |
| 94 | </TabItem> |
| 95 | <TabItem label="npm" icon="npm"> |
| 96 | <Code code={`npx alchemy deploy`} lang="sh" /> |
| 97 | </TabItem> |
| 98 | <TabItem label="pnpm" icon="pnpm"> |
| 99 | <Code code={`pnpm alchemy deploy`} lang="sh" /> |
| 100 | </TabItem> |
| 101 | <TabItem label="yarn" icon="seti:yarn"> |
| 102 | <Code code={`yarn alchemy deploy`} lang="sh" /> |
| 103 | </TabItem> |
| 104 | </Tabs> |
| 105 | |
| 106 | This updates the Worker. The public flag you set earlier remains stored in the |
| 107 | registry. |
| 108 | |
| 109 | ## Clone without credentials |
| 110 | |
| 111 | ```sh |
| 112 | GIT_TERMINAL_PROMPT=0 git -c credential.helper= \ |
| 113 | clone "$HOST/acme/web.git" public-copy |
| 114 | git -C public-copy fsck --strict |
| 115 | ``` |
| 116 | |
| 117 | The clone should succeed without a password. Disabling the credential helper |
| 118 | ensures this check does not accidentally use a saved credential. |
| 119 | |
| 120 | ## Verify that anonymous writes still fail |
| 121 | |
| 122 | ```sh |
| 123 | GIT_TERMINAL_PROMPT=0 git -c credential.helper= -C work \ |
| 124 | push origin HEAD:anonymous-write |
| 125 | ``` |
| 126 | |
| 127 | Expect authentication to fail and no `anonymous-write` branch to be created. |
| 128 | An authenticated push from Part 2 still works. |
| 129 | |
| 130 | ## Verify that private reads still fail |
| 131 | |
| 132 | Temporarily make the repository private: |
| 133 | |
| 134 | ```sh |
| 135 | curl --fail-with-body -u "x:$GIT_SECRET" \ |
| 136 | -X PATCH "$HOST/api/v1/repos/acme/web" \ |
| 137 | -H "Content-Type: application/json" \ |
| 138 | -d '{"public":false}' |
| 139 | curl -i "$HOST/api/v1/repos/acme/web" |
| 140 | ``` |
| 141 | |
| 142 | The anonymous read must return `401`. Restore the public setting before |
| 143 | continuing: |
| 144 | |
| 145 | ```sh |
| 146 | curl --fail-with-body -u "x:$GIT_SECRET" \ |
| 147 | -X PATCH "$HOST/api/v1/repos/acme/web" \ |
| 148 | -H "Content-Type: application/json" \ |
| 149 | -d '{"public":true}' |
| 150 | ``` |
| 151 | |
| 152 | [Part 4: Give users their own credentials](/git/tutorial/part-4) replaces the |
| 153 | shared credential with accounts and individual API keys. |