1import * as apikeys from "@distilled.cloud/gcp/apikeys_v2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { tagRecord } from "../../Tags.ts";
12import { GcpEnvironment } from "../Environment.ts";
13import {
14 createInternalLabels,
15 diffLabels,
16 hasAlchemyLabels,
17 stripInternalLabels,
18 toLabels,
19} from "../Labels.ts";
20import type { Providers } from "../Providers.ts";
21import {
22 waitForOperation as waitForLongRunning,
23 type LongRunningOperation,
24} from "../Operation.ts";
25
26const LOCATION = "global";
27const MAX_NAME_LENGTH = 63;
28
29export type ApiTarget = {
30 /**
31 * Canonical service name, e.g. `translate.googleapis.com`.
32 */
33 service?: string;
34 /**
35 * Allowed methods. Empty means all methods for `service`. A trailing
36 * `*` wildcard is allowed (`Get*`).
37 */
38 methods?: string[];
39};
40
41export type BrowserKeyRestrictions = {
42 /**
43 * Referrer URL regular expressions allowed to call with this key.
44 */
45 allowedReferrers?: string[];
46};
47
48export type AndroidApplication = {
49 /**
50 * SHA-1 fingerprint of the app signing cert. Colon-separated and
51 * compact hex are both accepted; the API stores the compact form.
52 */
53 sha1Fingerprint?: string;
54 /**
55 * Android application package name.
56 */
57 packageName?: string;
58};
59
60export type AndroidKeyRestrictions = {
61 /**
62 * Android apps allowed to call with this key.
63 */
64 allowedApplications?: AndroidApplication[];
65};
66
67export type ServerKeyRestrictions = {
68 /**
69 * Caller IP addresses (or CIDRs) allowed to call with this key.
70 */
71 allowedIps?: string[];
72};
73
74export type IosKeyRestrictions = {
75 /**
76 * iOS bundle IDs allowed to call with this key.
77 */
78 allowedBundleIds?: string[];
79};
80
81export type KeyRestrictions = {
82 /**
83 * Allowed API targets. Empty / omitted allows every enabled service.
84 */
85 apiTargets?: ApiTarget[];
86 /**
87 * HTTP referrer restrictions. Mutually exclusive with android, iOS,
88 * and server restrictions.
89 */
90 browserKeyRestrictions?: BrowserKeyRestrictions;
91 /**
92 * Android app restrictions. Mutually exclusive with browser, iOS, and
93 * server restrictions.
94 */
95 androidKeyRestrictions?: AndroidKeyRestrictions;
96 /**
97 * Caller IP restrictions. Mutually exclusive with browser, Android,
98 * and iOS restrictions.
99 */
100 serverKeyRestrictions?: ServerKeyRestrictions;
101 /**
102 * iOS bundle-id restrictions. Mutually exclusive with browser,
103 * Android, and server restrictions.
104 */
105 iosKeyRestrictions?: IosKeyRestrictions;
106};
107
108export type KeyProps = {
109 /**
110 * Key id (the `{key}` segment of
111 * `projects/{project}/locations/global/keys/{key}`). If omitted, a
112 * unique name is generated from the stack, stage, and logical id.
113 * Must match `[a-z]([a-z0-9-]{0,61}[a-z0-9])?`, must not be UUID-like,
114 * and is immutable — changing it replaces the key.
115 */
116 keyId?: string;
117 /**
118 * Human-readable display name. Max 63 characters.
119 */
120 displayName?: string;
121 /**
122 * User annotations. API keys have no labels field — Alchemy ownership
123 * (`alchemy-stack` / `alchemy-stage` / `alchemy-id`) is stored here
124 * so `list` / `pnpm nuke:gcp` can find owned keys. User keys are
125 * preserved; Alchemy keys win on conflict.
126 */
127 annotations?: Record<string, string>;
128 /**
129 * Key restrictions (API targets and client-type limits).
130 */
131 restrictions?: KeyRestrictions;
132 /**
133 * Service account the key is bound to. Immutable — changing it
134 * replaces the key. Binding enables service-account auth on the key.
135 */
136 serviceAccountEmail?: string;
137};
138
139export type Key = Resource<
140 "GCP.ApiKeys.Key",
141 KeyProps,
142 {
143 /** Full resource name `projects/{project}/locations/global/keys/{key}`. */
144 name: string;
145 /** Key id (last path segment). */
146 keyId: string;
147 /** Project id. */
148 project: string;
149 /** Always `global` — API keys are a global resource. */
150 location: string;
151 /** Human-readable display name. */
152 displayName: string | undefined;
153 /** User annotations (Alchemy ownership keys stripped). */
154 annotations: Record<string, string>;
155 /** Key restrictions currently configured. */
156 restrictions: KeyRestrictions | undefined;
157 /** Bound service account email, if any. */
158 serviceAccountEmail: string | undefined;
159 /**
160 * Encrypted key string. Empty on `list` (nuke does not need it).
161 * Fetch at runtime with {@link GetKeyString}.
162 */
163 keyString: string | undefined;
164 /** Server-assigned UUID4, stable until delete. */
165 uid: string | undefined;
166 /** Server checksum of the resource. */
167 etag: string | undefined;
168 /** RFC3339 creation timestamp. */
169 createTime: string | undefined;
170 /** RFC3339 last-update timestamp. */
171 updateTime: string | undefined;
172 },
173 never,
174 Providers
175>;
176
177/**
178 * A Google Cloud API key (the API Keys API, `apikeys.googleapis.com`).
179 *
180 * Keys are global (`locations/global`). They have no labels — Alchemy
181 * stamps ownership into `annotations` so `read`, `list`, and
182 * `pnpm nuke:gcp` can find them. `keyId` and `serviceAccountEmail` are
183 * immutable; changing either replaces the key. Deletes are soft: the key
184 * can be undeleted for 30 days, then it is purged.
185 *
186 * ### Creating a Key
187 * **Example:** Generated name
188 * ```typescript
189 * const maps = yield* GCP.ApiKeys.Key("Maps", {
190 * displayName: "maps browser key",
191 * });
192 * ```
193 *
194 * **Example:** Named key with annotations and API-target restrictions
195 * ```typescript
196 * const maps = yield* GCP.ApiKeys.Key("Maps", {
197 * keyId: "maps-browser",
198 * displayName: "maps browser key",
199 * annotations: { env: "prod" },
200 * restrictions: {
201 * apiTargets: [{ service: "geocoding-backend.googleapis.com" }],
202 * browserKeyRestrictions: {
203 * allowedReferrers: ["https://example.com/*"],
204 * },
205 * },
206 * });
207 * ```
208 *
209 * ### Reading the Key String
210 * **Example:** Get the encrypted key string at runtime
211 * ```typescript
212 * const getKeyString = yield* GCP.ApiKeys.GetKeyString(maps);
213 * const { keyString } = yield* getKeyString();
214 * ```
215 *
216 * @resource
217 * @category ApiKeys
218 */
219export const Key = Resource<Key>("GCP.ApiKeys.Key");
220
221export class KeyNotResolved extends Data.TaggedError(
222 "GCP.ApiKeys.KeyNotResolved",
223)<{
224 name: string;
225}> {}
226
227export class KeyStillExists extends Data.TaggedError(
228 "GCP.ApiKeys.KeyStillExists",
229)<{
230 name: string;
231}> {}
232
233const lastSegment = (value: string) => {
234 const trimmed = value.replace(/\/+$/, "");
235 const parts = trimmed.split("/");
236 return parts[parts.length - 1] || trimmed;
237};
238
239const rfc1035 = (name: string): string => {
240 let next = name
241 .toLowerCase()
242 .replace(/[^a-z0-9-]/g, "-")
243 .replace(/-+/g, "-")
244 .replace(/^-+/, "")
245 .replace(/-+$/, "");
246 if (!/^[a-z]/.test(next)) {
247 next = `k${next}`;
248 }
249 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/, "");
250 return next.length > 0 ? next : "apikey";
251};
252
253const resourceName = (project: string, keyId: string) =>
254 `projects/${project}/locations/${LOCATION}/keys/${keyId}`;
255
256const parentOf = (project: string) =>
257 `projects/${project}/locations/${LOCATION}`;
258
259const parseName = (name: string) => {
260 const parts = name.split("/").filter((part) => part.length > 0);
261 const keysAt = parts.lastIndexOf("keys");
262 const projectsAt = parts.lastIndexOf("projects");
263 return {
264 project:
265 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
266 keyId:
267 keysAt >= 0 && parts[keysAt + 1] ? parts[keysAt + 1]! : lastSegment(name),
268 };
269};
270
271const userAnnotations = (
272 annotations: Record<string, string | undefined> | null | undefined,
273): Record<string, string> => stripInternalLabels(tagRecord(annotations));
274
275const toId = (id: string, keyId: string | undefined, existing?: string) =>
276 Effect.gen(function* () {
277 if (keyId !== undefined) return keyId;
278 if (existing !== undefined) return existing;
279 return rfc1035(
280 yield* createPhysicalName({
281 id,
282 maxLength: MAX_NAME_LENGTH,
283 lowercase: true,
284 }),
285 );
286 });
287
288const isDeleted = (key: apikeys.V2Key | undefined) =>
289 (key?.deleteTime ?? "") !== "";
290
291const toRestrictions = (
292 restrictions: apikeys.V2Restrictions | undefined,
293): KeyRestrictions | undefined => {
294 if (restrictions === undefined) return undefined;
295 if (
296 (restrictions.apiTargets?.length ?? 0) === 0 &&
297 restrictions.browserKeyRestrictions === undefined &&
298 restrictions.androidKeyRestrictions === undefined &&
299 restrictions.serverKeyRestrictions === undefined &&
300 restrictions.iosKeyRestrictions === undefined
301 ) {
302 return undefined;
303 }
304 return {
305 apiTargets: restrictions.apiTargets,
306 browserKeyRestrictions: restrictions.browserKeyRestrictions,
307 androidKeyRestrictions: restrictions.androidKeyRestrictions,
308 serverKeyRestrictions: restrictions.serverKeyRestrictions,
309 iosKeyRestrictions: restrictions.iosKeyRestrictions,
310 };
311};
312
313const sorted = (values: readonly string[] | undefined) =>
314 [...(values ?? [])].slice().sort((a, b) => a.localeCompare(b));
315
316const fingerprintSha1 = (value: string | undefined) =>
317 (value ?? "").replace(/:/g, "").toUpperCase();
318
319const restrictionsFingerprint = (
320 restrictions: KeyRestrictions | undefined,
321): string => {
322 if (restrictions === undefined) return "";
323 const apiTargets = [...(restrictions.apiTargets ?? [])]
324 .map((target) => ({
325 service: target.service ?? "",
326 methods: sorted(target.methods),
327 }))
328 .sort((a, b) => a.service.localeCompare(b.service));
329 const android = [
330 ...(restrictions.androidKeyRestrictions?.allowedApplications ?? []),
331 ]
332 .map((app) => ({
333 packageName: app.packageName ?? "",
334 sha1Fingerprint: fingerprintSha1(app.sha1Fingerprint),
335 }))
336 .sort((a, b) => a.packageName.localeCompare(b.packageName));
337 return JSON.stringify({
338 apiTargets,
339 browser: restrictions.browserKeyRestrictions
340 ? {
341 allowedReferrers: sorted(
342 restrictions.browserKeyRestrictions.allowedReferrers,
343 ),
344 }
345 : undefined,
346 android: restrictions.androidKeyRestrictions
347 ? { allowedApplications: android }
348 : undefined,
349 server: restrictions.serverKeyRestrictions
350 ? {
351 allowedIps: sorted(restrictions.serverKeyRestrictions.allowedIps),
352 }
353 : undefined,
354 ios: restrictions.iosKeyRestrictions
355 ? {
356 allowedBundleIds: sorted(
357 restrictions.iosKeyRestrictions.allowedBundleIds,
358 ),
359 }
360 : undefined,
361 });
362};
363
364const toAttrs = (
365 key: apikeys.V2Key,
366 project: string,
367 keyString?: string,
368): Key["Attributes"] => {
369 const name = key.name ?? "";
370 const parsed = parseName(name);
371 return {
372 name,
373 keyId: parsed.keyId,
374 project: parsed.project || project,
375 location: LOCATION,
376 displayName: key.displayName,
377 annotations: userAnnotations(key.annotations),
378 restrictions: toRestrictions(key.restrictions),
379 serviceAccountEmail: key.serviceAccountEmail,
380 keyString,
381 uid: key.uid,
382 etag: key.etag,
383 createTime: key.createTime,
384 updateTime: key.updateTime,
385 };
386};
387
388const getByName = (name: string) =>
389 apikeys
390 .getProjectsLocationsKeys({ name })
391 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
392
393const getKeyStringValue = (name: string) =>
394 apikeys.getKeyStringProjectsLocationsKeys({ name }).pipe(
395 Effect.map((response) => response.keyString),
396 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
397 );
398
399const toAttrsLive = (key: apikeys.V2Key, project: string) =>
400 Effect.gen(function* () {
401 const name = key.name ?? "";
402 const keyString =
403 name.length > 0 ? yield* getKeyStringValue(name) : undefined;
404 return toAttrs(key, project, keyString);
405 });
406
407const waitUntilActive = (name: string) =>
408 getByName(name).pipe(
409 Effect.flatMap((key) =>
410 key && !isDeleted(key)
411 ? Effect.succeed(key)
412 : Effect.fail(new KeyNotResolved({ name })),
413 ),
414 Effect.retry({
415 while: (error) => error._tag === "GCP.ApiKeys.KeyNotResolved",
416 times: 8,
417 schedule: Schedule.spaced("1 second"),
418 }),
419 );
420
421const waitUntilGone = (name: string) =>
422 getByName(name).pipe(
423 Effect.flatMap((key) =>
424 key === undefined || isDeleted(key)
425 ? Effect.void
426 : Effect.fail(new KeyStillExists({ name })),
427 ),
428 Effect.retry({
429 while: (error) => error._tag === "GCP.ApiKeys.KeyStillExists",
430 times: 8,
431 schedule: Schedule.spaced("1 second"),
432 }),
433 );
434
435const reviveDeleted = (name: string) =>
436 Effect.gen(function* () {
437 const operation = yield* apikeys
438 .undeleteProjectsLocationsKeys({ name, body: {} })
439 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
440 if (operation !== undefined) {
441 yield* waitForOperation(operation);
442 }
443 return yield* getByName(name);
444 });
445
446const listOwnedKeys = (project: string) =>
447 apikeys.listProjectsLocationsKeys
448 .pages({
449 parent: parentOf(project),
450 pageSize: 1000,
451 })
452 .pipe(
453 Stream.flatMap((page) => Stream.fromIterable(page.keys ?? [])),
454 Stream.filter(
455 (key) =>
456 !isDeleted(key) &&
457 Object.keys(key.annotations ?? {}).some((item) =>
458 item.startsWith("alchemy-"),
459 ),
460 ),
461 Stream.map((key) => toAttrs(key, project)),
462 Stream.runCollect,
463 Effect.map((chunk) => Array.from(chunk)),
464 Effect.catchTag("NotFound", () => Effect.succeed([])),
465 );
466
467const toCreateBody = (
468 news: KeyProps,
469 annotations: Record<string, string>,
470): apikeys.V2Key => ({
471 displayName: news.displayName,
472 annotations,
473 restrictions: news.restrictions,
474 serviceAccountEmail: news.serviceAccountEmail,
475});
476
477export const KeyProvider = () =>
478 Provider.succeed(Key, {
479 stables: ["name", "keyId", "project", "location", "uid", "createTime"],
480
481 diff: Effect.fn(function* ({ news, olds, output }) {
482 if (!isResolved(news)) return undefined;
483 const previousId = olds?.keyId ?? output?.keyId;
484 const nextId = news.keyId ?? previousId;
485 const idChanged =
486 previousId !== undefined &&
487 nextId !== undefined &&
488 nextId !== previousId;
489 const previousSa =
490 olds?.serviceAccountEmail ?? output?.serviceAccountEmail ?? "";
491 const nextSa = news.serviceAccountEmail ?? previousSa;
492 const saChanged = previousSa !== nextSa;
493
494 if (!idChanged && !saChanged) return undefined;
495 return {
496 action: "replace" as const,
497 deleteFirst:
498 !idChanged && previousId !== undefined && nextId === previousId,
499 };
500 }),
501
502 read: Effect.fn(function* ({ id, olds, output }) {
503 const env = yield* GcpEnvironment.current;
504 const keyId = yield* toId(id, olds?.keyId, output?.keyId);
505 const name = output?.name ?? resourceName(env.project, keyId);
506 const existing = yield* getByName(name);
507 if (existing === undefined || isDeleted(existing)) return undefined;
508 const attrs = yield* toAttrsLive(existing, env.project);
509 return (yield* hasAlchemyLabels(id, tagRecord(existing.annotations)))
510 ? attrs
511 : Unowned(attrs);
512 }),
513
514 list: () =>
515 Effect.gen(function* () {
516 const env = yield* GcpEnvironment.current;
517 return yield* listOwnedKeys(env.project);
518 }),
519
520 reconcile: Effect.fn(function* ({ id, news, output }) {
521 const env = yield* GcpEnvironment.current;
522 const keyId = yield* toId(id, news.keyId, output?.keyId);
523 const name = resourceName(env.project, keyId);
524 const parent = parentOf(env.project);
525 const desiredAnnotations = {
526 ...toLabels(news.annotations),
527 ...(yield* createInternalLabels(id)),
528 };
529
530 let current = yield* getByName(name);
531
532 if (current !== undefined && isDeleted(current)) {
533 current = yield* reviveDeleted(name);
534 }
535
536 if (current === undefined || isDeleted(current)) {
537 const created = yield* apikeys
538 .createProjectsLocationsKeys({
539 parent,
540 keyId,
541 body: toCreateBody(news, desiredAnnotations),
542 })
543 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
544 if (created !== undefined) {
545 yield* waitForOperation(created);
546 }
547 current = yield* getByName(name);
548 if (current !== undefined && isDeleted(current)) {
549 current = yield* reviveDeleted(name);
550 }
551 if (current === undefined || isDeleted(current)) {
552 current = yield* waitUntilActive(name);
553 }
554 }
555
556 if (current === undefined || isDeleted(current)) {
557 return yield* new KeyNotResolved({ name });
558 }
559
560 const observedAnnotations = tagRecord(current.annotations);
561 const { upsert, removed } = diffLabels(
562 observedAnnotations,
563 desiredAnnotations,
564 );
565 const annotationsChanged = upsert.length > 0 || removed.length > 0;
566 const displayNameChanged =
567 (current.displayName ?? "") !== (news.displayName ?? "");
568 const restrictionsChanged =
569 restrictionsFingerprint(toRestrictions(current.restrictions)) !==
570 restrictionsFingerprint(news.restrictions);
571
572 if (annotationsChanged || displayNameChanged || restrictionsChanged) {
573 const updateMask = [
574 annotationsChanged ? "annotations" : undefined,
575 displayNameChanged ? "display_name" : undefined,
576 restrictionsChanged ? "restrictions" : undefined,
577 ].filter((field): field is string => field !== undefined);
578
579 const operation = yield* apikeys.patchProjectsLocationsKeys({
580 name,
581 updateMask: updateMask.join(","),
582 body: {
583 name,
584 etag: current.etag,
585 annotations: desiredAnnotations,
586 displayName: news.displayName,
587 restrictions: news.restrictions ?? {},
588 },
589 });
590 yield* waitForOperation(operation);
591 current = yield* waitUntilActive(name);
592 }
593
594 return yield* toAttrsLive(current, env.project);
595 }),
596
597 delete: Effect.fn(function* ({ output }) {
598 const operation = yield* apikeys
599 .deleteProjectsLocationsKeys({
600 name: output.name,
601 etag: output.etag,
602 })
603 .pipe(
604 Effect.retry({
605 while: (error) => error._tag === "Conflict",
606 times: 8,
607 schedule: Schedule.spaced("1 second"),
608 }),
609 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
610 );
611 if (operation !== undefined) {
612 yield* waitForDeleteOperation(operation);
613 }
614 yield* waitUntilGone(output.name);
615 }),
616 });
617
618/** API key operations finish within seconds. */
619const OPERATION_BUDGET = "5 minutes";
620
621/**
622 * Wait for a long-running operation. ALREADY_EXISTS (code 6) means a
623 * concurrent create won the race; reconcile observes the resource next.
624 */
625const waitForOperation = (operation: LongRunningOperation) =>
626 waitForLongRunning(operation, (name) => apikeys.getOperations({ name }), {
627 budget: OPERATION_BUDGET,
628 }).pipe(
629 Effect.catchIf(
630 (error) => error._tag === "GCP.OperationFailed" && error.code === 6,
631 () => Effect.succeed(operation),
632 ),
633 );
634
635/**
636 * Wait for a delete operation. A vanished operation or NOT_FOUND (code 5)
637 * means the resource is already gone.
638 */
639const waitForDeleteOperation = (operation: LongRunningOperation) =>
640 waitForOperation(operation).pipe(
641 Effect.catchIf(
642 (error) =>
643 error._tag === "NotFound" ||
644 (error._tag === "GCP.OperationFailed" && error.code === 5),
645 () => Effect.succeed(operation),
646 ),
647 );