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 */
11import * as Context from "effect/Context";
12import * as Effect from "effect/Effect";
13import * as HttpApiMiddleware from "effect/http-api/HttpApiMiddleware";
14import * as Schema from "effect/Schema";
15import * as HttpRouter from "effect/http/HttpRouter";
16import * as HttpServerRequest from "effect/http/HttpServerRequest";
17import * as HttpServerResponse from "effect/http/HttpServerResponse";
18import { GitApi, isRead, RegistryStore } from "@/Git/index.ts";
19import { 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 */
26export 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). */
29export const TEST_SECRET_DEV = "test-secret-git-service-suite-dev";
30export const TEST_USER = { id: "e2e", name: "Suite" } as const;
31export const TEST_USER_DEV = { id: "dev", name: "Dev" } as const;
32process.env.GIT_SERVICE_SECRET ??= TEST_SECRET;
33
34export interface TestUser {
35 readonly id: string;
36 readonly name: string;
37}
38
39/** 401 — no usable credential, on a route that needs one. */
40export 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 */
50export 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.
57class AuthenticationErrors extends HttpApiMiddleware.Service<AuthenticationErrors>()(
58 "test/Git/AuthenticationErrors",
59 { error: Unauthorized },
60) {}
61export 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 */
67const 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"). */
86const 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 */
97export 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;