flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/web/app/lib/access.server.ts

131 lines5,314 bytesCodeBlame
1import { data } from "react-router";
2
3import {
4 type Capability,
5 type Repo,
6 type Result,
7 type Viewer,
8 abilities,
9 can,
10 granted,
11 httpStatus,
12 needs,
13 permission,
14} from "@g1t/contracts";
15
16import type { ViewerAccess } from "./access";
17import { repos, work } from "./services.server";
18import { getViewer } from "./session.server";
19
20type Context = Parameters<typeof getViewer>[0];
21type RepoParams = { owner?: string; repo?: string };
22
23/**
24 * One count of a project's open issues and pull requests per request: the
25 * sidebar (root) and the project's tabs (its layout) both show them.
26 */
27const tallies = new WeakMap<object, Map<string, ReturnType<typeof work.counts>>>();
28
29export function countsFor(context: Context, params: RepoParams): ReturnType<typeof work.counts> {
30 const key = `${params.owner}/${params.repo}`.toLowerCase();
31 let seen = tallies.get(context);
32 if (!seen) tallies.set(context, (seen = new Map()));
33 let found = seen.get(key);
34 if (!found) {
35 found = work.counts({ namespace: params.owner ?? "", name: params.repo ?? "" }, getViewer(context));
36 seen.set(key, found);
37 }
38 return found;
39}
40
41/**
42 * The repositories of these ids the viewer can read: one call for all of
43 * them, then one each for any it leaves out (forks).
44 */
45export async function readableRepos(ids: string[], viewer: Viewer): Promise<Repo[]> {
46 if (ids.length === 0) return [];
47 const found = await repos.readable(ids, viewer).catch(() => [] as Repo[]);
48 const seen = new Set(found.map((repo) => repo.id));
49 const rest = await Promise.all(ids.filter((id) => !seen.has(id)).map((id) => repos.getById(id, viewer).catch(() => null)));
50 return [...found, ...rest.flatMap((repo) => (repo?.ok ? [repo.value] : []))];
51}
52
53/**
54 * One lookup of a repository per request: the repository's layout and the
55 * page under it load at the same time and both need it.
56 */
57const lookups = new WeakMap<object, Map<string, Promise<Result<Repo>>>>();
58
59export function repoFor(context: Context, params: RepoParams): Promise<Result<Repo>> {
60 const key = `${params.owner}/${params.repo}`.toLowerCase();
61 let seen = lookups.get(context);
62 if (!seen) lookups.set(context, (seen = new Map()));
63 let found = seen.get(key);
64 if (!found) {
65 found = repos.get({ namespace: params.owner ?? "", name: params.repo ?? "" }, getViewer(context));
66 seen.set(key, found);
67 }
68 return found;
69}
70
71/** The viewer's role on a repository and what it lets them do. */
72export function accessFor(viewer: Viewer, repo: Repo): ViewerAccess {
73 return {
74 role: permission(viewer, repo),
75 // A role of their own, not only because the repository is public.
76 insider: viewer ? granted(viewer, repo) != null : false,
77 can: abilities(viewer, repo),
78 };
79}
80
81/**
82 * The repository and the viewer's access to it, refusing with a 404 when
83 * they cannot read it and a 403 that says which role is needed when they
84 * can read it but not do `capability`.
85 */
86export async function requireRepo(context: Context, params: RepoParams, capability: Capability = "read") {
87 const viewer = getViewer(context);
88 const found = await repoFor(context, params);
89 if (!found.ok) {
90 if (found.error.code === "not_found" || found.error.code === "forbidden") throw data(null, { status: 404 });
91 throw data(found.error.message, { status: httpStatus(found.error) });
92 }
93 const access = accessFor(viewer, found.value);
94 if (!access.can.read) throw data(null, { status: 404 });
95 if (!access.can[capability]) throw data(needs(capability), { status: 403 });
96 return { viewer, repo: found.value, access };
97}
98
99/**
100 * Pages only people with a role of their own see (plans, memory,
101 * deployments, security, settings): a 404 for anyone else, as before.
102 */
103export async function requireInsider(context: Context, params: RepoParams, capability: Capability = "read") {
104 const found = await requireRepo(context, params, "read");
105 if (!found.access.insider) throw data(null, { status: 404 });
106 if (!found.access.can[capability]) throw data(needs(capability), { status: 403 });
107 return found;
108}
109
110/** The viewer's access, or none when the repository cannot be read. */
111export async function accessTo(context: Context, params: RepoParams): Promise<ViewerAccess> {
112 const found = await repoFor(context, params);
113 return found.ok ? accessFor(getViewer(context), found.value) : { role: null, insider: false, can: abilities(null, { id: "", namespace: "", isPrivate: true }) };
114}
115
116/**
117 * For an action: why the viewer may not do `capability` here, or null when
118 * they may. Not cached, since the action may change the repository.
119 */
120export async function refusal(context: Context, params: RepoParams, capability: Capability): Promise<string | null> {
121 const viewer = getViewer(context);
122 const found = await repos.get({ namespace: params.owner ?? "", name: params.repo ?? "" }, viewer);
123 if (!found.ok) return found.error.message;
124 return can(viewer, found.value, capability) ? null : needs(capability);
125}
126
127/** For an action whose page only people with `capability` see: a 403 that says why, otherwise nothing. */
128export async function requireCapability(context: Context, params: RepoParams, capability: Capability): Promise<void> {
129 const refused = await refusal(context, params, capability);
130 if (refused) throw data(refused, { status: 403 });
131}