| 1 | The decision that actually matters: namespace split |
| 2 | |
| 3 | /:username/:repo/info/refs is a greedy root-level route. Registered alongside /repositories, it competes with every current and future REST path, and Nest resolves that by registration order in AppModule.imports — a footgun you'd be re-debugging for months. |
| 4 | |
| 5 | Split the namespaces instead, the way GitHub does (api.github.com vs github.com/user/repo.git). You're already half-way there since Better Auth sits at /api/auth: |
| 6 | |
| 7 | app.setGlobalPrefix('api', { exclude: [{ path: ':username/:repo/(.*)', method: RequestMethod.ALL }] }); |
| 8 | |
| 9 | REST lives under /api/*, the root namespace belongs to the Git transport. This is worth doing before the route table grows — it means changing NEXT_PUBLIC_API_URL usage in fetch-client.ts and regenerating openapi.json. |
| 10 | |
| 11 | Layout |
| 12 | |
| 13 | I'd put this at src/git/, not src/resources/git/. Your resources/ folder means "REST resource, appears in OpenAPI, has DTOs." Git HTTP is a wire protocol — binary streams, pkt-line framing, no JSON, excluded from Swagger. Filing it under resources/ invites people to treat it like one. |
| 14 | |
| 15 | src/git/ |
| 16 | git.module.ts |
| 17 | git-http.controller.ts # the 3 routes, nothing else |
| 18 | git-http.service.ts # resolve repo → authorize → delegate |
| 19 | git.errors.ts # extends your DomainError |
| 20 | git.constants.ts # service names, content types |
| 21 | guards/info/refs?service=git-upload-pack is anonymous for public repos; and a 401 must carry WWW-Authenticate: Basic realm="Ghost" or the client fails instead of prompting for credentials. |
| 22 | |
| 23 | Gzip is optional but common. Git sends Content-Encoding: gzip on POST bodies at its discretion. That's a middleware because it has to wrap the stream before the controller reads it. |
| 24 | |
| 25 | .git suffix. git clone http://host/user/repo.git and .../repo both need to work — normalize in a small param pipe rather than in every handler. |
| 26 | |
| 27 | One thing to flag: repository lookup here is by owner + slug, and your getRepositoryBySlug currently returns void (it only throws or doesn't). The transport needs the actual row to resolve the storage path, so that method wants a real return value before you build on it — related to the inverted collision check I mentioned earlier. |
| 28 | |
| 29 | Want me to scaffold the module and guard, or write up the info/refs pkt-line advertisement specifically? |
| 30 | |
| 31 | ✻ Churned for 1m 7s · done 2:09 PM |
| 32 | |
| 33 | ❯ ➜ git push -u origin main |
| 34 | fatal: repository 'http://localhost:3001/phantomknight287/ghost.git/' not found |
| 35 | |
| 36 | what |
| 37 | |
| 38 | Read 1 file, ran 1 shell command |
| 39 | Interrupted · What should Claude do instead? |
| 40 | |
| 41 | ❯ now read |
| 42 | |
| 43 | ➜ git push -u origin main |
| 44 | fatal: Could not read from remote repository. |
| 45 | |
| 46 | Please make sure you have the correct access rights |
| 47 | and the repository exists. |
| 48 | |
| 49 | i get this but OptionalAuth means no auth is needed so idk tell me |
| 50 | |
| 51 | Searched for 1 pattern |
| 52 | |
| 53 | Found it. Let me confirm with a real request before explaining. |
| 54 | |
| 55 | Ran 2 shell commands |
| 56 | |
| 57 | Confirmed — 35 bytes, and the refs advertisement is entirely absent. The body is only the service header and flush packet. |
| 58 | |
| 59 | Ran 2 shell commands |
| 60 | |
| 61 | Now the real test — an actual push: |
| 62 | |
| 63 | Pushed to main, ran 2 shell commands |
| 64 | bodyParser: false is already set in main.ts — you're safe today, and this is the reason to keep it that way. If you ever re-enable it for the REST side, scope it per-route, or express.json() will consume the packfile stream before your handler sees it. |
| 65 | |
| 66 | Auth diverges from the rest of the app. Git CLI sends HTTP Basic, not your session cookie, so git-auth.guard.ts can't reuse the Better Auth session guard. Three rules it encodes: git-receive-pack always requires auth; info/refs?service=git-upload-pack is anonymous for public repos; and a 401 must carry WWW-Authenticate: Basic realm="Ghost" or the client fails instead of prompting for credentials. |
| 67 | |
| 68 | Gzip is optional but common. Git sends Content-Encoding: gzip on POST bodies at its discretion. That's a middleware because it has to wrap the stream before the controller reads it. |
| 69 | |
| 70 | .git suffix. git clone http://host/user/repo.git and .../repo both need to work — normalize in a small param pipe rather than in every handler. |
| 71 | |
| 72 | One thing to flag: repository lookup here is by owner + slug, and your getRepositoryBySlug currently returns void (it only throws or doesn't). The transport needs the actual row to resolve the storage path, so that method wants a real return value before you build on it — related to the inverted collision check I mentioned earlier. |
| 73 | |
| 74 | Want me to scaffold the module and guard, or write up the info/refs pkt-line advertisement specifically? |