1import * as firebaserules from "@distilled.cloud/gcp/firebaserules_v1";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { createPhysicalName } from "../../PhysicalName.ts";
5import {
6 alchemyLabelKeys,
7 createInternalLabels,
8 hasAlchemyLabels,
9} from "../Labels.ts";
10
11export const MAX_RELEASE_ID_LENGTH = 63;
12export const GENERATED_RELEASE_PREFIX = "alc-";
13
14export type RulesetFile = {
15 /** Path of this file in the ruleset (e.g. `firestore.rules`). */
16 name: string;
17 /** Textual rules content. */
18 content: string;
19 /** Optional fingerprint (git SHA, etag, …) associated with the file. */
20 fingerprint?: string;
21};
22
23export type RulesetSource = {
24 /** Files that make up the ruleset. At least one file is required. */
25 files: RulesetFile[];
26};
27
28export const lastSegment = (value: string) => {
29 const trimmed = value.replace(/\/+$/, "");
30 const parts = trimmed.split("/");
31 return parts[parts.length - 1] || trimmed;
32};
33
34export const projectParent = (project: string) =>
35 project.startsWith("projects/") ? project : `projects/${project}`;
36
37export const rulesetResourceName = (project: string, rulesetId: string) =>
38 `${projectParent(project)}/rulesets/${rulesetId}`;
39
40export const releaseResourceName = (project: string, releaseId: string) =>
41 `${projectParent(project)}/releases/${releaseId}`;
42
43export const expandRulesetName = (project: string, value: string) => {
44 const trimmed = value.replace(/\/+$/, "");
45 if (trimmed.includes("/")) return trimmed;
46 return rulesetResourceName(project, trimmed);
47};
48
49export const rulesetIdOf = (name: string) => {
50 const marker = "/rulesets/";
51 const index = name.indexOf(marker);
52 return index >= 0 ? name.slice(index + marker.length) : lastSegment(name);
53};
54
55export const releaseIdOf = (name: string) => {
56 const marker = "/releases/";
57 const index = name.indexOf(marker);
58 return index >= 0 ? name.slice(index + marker.length) : lastSegment(name);
59};
60
61export const sameText = (left: string | undefined, right: string | undefined) =>
62 (left ?? "") === (right ?? "");
63
64const markerOf = (labels: Record<string, string>) =>
65 `// [alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
66
67const OWNERSHIP_LINE = /^\/\/ \[alchemy [^\]]*\]\r?\n?/;
68
69export const stripOwnershipLine = (content: string | undefined): string =>
70 (content ?? "").replace(OWNERSHIP_LINE, "");
71
72export const parseOwnership = (
73 content: string | undefined,
74): {
75 labels: Record<string, string>;
76 content: string;
77} => {
78 const raw = content ?? "";
79 if (!raw.startsWith("// [alchemy ")) {
80 return { labels: {}, content: raw };
81 }
82 const end = raw.indexOf("]");
83 if (end < 0) return { labels: {}, content: raw };
84 const labels: Record<string, string> = {};
85 for (const part of raw.slice("// [alchemy ".length, end).split(/\s+/)) {
86 const eq = part.indexOf("=");
87 if (eq > 0) {
88 labels[part.slice(0, eq)] = part.slice(eq + 1);
89 }
90 }
91 return { labels, content: stripOwnershipLine(raw) };
92};
93
94export const hasOwnershipMarker = (content: string | undefined) =>
95 Object.keys(parseOwnership(content).labels).some((key) =>
96 key.startsWith("alchemy-"),
97 );
98
99export const sourceHasOwnership = (
100 source: firebaserules.Source | RulesetSource | undefined,
101) => (source?.files ?? []).some((file) => hasOwnershipMarker(file.content));
102
103const prefixMatch = (expected: string, observed: string) =>
104 expected === observed ||
105 expected.startsWith(observed) ||
106 observed.startsWith(expected);
107
108export const ownedByAlchemy = (
109 id: string,
110 source: firebaserules.Source | RulesetSource | undefined,
111) =>
112 Effect.gen(function* () {
113 const expected = yield* createInternalLabels(id);
114 const file = (source?.files ?? []).find((item) =>
115 hasOwnershipMarker(item.content),
116 );
117 if (file === undefined) return false;
118 const { labels } = parseOwnership(file.content);
119 const exact = yield* hasAlchemyLabels(id, labels);
120 if (exact) return true;
121 return (
122 prefixMatch(
123 expected[alchemyLabelKeys.stack] ?? "",
124 labels[alchemyLabelKeys.stack] ?? "",
125 ) &&
126 prefixMatch(
127 expected[alchemyLabelKeys.stage] ?? "",
128 labels[alchemyLabelKeys.stage] ?? "",
129 ) &&
130 prefixMatch(
131 expected[alchemyLabelKeys.id] ?? "",
132 labels[alchemyLabelKeys.id] ?? "",
133 )
134 );
135 });
136
137export const toUserFiles = (
138 files: readonly firebaserules.File[] | readonly RulesetFile[] | undefined,
139): RulesetFile[] =>
140 (files ?? []).map((file) => ({
141 name: file.name ?? "firestore.rules",
142 content: stripOwnershipLine(file.content),
143 fingerprint: file.fingerprint,
144 }));
145
146export const stampSource = (
147 source: RulesetSource,
148 labels: Record<string, string>,
149): firebaserules.Source => {
150 const marker = markerOf(labels);
151 const files =
152 source.files.length > 0
153 ? source.files
154 : [{ name: "firestore.rules", content: "" }];
155 return {
156 files: files.map((file, index) => {
157 const content = stripOwnershipLine(file.content);
158 return {
159 name: file.name,
160 fingerprint: file.fingerprint,
161 content: index === 0 ? `${marker}\n${content}` : content,
162 };
163 }),
164 };
165};
166
167export const sourceFingerprint = (
168 files: readonly firebaserules.File[] | readonly RulesetFile[] | undefined,
169) =>
170 JSON.stringify(
171 toUserFiles(files)
172 .map((file) => ({
173 name: file.name,
174 content: file.content,
175 fingerprint: file.fingerprint ?? "",
176 }))
177 .slice()
178 .sort((left, right) => left.name.localeCompare(right.name)),
179 );
180
181export const toReleaseId = (
182 id: string,
183 explicit: string | undefined,
184 existing: string | undefined,
185) =>
186 Effect.gen(function* () {
187 if (explicit !== undefined) return explicit;
188 if (existing !== undefined) return existing;
189 const generated = yield* createPhysicalName({
190 id,
191 maxLength: MAX_RELEASE_ID_LENGTH - GENERATED_RELEASE_PREFIX.length,
192 lowercase: true,
193 });
194 return `${GENERATED_RELEASE_PREFIX}${generated}`.slice(
195 0,
196 MAX_RELEASE_ID_LENGTH,
197 );
198 });
199
200export const isGeneratedReleaseId = (releaseId: string | undefined) =>
201 (releaseId ?? "").startsWith(GENERATED_RELEASE_PREFIX);
202
203export const getRuleset = (name: string) =>
204 name.length === 0
205 ? Effect.succeed(undefined)
206 : firebaserules
207 .getProjectsRulesets({ name })
208 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
209
210export const getRelease = (name: string) =>
211 name.length === 0
212 ? Effect.succeed(undefined)
213 : firebaserules
214 .getProjectsReleases({ name })
215 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
216
217const listRulesetMetas = (project: string) =>
218 firebaserules.listProjectsRulesets
219 .pages({
220 name: projectParent(project),
221 pageSize: 100,
222 })
223 .pipe(
224 Stream.flatMap((page) => Stream.fromIterable(page.rulesets ?? [])),
225 Stream.runCollect,
226 Effect.map((chunk) => Array.from(chunk)),
227 Effect.catchTag("NotFound", () => Effect.succeed([])),
228 );
229
230export const listFullRulesets = (project: string) =>
231 Effect.gen(function* () {
232 const metas = yield* listRulesetMetas(project);
233 const loaded = yield* Effect.forEach(
234 metas,
235 (ruleset) => getRuleset(ruleset.name ?? ""),
236 { concurrency: 8 },
237 );
238 return loaded.filter(
239 (ruleset): ruleset is firebaserules.Ruleset => ruleset !== undefined,
240 );
241 });
242
243export const listOwnedRulesets = (project: string) =>
244 Effect.gen(function* () {
245 const rulesets = yield* listFullRulesets(project);
246 return rulesets.filter((ruleset) => sourceHasOwnership(ruleset.source));
247 });
248
249export const findOwnedRuleset = (
250 rulesets: readonly firebaserules.Ruleset[],
251 id: string,
252 existingName: string | undefined,
253) =>
254 Effect.gen(function* () {
255 if (existingName) {
256 const hit = rulesets.find((ruleset) => ruleset.name === existingName);
257 if (hit) return hit;
258 }
259 for (const ruleset of rulesets) {
260 if (yield* ownedByAlchemy(id, ruleset.source)) {
261 return ruleset;
262 }
263 }
264 return undefined;
265 });
266
267export const listReleases = (project: string) =>
268 firebaserules.listProjectsReleases
269 .pages({
270 name: projectParent(project),
271 pageSize: 100,
272 })
273 .pipe(
274 Stream.flatMap((page) => Stream.fromIterable(page.releases ?? [])),
275 Stream.runCollect,
276 Effect.map((chunk) => Array.from(chunk)),
277 Effect.catchTag("NotFound", () => Effect.succeed([])),
278 );