1/**
2 * Who may call what. Native `HttpApiMiddleware` on the application's API,
3 * git's and ours: REST, the wire, the raw reads, and the GitHub facade.
4 * A user owns the repositories under their own name; anyone may read a
5 * public one.
6 */
7import { RuntimeContext } from "alchemy";
8import * as Git from "alchemy/Git";
9import * as Effect from "effect/Effect";
10import * as Redacted from "effect/Redacted";
11import * as Layer from "effect/Layer";
12import * as HttpApiMiddleware from "effect/http-api/HttpApiMiddleware";
13import * as HttpRouter from "effect/http/HttpRouter";
14import { HttpServerRequest } from "effect/http/HttpServerRequest";
15import * as HttpServerResponse from "effect/http/HttpServerResponse";
16import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
17import * as HttpApiSecurity from "effect/http-api/HttpApiSecurity";
18import { Auth, Session, Unauthorized } from "./auth.ts";
19
20/** A 401 that makes `git` ask for credentials. */
21const unauthorized = HttpServerResponse.jsonUnsafe(
22 { _tag: "Unauthorized" },
23 { status: 401, headers: { "www-authenticate": 'Basic realm="git"' } },
24);
25
26/**
27 * An API key in the password field of the remote names a user; otherwise
28 * the session cookie does; otherwise the request is anonymous. A user may
29 * do anything under their own owner name and use the routes that have no
30 * owner (create, list, import); anyone may read a public repository.
31 */
32export class Authentication extends HttpApiMiddleware.Service<
33 Authentication,
34 { provides: Session }
35>()("app/Authentication", { error: Unauthorized }) {
36 static readonly layer = Layer.effect(
37 Authentication,
38 Effect.gen(function* () {
39 const auth = yield* Auth;
40 const registry = yield* Git.RegistryStore;
41
42 const resolve = Effect.gen(function* () {
43 const { password } = yield* HttpApiBuilder.securityDecode(
44 HttpApiSecurity.basic,
45 );
46 const key = Redacted.value(password);
47 if (key !== "") {
48 const verified = yield* auth.api
49 .verifyApiKey({ body: { key } })
50 .pipe(
51 Effect.catchTag("BetterAuthApiError", () =>
52 Effect.succeed({ valid: false as const, key: null }),
53 ),
54 );
55 return verified.valid && verified.key
56 ? { id: verified.key.referenceId }
57 : undefined;
58 }
59 const session = yield* auth
60 .getSession()
61 .pipe(
62 Effect.catchTag("BetterAuthApiError", () => Effect.succeed(null)),
63 );
64 return session
65 ? { id: session.user.id, name: session.user.name }
66 : undefined;
67 });
68
69 /**
70 * Anonymous may read one public repository. A repository that does
71 * not exist is the route's 404, so a 401 never confirms a private one.
72 */
73 const publicRead = Effect.gen(function* () {
74 const params = yield* HttpRouter.params;
75 const owner = params.owner?.toLowerCase();
76 const name = params.repo?.toLowerCase().replace(/\.git$/, "");
77 if (owner === undefined || name === undefined) return false;
78 const entry = yield* registry
79 .resolve(owner, name)
80 .pipe(Effect.catchTag("StoreError", () => Effect.succeed(undefined)));
81 return entry === undefined || entry.public;
82 });
83
84 return (httpEffect) =>
85 Effect.gen(function* () {
86 const user = yield* resolve;
87 const { owner } = yield* HttpRouter.params;
88 const own =
89 owner === undefined ||
90 owner.toLowerCase() === user?.id.toLowerCase();
91 if (user !== undefined && own) {
92 return yield* Effect.provideService(httpEffect, Session, { user });
93 }
94 const request = yield* HttpServerRequest;
95 if (Git.isRead(request) && (yield* publicRead)) {
96 return yield* Effect.provideService(httpEffect, Session, {
97 user: user ?? null,
98 });
99 }
100 return unauthorized;
101 }).pipe(Effect.provide(RuntimeContext.phantom));
102 }),
103 );
104}