1import * as firebaseappcheck from "@distilled.cloud/gcp/firebaseappcheck_v1";
2import * as firebase from "@distilled.cloud/gcp/firebase_v1beta1";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import {
9 alchemyLabelKeys,
10 createInternalLabels,
11 hasAlchemyLabels,
12} from "../Labels.ts";
13import { isTransientGcpError } from "../Errors.ts";
14
15export const DEFAULT_SERVICE_ID = "oauth2.googleapis.com";
16export const DEFAULT_ENFORCEMENT_MODE = "UNENFORCED";
17export const RESOURCE_POLICY_SERVICES = [DEFAULT_SERVICE_ID] as const;
18export const DUMMY_CLIENT_PREFIX = "alc-";
19export const MAX_DISPLAY_NAME = 63;
20
21export class ResourceNotResolved extends Data.TaggedError(
22 "GCP.FirebaseAppCheck.ResourceNotResolved",
23)<{
24 name: string;
25}> {}
26
27export const lastSegment = (value: string) => {
28 const trimmed = value.replace(/\/+$/, "");
29 const parts = trimmed.split("/");
30 return parts[parts.length - 1] || trimmed;
31};
32
33export const projectParent = (project: string) => `projects/${project}`;
34
35export const expandApp = (project: string, value: string) => {
36 const trimmed = value.replace(/\/+$/, "");
37 if (trimmed.includes("/apps/")) return trimmed;
38 return `${projectParent(project)}/apps/${trimmed}`;
39};
40
41export const appIdOf = (app: string) => {
42 const parts = app.split("/").filter((part) => part.length > 0);
43 const appsAt = parts.lastIndexOf("apps");
44 return appsAt >= 0 && parts[appsAt + 1]
45 ? parts[appsAt + 1]!
46 : lastSegment(app);
47};
48
49export const parseDebugTokenName = (name: string) => {
50 const parts = name.split("/").filter((part) => part.length > 0);
51 const projectsAt = parts.lastIndexOf("projects");
52 const appsAt = parts.lastIndexOf("apps");
53 const tokensAt = parts.lastIndexOf("debugTokens");
54 return {
55 project:
56 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
57 appId: appsAt >= 0 && parts[appsAt + 1] ? parts[appsAt + 1]! : "",
58 debugTokenId:
59 tokensAt >= 0 && parts[tokensAt + 1]
60 ? parts[tokensAt + 1]!
61 : lastSegment(name),
62 app:
63 appsAt >= 0
64 ? parts.slice(0, tokensAt >= 0 ? tokensAt : parts.length).join("/")
65 : "",
66 };
67};
68
69export const parseResourcePolicyName = (name: string) => {
70 const parts = name.split("/").filter((part) => part.length > 0);
71 const projectsAt = parts.lastIndexOf("projects");
72 const servicesAt = parts.lastIndexOf("services");
73 const policiesAt = parts.lastIndexOf("resourcePolicies");
74 return {
75 project:
76 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
77 serviceId:
78 servicesAt >= 0 && parts[servicesAt + 1] ? parts[servicesAt + 1]! : "",
79 resourcePolicyId:
80 policiesAt >= 0 && parts[policiesAt + 1]
81 ? parts[policiesAt + 1]!
82 : lastSegment(name),
83 parent:
84 servicesAt >= 0
85 ? parts.slice(0, policiesAt >= 0 ? policiesAt : parts.length).join("/")
86 : "",
87 };
88};
89
90export const serviceParent = (project: string, serviceId: string) =>
91 `${projectParent(project)}/services/${serviceId}`;
92
93export const encodeDisplayName = (
94 labels: Record<string, string>,
95 displayName: string | undefined,
96): string => {
97 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
98 const combined =
99 displayName && displayName.length > 0 ? `${marker} ${displayName}` : marker;
100 return combined.slice(0, 1024);
101};
102
103export const parseDisplayName = (
104 displayName: string | undefined,
105): {
106 labels: Record<string, string>;
107 displayName: string | undefined;
108} => {
109 if (!displayName?.startsWith("[alchemy ")) {
110 return { labels: {}, displayName };
111 }
112 const end = displayName.indexOf("]");
113 if (end < 0) return { labels: {}, displayName };
114 const labels: Record<string, string> = {};
115 for (const part of displayName.slice("[alchemy ".length, end).split(/\s+/)) {
116 const eq = part.indexOf("=");
117 if (eq > 0) {
118 labels[part.slice(0, eq)] = part.slice(eq + 1);
119 }
120 }
121 const rest = displayName.slice(end + 1).trim();
122 return { labels, displayName: rest.length > 0 ? rest : undefined };
123};
124
125export const hasOwnershipMarker = (displayName: string | undefined) =>
126 Object.keys(parseDisplayName(displayName).labels).some((key) =>
127 key.startsWith("alchemy-"),
128 );
129
130export const ownedByAlchemy = (id: string, displayName: string | undefined) =>
131 Effect.gen(function* () {
132 const { labels } = parseDisplayName(displayName);
133 return yield* hasAlchemyLabels(id, labels);
134 });
135
136export const createOwnership = (id: string) => createInternalLabels(id);
137
138export const dummyClientId = (labels: Record<string, string>) => {
139 const id = labels[alchemyLabelKeys.id] ?? "x";
140 return `${DUMMY_CLIENT_PREFIX}${id}`.slice(0, 63);
141};
142
143export const dummyTargetResource = (
144 project: string,
145 labels: Record<string, string>,
146) =>
147 `//oauth2.googleapis.com/projects/${project}/oauthClients/${dummyClientId(labels)}`;
148
149export const hasDummyAlchemyTarget = (targetResource: string | undefined) =>
150 lastSegment(targetResource ?? "").startsWith(DUMMY_CLIENT_PREFIX);
151
152export const ownedDummyTarget = (
153 targetResource: string | undefined,
154 labels: Record<string, string>,
155) => lastSegment(targetResource ?? "") === dummyClientId(labels);
156
157export const toDisplayName = (
158 id: string,
159 explicit: string | undefined,
160 existing?: string,
161) =>
162 Effect.gen(function* () {
163 if (explicit !== undefined) return explicit;
164 if (existing !== undefined) return existing;
165 return yield* createPhysicalName({
166 id,
167 maxLength: MAX_DISPLAY_NAME,
168 lowercase: true,
169 });
170 });
171
172export const randomUuid4 = () => Effect.sync(() => crypto.randomUUID());
173
174export const toToken = (explicit: string | undefined, existing?: string) =>
175 Effect.gen(function* () {
176 if (explicit !== undefined && explicit.length > 0) return explicit;
177 if (existing !== undefined && existing.length > 0) return existing;
178 return yield* randomUuid4();
179 });
180
181export const sameText = (left: string | undefined, right: string | undefined) =>
182 (left ?? "") === (right ?? "");
183
184export const sameTextInsensitive = (
185 left: string | undefined,
186 right: string | undefined,
187) => (left ?? "").toLowerCase() === (right ?? "").toLowerCase();
188
189export const normalizeEnforcement = (value: string | undefined) =>
190 (value ?? DEFAULT_ENFORCEMENT_MODE).toUpperCase();
191
192export const replaceOnIdentity = (input: {
193 previous?: string;
194 next?: string;
195 extra?: boolean;
196 deleteFirst?: boolean;
197}) => {
198 if (input.extra === true) {
199 return {
200 action: "replace" as const,
201 deleteFirst: input.deleteFirst !== false,
202 };
203 }
204 if (
205 input.previous !== undefined &&
206 input.next !== undefined &&
207 input.previous !== input.next
208 ) {
209 return {
210 action: "replace" as const,
211 deleteFirst: input.deleteFirst !== false,
212 };
213 }
214 return undefined;
215};
216
217export const retryTransient = <A, E extends { readonly _tag: string }, R>(
218 effect: Effect.Effect<A, E, R>,
219) =>
220 effect.pipe(
221 Effect.retry({
222 while: isTransientGcpError,
223 times: 8,
224 schedule: Schedule.exponential("250 millis"),
225 }),
226 );
227
228export const getDebugToken = (name: string) =>
229 name.length === 0
230 ? Effect.succeed(undefined)
231 : firebaseappcheck
232 .getProjectsAppsDebugTokens({ name })
233 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
234
235export const getResourcePolicy = (name: string) =>
236 name.length === 0
237 ? Effect.succeed(undefined)
238 : firebaseappcheck
239 .getProjectsServicesResourcePolicies({ name })
240 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
241
242const emptyList = <A>() => Effect.succeed<A[]>([]);
243
244export const collectPages = <Page, Item, E, R>(
245 pages: Stream.Stream<Page, E, R>,
246 items: (page: Page) => readonly Item[] | null | undefined,
247) =>
248 pages.pipe(
249 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
250 Stream.runCollect,
251 Effect.map((chunk) => Array.from(chunk) as Item[]),
252 );
253
254export const listFirebaseApps = (project: string) =>
255 collectPages(
256 firebase.searchAppsProjects.pages({
257 parent: projectParent(project),
258 pageSize: 100,
259 }),
260 (page) => page.apps,
261 ).pipe(
262 Effect.catchTag("NotFound", () => emptyList<firebase.FirebaseAppInfo>()),
263 );
264
265export const listDebugTokensForApp = (app: string) =>
266 collectPages(
267 firebaseappcheck.listProjectsAppsDebugTokens.pages({
268 parent: app,
269 pageSize: 20,
270 }),
271 (page) => page.debugTokens,
272 ).pipe(
273 Effect.catchTag("NotFound", () =>
274 emptyList<firebaseappcheck.GoogleFirebaseAppcheckV1DebugToken>(),
275 ),
276 );
277
278export const listOwnedDebugTokens = (project: string) =>
279 Effect.gen(function* () {
280 const apps = yield* listFirebaseApps(project);
281 const pages = yield* Effect.forEach(
282 apps.filter((app) => (app.appId ?? "").length > 0),
283 (app) => listDebugTokensForApp(expandApp(project, app.appId ?? "")),
284 { concurrency: 5 },
285 );
286 return pages
287 .flat()
288 .filter((token) => hasOwnershipMarker(token.displayName));
289 });
290
291export const findOwnedDebugToken = (
292 tokens: readonly firebaseappcheck.GoogleFirebaseAppcheckV1DebugToken[],
293 id: string,
294 name?: string,
295) =>
296 Effect.gen(function* () {
297 if (name) {
298 const match = tokens.find((token) => token.name === name);
299 if (match) return match;
300 }
301 for (const token of tokens) {
302 if (yield* ownedByAlchemy(id, token.displayName)) return token;
303 }
304 return undefined;
305 });
306
307export const listResourcePolicies = (project: string, serviceId: string) =>
308 collectPages(
309 firebaseappcheck.listProjectsServicesResourcePolicies.pages({
310 parent: serviceParent(project, serviceId),
311 pageSize: 100,
312 }),
313 (page) => page.resourcePolicies,
314 ).pipe(
315 Effect.catchTag("NotFound", () =>
316 emptyList<firebaseappcheck.GoogleFirebaseAppcheckV1ResourcePolicy>(),
317 ),
318 );
319
320export const listOwnedResourcePolicies = (project: string) =>
321 Effect.gen(function* () {
322 const pages = yield* Effect.forEach(
323 RESOURCE_POLICY_SERVICES,
324 (serviceId) => listResourcePolicies(project, serviceId),
325 { concurrency: 2 },
326 );
327 return pages
328 .flat()
329 .filter((policy) => hasDummyAlchemyTarget(policy.targetResource));
330 });
331
332export const findOwnedResourcePolicy = (
333 policies: readonly firebaseappcheck.GoogleFirebaseAppcheckV1ResourcePolicy[],
334 labels: Record<string, string>,
335 name?: string,
336) => {
337 if (name) {
338 const match = policies.find((policy) => policy.name === name);
339 if (match) return match;
340 }
341 return policies.find((policy) =>
342 ownedDummyTarget(policy.targetResource, labels),
343 );
344};