1import * as firebaserules from "@distilled.cloud/gcp/firebaserules_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import { createInternalLabels } from "../Labels.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 findOwnedRuleset,
14 getRuleset,
15 listFullRulesets,
16 listOwnedRulesets,
17 ownedByAlchemy,
18 projectParent,
19 rulesetIdOf,
20 sameText,
21 sourceFingerprint,
22 stampSource,
23 toUserFiles,
24 type RulesetFile,
25 type RulesetSource,
26} from "./internal.ts";
27
28export type { RulesetFile, RulesetSource };
29
30export type RulesetProps = {
31 /**
32 * Source files for this ruleset. Rulesets are immutable — changing
33 * `source` replaces the ruleset. Firebase Rules has no labels field,
34 * so Alchemy stamps ownership into a `// [alchemy …]` comment on the
35 * first file and strips it from attributes.
36 */
37 source: RulesetSource;
38 /**
39 * Intended resource this ruleset should be released to, e.g.
40 * `firestore.googleapis.com/projects/{project}/databases/(default)`.
41 * May be left blank for the default release. Immutable — changing it
42 * replaces the ruleset.
43 */
44 attachmentPoint?: string;
45};
46
47export type Ruleset = Resource<
48 "GCP.FirebaseRules.Ruleset",
49 RulesetProps,
50 {
51 /** Full resource name `projects/{project}/rulesets/{rulesetId}`. */
52 name: string;
53 /** Server-assigned ruleset id (last path segment). */
54 rulesetId: string;
55 /** Project id. */
56 project: string;
57 /** User source with the Alchemy ownership comment stripped. */
58 source: RulesetSource;
59 /** Services declared by this ruleset (`cloud.firestore`, …). */
60 services: string[];
61 /** Intended release attachment point, if set. */
62 attachmentPoint: string | undefined;
63 /** RFC3339 creation timestamp. */
64 createTime: string | undefined;
65 },
66 never,
67 Providers
68>;
69
70/**
71 * An immutable Firebase Security Rules ruleset.
72 *
73 * Rulesets have no labels field, so Alchemy stamps ownership into a
74 * `// [alchemy …]` comment on the first source file for `list` / nuke.
75 * Source and `attachmentPoint` are identity — changing either replaces
76 * the ruleset. The ruleset id is assigned by the service.
77 *
78 * ### Creating a Ruleset
79 * **Example:** Firestore rules
80 * ```typescript
81 * const ruleset = yield* GCP.FirebaseRules.Ruleset("Firestore", {
82 * source: {
83 * files: [{
84 * name: "firestore.rules",
85 * content: "rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /{document=**} { allow read, write: if false; } } }",
86 * }],
87 * },
88 * });
89 * ```
90 *
91 * **Example:** Storage rules with an attachment point
92 * ```typescript
93 * const ruleset = yield* GCP.FirebaseRules.Ruleset("Storage", {
94 * attachmentPoint:
95 * "firebase.storage.googleapis.com/projects/my-project/buckets/my-bucket",
96 * source: {
97 * files: [{
98 * name: "storage.rules",
99 * content: "rules_version = '2'; service firebase.storage { match /b/{bucket}/o { match /{allPaths=**} { allow read, write: if false; } } }",
100 * }],
101 * },
102 * });
103 * ```
104 *
105 * ### Replacing a Ruleset
106 * **Example:** New source (new ruleset id)
107 * ```typescript
108 * const ruleset = yield* GCP.FirebaseRules.Ruleset("Firestore", {
109 * source: {
110 * files: [{
111 * name: "firestore.rules",
112 * content: "rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /{document=**} { allow read, write: if request.auth != null; } } }",
113 * }],
114 * },
115 * });
116 * ```
117 *
118 * @resource
119 * @category FirebaseRules
120 */
121export const Ruleset = Resource<Ruleset>("GCP.FirebaseRules.Ruleset");
122
123export class RulesetNotResolved extends Data.TaggedError(
124 "GCP.FirebaseRules.RulesetNotResolved",
125)<{
126 name: string;
127}> {}
128
129const toAttrs = (ruleset: firebaserules.Ruleset, project: string) => {
130 const name = ruleset.name ?? "";
131 return {
132 name,
133 rulesetId: rulesetIdOf(name),
134 project,
135 source: { files: toUserFiles(ruleset.source?.files) },
136 services: [...(ruleset.metadata?.services ?? [])],
137 attachmentPoint: ruleset.attachmentPoint,
138 createTime: ruleset.createTime,
139 };
140};
141
142export const RulesetProvider = () =>
143 Provider.succeed(Ruleset, {
144 stables: ["name", "rulesetId", "project", "createTime"],
145
146 diff: Effect.fn(function* ({ news, output }) {
147 if (!isResolved(news)) return undefined;
148 if (output === undefined) return undefined;
149 const sourceChanged =
150 sourceFingerprint(news.source.files) !==
151 sourceFingerprint(output.source.files);
152 const attachmentChanged =
153 news.attachmentPoint !== undefined &&
154 (news.attachmentPoint ?? "") !== (output.attachmentPoint ?? "");
155 if (!sourceChanged && !attachmentChanged) return undefined;
156 return { action: "replace" as const, deleteFirst: false };
157 }),
158
159 read: Effect.fn(function* ({ id, output }) {
160 const env = yield* GcpEnvironment.current;
161 let existing = yield* getRuleset(output?.name ?? "");
162 if (existing === undefined) {
163 existing = yield* findOwnedRuleset(
164 yield* listFullRulesets(env.project),
165 id,
166 output?.name,
167 );
168 }
169 if (existing === undefined) return undefined;
170 const attrs = toAttrs(existing, env.project);
171 return (yield* ownedByAlchemy(id, existing.source))
172 ? attrs
173 : Unowned(attrs);
174 }),
175
176 list: () =>
177 Effect.gen(function* () {
178 const env = yield* GcpEnvironment.current;
179 const rulesets = yield* listOwnedRulesets(env.project);
180 return rulesets.map((ruleset) => toAttrs(ruleset, env.project));
181 }),
182
183 reconcile: Effect.fn(function* ({ id, news, output }) {
184 const env = yield* GcpEnvironment.current;
185 const parent = projectParent(env.project);
186 const ownership = yield* createInternalLabels(id);
187 const desiredSource = stampSource(news.source, ownership);
188
189 let current = yield* getRuleset(output?.name ?? "");
190 if (current === undefined) {
191 const found = yield* findOwnedRuleset(
192 yield* listFullRulesets(env.project),
193 id,
194 output?.name,
195 );
196 // Replacement uses the same logical id as the old generation. Only
197 // reuse a scanned ruleset when source matches (crash-after-create);
198 // a different source must become a new ruleset.
199 if (
200 found !== undefined &&
201 (output?.name === found.name ||
202 (sourceFingerprint(found.source?.files) ===
203 sourceFingerprint(news.source.files) &&
204 sameText(found.attachmentPoint, news.attachmentPoint)))
205 ) {
206 current = found;
207 }
208 }
209
210 if (current === undefined) {
211 const created = yield* firebaserules
212 .createProjectsRulesets({
213 name: parent,
214 body: {
215 source: desiredSource,
216 attachmentPoint: news.attachmentPoint,
217 },
218 })
219 .pipe(
220 Effect.catchTag("Conflict", () =>
221 listFullRulesets(env.project).pipe(
222 Effect.flatMap((rulesets) =>
223 findOwnedRuleset(rulesets, id, undefined),
224 ),
225 ),
226 ),
227 );
228 current = created ?? undefined;
229 }
230
231 if (current === undefined) {
232 return yield* new RulesetNotResolved({
233 name: output?.name ?? `${parent}/rulesets`,
234 });
235 }
236
237 return toAttrs(current, env.project);
238 }),
239
240 delete: Effect.fn(function* ({ output }) {
241 if (!output.name) return;
242 yield* firebaserules.deleteProjectsRulesets({ name: output.name }).pipe(
243 Effect.retry({
244 while: (error) => error._tag === "Conflict",
245 times: 8,
246 schedule: Schedule.spaced("2 seconds"),
247 }),
248 Effect.catchTag("NotFound", () => Effect.void),
249 );
250 }),
251 });