| 1 | /** |
| 2 | * The suites' auth, in user land, the way a host builds it: one `HttpRouter` |
| 3 | * middleware in front of every git route. Two shared secrets name two |
| 4 | * users; anything else is anonymous, and anonymous may read public |
| 5 | * repositories and nothing more. The engine never sees a credential. |
| 6 | * |
| 7 | * So that the suites work out of the box (local dev especially), a |
| 8 | * deterministic default `GIT_SERVICE_SECRET` is installed into |
| 9 | * `process.env` at module load unless the caller already exported one. |
| 10 | */ |
| 11 | import * as Context from "effect/Context"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as HttpApiMiddleware from "effect/http-api/HttpApiMiddleware"; |
| 14 | import * as Schema from "effect/Schema"; |
| 15 | import * as HttpRouter from "effect/http/HttpRouter"; |
| 16 | import * as HttpServerRequest from "effect/http/HttpServerRequest"; |
| 17 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 18 | import { GitApi, isRead, RegistryStore } from "@/Git/index.ts"; |
| 19 | import { RuntimeContext } from "@/RuntimeContext.ts"; |
| 20 | |
| 21 | /** |
| 22 | * The secret every suite authenticates with. Honors a caller-provided |
| 23 | * `GIT_SERVICE_SECRET` (the deployed-cloud suites may point at a |
| 24 | * standing deployment); otherwise a deterministic test-only value. |
| 25 | */ |
| 26 | export const TEST_SECRET: string = |
| 27 | process.env.GIT_SERVICE_SECRET ?? "test-secret-git-service-suite"; |
| 28 | /** A second credential, resolved to a second user (for the hook tests). */ |
| 29 | export const TEST_SECRET_DEV = "test-secret-git-service-suite-dev"; |
| 30 | export const TEST_USER = { id: "e2e", name: "Suite" } as const; |
| 31 | export const TEST_USER_DEV = { id: "dev", name: "Dev" } as const; |
| 32 | process.env.GIT_SERVICE_SECRET ??= TEST_SECRET; |
| 33 | |
| 34 | export interface TestUser { |
| 35 | readonly id: string; |
| 36 | readonly name: string; |
| 37 | } |
| 38 | |
| 39 | /** 401 — no usable credential, on a route that needs one. */ |
| 40 | export class Unauthorized extends Schema.TaggedError<Unauthorized>()( |
| 41 | "Unauthorized", |
| 42 | {}, |
| 43 | { httpApiStatus: 401 }, |
| 44 | ) {} |
| 45 | |
| 46 | /** |
| 47 | * Who is calling, as the middleware resolved it: a user, or `null` for an |
| 48 | * anonymous read of a public repository. Routes and hooks read it. |
| 49 | */ |
| 50 | export class TestCaller extends Context.Service< |
| 51 | TestCaller, |
| 52 | { readonly user: TestUser | null } |
| 53 | >()("test/Git/Caller") {} |
| 54 | |
| 55 | /** API schema for typed clients. Authentication is applied to router layers. */ |
| 56 | // Client-only error declaration: router middleware returns this error on 401. |
| 57 | class AuthenticationErrors extends HttpApiMiddleware.Service<AuthenticationErrors>()( |
| 58 | "test/Git/AuthenticationErrors", |
| 59 | { error: Unauthorized }, |
| 60 | ) {} |
| 61 | export class TestApi extends GitApi.middleware(AuthenticationErrors) {} |
| 62 | |
| 63 | /** |
| 64 | * The credential a request carries: `git` sends HTTP Basic with the |
| 65 | * token in the password field, the REST clients a bearer token. |
| 66 | */ |
| 67 | const credential = ( |
| 68 | headers: Readonly<Record<string, string | undefined>>, |
| 69 | ): string | undefined => { |
| 70 | const header = headers.authorization; |
| 71 | if (header === undefined) return undefined; |
| 72 | const space = header.indexOf(" "); |
| 73 | if (space === -1) return undefined; |
| 74 | const scheme = header.slice(0, space).toLowerCase(); |
| 75 | const rest = header.slice(space + 1).trim(); |
| 76 | if (scheme === "bearer" || scheme === "token") return rest || undefined; |
| 77 | if (scheme === "basic") { |
| 78 | const decoded = Buffer.from(rest, "base64").toString("utf8"); |
| 79 | const colon = decoded.indexOf(":"); |
| 80 | return colon === -1 ? undefined : decoded.slice(colon + 1) || undefined; |
| 81 | } |
| 82 | return undefined; |
| 83 | }; |
| 84 | |
| 85 | /** A 401 that makes `git` ask for credentials (or fail with "Authentication failed"). */ |
| 86 | const unauthorized = HttpServerResponse.jsonUnsafe( |
| 87 | { _tag: "Unauthorized" }, |
| 88 | { status: 401, headers: { "www-authenticate": 'Basic realm="git"' } }, |
| 89 | ); |
| 90 | |
| 91 | /** |
| 92 | * Two secrets, two users; anything else is anonymous. Anonymous callers |
| 93 | * may read a public repository: the REST and raw reads, the GitHub |
| 94 | * facade's `GET`s, and a clone or fetch over the wire. Everything else |
| 95 | * is a 401 with `WWW-Authenticate`, so `git` prompts. |
| 96 | */ |
| 97 | export const TestAuthLive = HttpRouter.middleware<{ provides: TestCaller }>()( |
| 98 | Effect.gen(function* () { |
| 99 | const registry = yield* RegistryStore; |
| 100 | return (httpEffect) => |
| 101 | Effect.gen(function* () { |
| 102 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 103 | const presented = credential(request.headers); |
| 104 | if (presented === TEST_SECRET) { |
| 105 | return yield* Effect.provideService(httpEffect, TestCaller, { |
| 106 | user: TEST_USER, |
| 107 | }); |
| 108 | } |
| 109 | if (presented === TEST_SECRET_DEV) { |
| 110 | return yield* Effect.provideService(httpEffect, TestCaller, { |
| 111 | user: TEST_USER_DEV, |
| 112 | }); |
| 113 | } |
| 114 | if (presented !== undefined) return unauthorized; |
| 115 | |
| 116 | // Anonymous: a read of one public repository, or nothing. A |
| 117 | // repository that does not exist is the route's 404, so a 401 |
| 118 | // never confirms a private one. |
| 119 | if (!isRead(request)) return unauthorized; |
| 120 | const params = yield* HttpRouter.params; |
| 121 | const owner = params.owner?.toLowerCase(); |
| 122 | const name = params.repo?.toLowerCase().replace(/\.git$/, ""); |
| 123 | if (owner === undefined || name === undefined) return unauthorized; |
| 124 | const entry = yield* registry |
| 125 | .resolve(owner, name) |
| 126 | .pipe(Effect.catchTag("StoreError", () => Effect.succeed(undefined))); |
| 127 | if (entry !== undefined && !entry.public) return unauthorized; |
| 128 | return yield* Effect.provideService(httpEffect, TestCaller, { |
| 129 | user: null, |
| 130 | }); |
| 131 | }).pipe(Effect.provide(RuntimeContext.phantom)); |
| 132 | }), |
| 133 | ).layer; |