1import * as firebaseappcheck from "@distilled.cloud/gcp/firebaseappcheck_v1";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { GcpEnvironment } from "../Environment.ts";
8import type { Providers } from "../Providers.ts";
9import {
10 createOwnership,
11 encodeDisplayName,
12 expandApp,
13 findOwnedDebugToken,
14 getDebugToken,
15 listDebugTokensForApp,
16 listOwnedDebugTokens,
17 ownedByAlchemy,
18 parseDebugTokenName,
19 parseDisplayName,
20 replaceOnIdentity,
21 ResourceNotResolved,
22 retryTransient,
23 sameText,
24 sameTextInsensitive,
25 toDisplayName,
26 toToken,
27} from "./internal.ts";
28
29export type AppsDebugTokenProps = {
30 /**
31 * Firebase App id (`1:123:web:abc`) or full resource name
32 * `projects/{project}/apps/{appId}`. Immutable — changing it replaces
33 * the debug token.
34 */
35 app: string;
36 /**
37 * Human-readable display name. Debug tokens have no labels field, so
38 * Alchemy stamps ownership into a `[alchemy …]` prefix and strips it
39 * from attributes.
40 */
41 displayName?: string;
42 /**
43 * Secret token. Must be a UUID4 (case insensitive). If omitted, a UUID4
44 * is generated and stored in Alchemy state. Input-only on the GCP API
45 * — never returned by get/list. Immutable — changing it replaces the
46 * debug token. Each app may have at most 20 debug tokens.
47 */
48 token?: string;
49};
50
51export type AppsDebugToken = Resource<
52 "GCP.FirebaseAppCheck.AppsDebugToken",
53 AppsDebugTokenProps,
54 {
55 /** Full resource name. */
56 name: string;
57 /** Server-assigned debug token id (last path segment). */
58 debugTokenId: string;
59 /** Parent app resource `projects/{project}/apps/{appId}`. */
60 app: string;
61 /** Firebase App id. */
62 appId: string;
63 /** Project id. */
64 project: string;
65 /** User display name with the Alchemy ownership prefix stripped. */
66 displayName: string | undefined;
67 /**
68 * Secret token from create input / Alchemy state. Never populated by
69 * the App Check API after create.
70 */
71 token: string | undefined;
72 /** Server-assigned checksum for optimistic concurrency. */
73 etag: string | undefined;
74 /** RFC3339 last-update timestamp. */
75 updateTime: string | undefined;
76 },
77 never,
78 Providers
79>;
80
81/**
82 * A Firebase App Check debug token. Debug tokens let development and
83 * integration testing bypass app attestation while App Check still
84 * protects production Firebase services.
85 *
86 * Debug tokens have no labels field, so Alchemy stamps ownership into
87 * `displayName` for `list` / nuke. `app` and `token` are identity —
88 * changing either replaces the token. Display name updates in place.
89 * GCP never returns `token` after create; Alchemy stores the secret in
90 * state so {@link ExchangeDebugToken} can redeem it.
91 *
92 * ### Creating a Debug Token
93 * **Example:** Generated UUID
94 * ```typescript
95 * const debug = yield* GCP.FirebaseAppCheck.AppsDebugToken("Local", {
96 * app: "1:123:web:abc",
97 * displayName: "ios simulator",
98 * });
99 * ```
100 *
101 * **Example:** Explicit UUID4
102 * ```typescript
103 * const debug = yield* GCP.FirebaseAppCheck.AppsDebugToken("Local", {
104 * app: "1:123:web:abc",
105 * displayName: "android emulator",
106 * token: "123e4567-e89b-12d3-a456-426614174000",
107 * });
108 * ```
109 *
110 * ### Updating a Debug Token
111 * **Example:** Rename
112 * ```typescript
113 * const debug = yield* GCP.FirebaseAppCheck.AppsDebugToken("Local", {
114 * displayName: "ci runner",
115 * });
116 * ```
117 *
118 * ### Exchanging a Debug Token
119 * **Example:** Mint an App Check token
120 * ```typescript
121 * const exchange = yield* GCP.FirebaseAppCheck.ExchangeDebugToken(debug);
122 * const { token, ttl } = yield* exchange();
123 * ```
124 *
125 * @resource
126 * @category FirebaseAppCheck
127 */
128export const AppsDebugToken = Resource<AppsDebugToken>(
129 "GCP.FirebaseAppCheck.AppsDebugToken",
130);
131
132const getByName = getDebugToken;
133
134const toAttrs = (
135 token: firebaseappcheck.GoogleFirebaseAppcheckV1DebugToken,
136 project: string,
137 secret: string | undefined,
138): AppsDebugToken["Attributes"] => {
139 const name = token.name ?? "";
140 const parsed = parseDebugTokenName(name);
141 const { displayName } = parseDisplayName(token.displayName);
142 return {
143 name,
144 debugTokenId: parsed.debugTokenId,
145 app: parsed.app,
146 appId: parsed.appId,
147 project: parsed.project || project,
148 displayName,
149 token: secret,
150 etag: token.etag,
151 updateTime: token.updateTime,
152 };
153};
154
155export const AppsDebugTokenProvider = () =>
156 Provider.succeed(AppsDebugToken, {
157 stables: ["name", "debugTokenId", "app", "appId", "project", "token"],
158
159 diff: Effect.fn(function* ({ news, olds, output }) {
160 if (!isResolved(news)) return undefined;
161 const env = yield* GcpEnvironment.current;
162 const previousApp = expandApp(
163 env.project,
164 olds?.app ?? output?.app ?? "",
165 );
166 const nextApp = expandApp(env.project, news.app);
167 return replaceOnIdentity({
168 previous: previousApp.length > 0 ? previousApp : undefined,
169 next: nextApp,
170 extra:
171 news.token !== undefined &&
172 (output?.token ?? olds?.token) !== undefined &&
173 !sameTextInsensitive(news.token, output?.token ?? olds?.token),
174 deleteFirst: true,
175 });
176 }),
177
178 read: Effect.fn(function* ({ id, olds, output }) {
179 const env = yield* GcpEnvironment.current;
180 let existing = yield* getByName(output?.name ?? "");
181 if (existing === undefined && olds?.app) {
182 const app = expandApp(env.project, olds.app);
183 existing = yield* findOwnedDebugToken(
184 yield* listDebugTokensForApp(app),
185 id,
186 output?.name,
187 );
188 }
189 if (existing === undefined) return undefined;
190 const attrs = toAttrs(existing, env.project, output?.token);
191 return (yield* ownedByAlchemy(id, existing.displayName))
192 ? attrs
193 : Unowned(attrs);
194 }),
195
196 list: () =>
197 Effect.gen(function* () {
198 const env = yield* GcpEnvironment.current;
199 const tokens = yield* listOwnedDebugTokens(env.project);
200 return tokens.map((token) => toAttrs(token, env.project, undefined));
201 }),
202
203 reconcile: Effect.fn(function* ({ id, news, output }) {
204 const env = yield* GcpEnvironment.current;
205 const app = expandApp(env.project, news.app);
206 const ownership = yield* createOwnership(id);
207 const userDisplay = yield* toDisplayName(
208 id,
209 news.displayName,
210 output?.displayName,
211 );
212 const desiredDisplay = encodeDisplayName(ownership, userDisplay);
213 const secret = yield* toToken(news.token, output?.token);
214
215 let current = yield* getByName(output?.name ?? "");
216 if (current === undefined) {
217 current = yield* findOwnedDebugToken(
218 yield* listDebugTokensForApp(app),
219 id,
220 output?.name,
221 );
222 }
223
224 if (current === undefined) {
225 const created = yield* retryTransient(
226 firebaseappcheck.createProjectsAppsDebugTokens({
227 parent: app,
228 body: {
229 displayName: desiredDisplay,
230 token: secret,
231 },
232 }),
233 ).pipe(
234 Effect.catchTag("Conflict", () =>
235 listDebugTokensForApp(app).pipe(
236 Effect.flatMap((tokens) =>
237 findOwnedDebugToken(tokens, id, output?.name),
238 ),
239 ),
240 ),
241 );
242 current = created ?? undefined;
243 }
244
245 if (current === undefined) {
246 return yield* new ResourceNotResolved({
247 name: output?.name ?? `${app}/debugTokens`,
248 });
249 }
250
251 if (!sameText(current.displayName, desiredDisplay)) {
252 current = yield* retryTransient(
253 firebaseappcheck.patchProjectsAppsDebugTokens({
254 name: current.name ?? "",
255 updateMask: "display_name",
256 body: {
257 displayName: desiredDisplay,
258 etag: current.etag,
259 },
260 }),
261 );
262 }
263
264 return toAttrs(current, env.project, secret);
265 }),
266
267 delete: Effect.fn(function* ({ output }) {
268 if (!output.name) return;
269 yield* retryTransient(
270 firebaseappcheck.deleteProjectsAppsDebugTokens({
271 name: output.name,
272 etag: output.etag,
273 }),
274 ).pipe(
275 Effect.catchTag("Conflict", () =>
276 firebaseappcheck.deleteProjectsAppsDebugTokens({
277 name: output.name,
278 }),
279 ),
280 Effect.catchTag("NotFound", () => Effect.void),
281 );
282 }),
283 });