1import * as recaptchaenterprise from "@distilled.cloud/gcp/recaptchaenterprise_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 stripInternalLabels,
15 toLabels,
16} from "../Labels.ts";
17import type { Providers } from "../Providers.ts";
18import {
19 findOwnedKey,
20 keyNameOf,
21 lastSegment,
22 listKeys,
23 sameText,
24 stringList,
25 toDisplayName,
26 updateMaskOf,
27} from "./internal.ts";
28
29export type TestingOptions = {
30 /**
31 * Score returned for every assessment of this key. Must be between 0
32 * and 1 inclusive.
33 */
34 testingScore?: number;
35 /**
36 * Challenge outcome for CHECKBOX / INVISIBLE keys: `NOCAPTCHA` or
37 * `UNSOLVABLE_CHALLENGE`.
38 */
39 testingChallenge?: string;
40};
41
42export type AppleDeveloperId = {
43 /**
44 * Input-only Apple DeviceCheck private key (`.p8` contents).
45 */
46 privateKey?: string;
47 /**
48 * Apple team id (10 characters).
49 */
50 teamId?: string;
51 /**
52 * Apple developer key id (10 characters).
53 */
54 keyId?: string;
55};
56
57export type IOSKeySettings = {
58 /**
59 * iOS bundle ids allowed to use the key.
60 */
61 allowedBundleIds?: string[];
62 /**
63 * When true, `allowedBundleIds` are not enforced.
64 */
65 allowAllBundleIds?: boolean;
66 /**
67 * Apple Developer account used for App Attest / DeviceCheck.
68 */
69 appleDeveloperId?: AppleDeveloperId;
70};
71
72export type AndroidKeySettings = {
73 /**
74 * Android package names allowed to use the key.
75 */
76 allowedPackageNames?: string[];
77 /**
78 * When true, the key may be used by apps distributed outside Play.
79 */
80 supportNonGoogleAppStoreDistribution?: boolean;
81 /**
82 * When true, `allowedPackageNames` are not enforced.
83 */
84 allowAllPackageNames?: boolean;
85};
86
87export type WebActionSettings = {
88 /**
89 * Challenge is triggered when the score is below this threshold (0–1).
90 */
91 scoreThreshold?: number;
92};
93
94export type WebChallengeSettings = {
95 /**
96 * Default action threshold.
97 */
98 defaultSettings?: WebActionSettings;
99 /**
100 * Per-action score thresholds. Action names match `data-action`.
101 */
102 actionSettings?: Record<string, WebActionSettings | undefined>;
103};
104
105export type WebKeySettings = {
106 /**
107 * Hostnames allowed to use the key (no path, port, or fragment).
108 */
109 allowedDomains?: string[];
110 /**
111 * How the key is integrated with the site. Immutable — changing it
112 * replaces the key.
113 * @default "SCORE"
114 */
115 integrationType?: string;
116 /**
117 * Challenge frequency / difficulty for CHECKBOX, INVISIBLE, and
118 * POLICY_BASED_CHALLENGE keys.
119 */
120 challengeSecurityPreference?: string;
121 /**
122 * When true, `allowedDomains` are not enforced.
123 * @default true
124 */
125 allowAllDomains?: boolean;
126 /**
127 * Allow AMP pages. SCORE keys only. Immutable — changing it replaces
128 * the key.
129 */
130 allowAmpTraffic?: boolean;
131 /**
132 * POLICY_BASED_CHALLENGE thresholds.
133 */
134 challengeSettings?: WebChallengeSettings;
135};
136
137export type WafSettings = {
138 /**
139 * WAF service that uses this key (`CA`, `FASTLY`, `CLOUDFLARE`,
140 * `AKAMAI`). Immutable — changing it replaces the key.
141 */
142 wafService?: string;
143 /**
144 * WAF feature (`CHALLENGE_PAGE`, `SESSION_TOKEN`, `ACTION_TOKEN`,
145 * `EXPRESS`). Immutable — changing it replaces the key.
146 */
147 wafFeature?: string;
148};
149
150export type KeyProps = {
151 /**
152 * Server-assigned key id (the `{key}` segment of
153 * `projects/{project}/keys/{key}`). This is also the site key used by
154 * client libraries. Immutable — changing it replaces the key.
155 */
156 keyId?: string;
157 /**
158 * Human-readable display name. If omitted, a unique name is generated
159 * from the stack, stage, and logical id.
160 */
161 displayName?: string;
162 /**
163 * User labels. Alchemy ownership labels are merged in automatically.
164 */
165 labels?: Record<string, string>;
166 /**
167 * User-acceptance testing overrides (fixed score / challenge).
168 */
169 testingOptions?: TestingOptions;
170 /**
171 * Website key settings. Default when no Android, iOS, or Express
172 * settings are set: SCORE integration with `allowAllDomains`.
173 */
174 webSettings?: WebKeySettings;
175 /**
176 * Android app key settings. Mutually exclusive with web, iOS, and
177 * Express. Changing the platform kind replaces the key.
178 */
179 androidSettings?: AndroidKeySettings;
180 /**
181 * iOS app key settings. Mutually exclusive with web, Android, and
182 * Express. Changing the platform kind replaces the key.
183 */
184 iosSettings?: IOSKeySettings;
185 /**
186 * reCAPTCHA Express settings. Mutually exclusive with web, Android,
187 * and iOS. Changing the platform kind replaces the key.
188 */
189 expressSettings?: Record<string, never> | {};
190 /**
191 * Web Application Firewall settings. Immutable — changing them
192 * replaces the key.
193 */
194 wafSettings?: WafSettings;
195};
196
197export type Key = Resource<
198 "GCP.RecaptchaEnterprise.Key",
199 KeyProps,
200 {
201 /** Full resource name `projects/{project}/keys/{key}`. */
202 name: string;
203 /** Server-assigned key id (site key). */
204 keyId: string;
205 /** Project id used when the key was reconciled. */
206 project: string;
207 /** Human-readable display name. */
208 displayName: string | undefined;
209 /** User labels (Alchemy ownership labels stripped). */
210 labels: Record<string, string>;
211 /** Testing overrides, if set. */
212 testingOptions: TestingOptions | undefined;
213 /** Website settings, if this is a web key. */
214 webSettings: WebKeySettings | undefined;
215 /** Android settings, if this is an Android key. */
216 androidSettings: AndroidKeySettings | undefined;
217 /** iOS settings, if this is an iOS key. */
218 iosSettings: IOSKeySettings | undefined;
219 /** Whether Express settings are configured. */
220 expressSettings: Record<string, never> | {} | undefined;
221 /** WAF settings, if set. */
222 wafSettings: WafSettings | undefined;
223 /** RFC3339 creation timestamp. */
224 createTime: string | undefined;
225 },
226 never,
227 Providers
228>;
229
230/**
231 * A reCAPTCHA Enterprise key.
232 *
233 * Key ids are assigned by Google and are the site key used by client
234 * libraries. Alchemy stamps ownership into labels so `list` / nuke can
235 * find them. Display name, labels, and allowed domains update in place.
236 * Changing `keyId`, platform kind (web / Android / iOS / Express),
237 * `webSettings.integrationType`, AMP traffic, WAF settings, or testing
238 * options replaces the key.
239 *
240 * ### Creating a Key
241 * **Example:** Generated SCORE web key
242 * ```typescript
243 * const key = yield* GCP.RecaptchaEnterprise.Key("Signup", {});
244 * ```
245 *
246 * **Example:** Named SCORE key with labels and a test score
247 * ```typescript
248 * const key = yield* GCP.RecaptchaEnterprise.Key("Signup", {
249 * displayName: "signup form",
250 * labels: { env: "prod" },
251 * testingOptions: { testingScore: 0.9 },
252 * webSettings: {
253 * integrationType: "SCORE",
254 * allowAllDomains: true,
255 * },
256 * });
257 * ```
258 *
259 * ### Assessments
260 * **Example:** Create an assessment for the key
261 * ```typescript
262 * const createAssessment = yield* GCP.RecaptchaEnterprise.CreateAssessment(
263 * key,
264 * );
265 * const assessment = yield* createAssessment({
266 * body: {
267 * event: { token: recaptchaToken, expectedAction: "login" },
268 * },
269 * });
270 * ```
271 *
272 * @resource
273 * @category RecaptchaEnterprise
274 */
275export const Key = Resource<Key>("GCP.RecaptchaEnterprise.Key");
276
277export class KeyNotResolved extends Data.TaggedError(
278 "GCP.RecaptchaEnterprise.KeyNotResolved",
279)<{
280 name: string;
281}> {}
282
283type KeyKind = "web" | "android" | "ios" | "express";
284
285const userLabels = (
286 labels: Record<string, string | undefined> | null | undefined,
287): Record<string, string> => stripInternalLabels(tagRecord(labels));
288
289const kindOf = (
290 props: {
291 webSettings?: unknown;
292 androidSettings?: unknown;
293 iosSettings?: unknown;
294 expressSettings?: unknown;
295 },
296 fallback?: KeyKind,
297): KeyKind => {
298 if (props.androidSettings !== undefined) return "android";
299 if (props.iosSettings !== undefined) return "ios";
300 if (props.expressSettings !== undefined) return "express";
301 if (props.webSettings !== undefined) return "web";
302 return fallback ?? "web";
303};
304
305const toTestingOptions = (
306 options:
307 | recaptchaenterprise.GoogleCloudRecaptchaenterpriseV1TestingOptions
308 | TestingOptions
309 | undefined,
310): TestingOptions | undefined => {
311 if (options === undefined) return undefined;
312 if (
313 options.testingScore === undefined &&
314 options.testingChallenge === undefined
315 ) {
316 return undefined;
317 }
318 return {
319 testingScore: options.testingScore,
320 testingChallenge: options.testingChallenge,
321 };
322};
323
324const toWebSettings = (
325 settings:
326 | recaptchaenterprise.GoogleCloudRecaptchaenterpriseV1WebKeySettings
327 | WebKeySettings
328 | undefined,
329): WebKeySettings | undefined => {
330 if (settings === undefined) return undefined;
331 return {
332 allowedDomains: stringList(settings.allowedDomains),
333 integrationType: settings.integrationType,
334 challengeSecurityPreference: settings.challengeSecurityPreference,
335 allowAllDomains: settings.allowAllDomains,
336 allowAmpTraffic: settings.allowAmpTraffic,
337 challengeSettings: settings.challengeSettings,
338 };
339};
340
341const toAndroidSettings = (
342 settings:
343 | recaptchaenterprise.GoogleCloudRecaptchaenterpriseV1AndroidKeySettings
344 | AndroidKeySettings
345 | undefined,
346): AndroidKeySettings | undefined => {
347 if (settings === undefined) return undefined;
348 return {
349 allowedPackageNames: stringList(settings.allowedPackageNames),
350 supportNonGoogleAppStoreDistribution:
351 settings.supportNonGoogleAppStoreDistribution,
352 allowAllPackageNames: settings.allowAllPackageNames,
353 };
354};
355
356const toIosSettings = (
357 settings:
358 | recaptchaenterprise.GoogleCloudRecaptchaenterpriseV1IOSKeySettings
359 | IOSKeySettings
360 | undefined,
361): IOSKeySettings | undefined => {
362 if (settings === undefined) return undefined;
363 const apple = settings.appleDeveloperId;
364 return {
365 allowedBundleIds: stringList(settings.allowedBundleIds),
366 allowAllBundleIds: settings.allowAllBundleIds,
367 appleDeveloperId:
368 apple === undefined
369 ? undefined
370 : {
371 teamId: apple.teamId,
372 keyId: apple.keyId,
373 },
374 };
375};
376
377const toWafSettings = (
378 settings:
379 | recaptchaenterprise.GoogleCloudRecaptchaenterpriseV1WafSettings
380 | WafSettings
381 | undefined,
382): WafSettings | undefined => {
383 if (settings === undefined) return undefined;
384 if (settings.wafService === undefined && settings.wafFeature === undefined) {
385 return undefined;
386 }
387 return {
388 wafService: settings.wafService,
389 wafFeature: settings.wafFeature,
390 };
391};
392
393const toExpressSettings = (
394 settings:
395 | recaptchaenterprise.GoogleCloudRecaptchaenterpriseV1FirewallActionBlockAction
396 | Record<string, never>
397 | {}
398 | undefined,
399): Record<string, never> | {} | undefined =>
400 settings === undefined ? undefined : {};
401
402const desiredWebSettings = (news: KeyProps): WebKeySettings => ({
403 allowAllDomains: true,
404 integrationType: "SCORE",
405 ...news.webSettings,
406});
407
408const platformBody = (news: KeyProps, kind: KeyKind) => {
409 if (kind === "android") {
410 return { androidSettings: news.androidSettings ?? {} };
411 }
412 if (kind === "ios") {
413 return { iosSettings: news.iosSettings ?? {} };
414 }
415 if (kind === "express") {
416 return { expressSettings: {} };
417 }
418 return { webSettings: desiredWebSettings(news) };
419};
420
421const toKeyBody = (
422 news: KeyProps,
423 displayName: string,
424 labels: Record<string, string>,
425 kind: KeyKind,
426): recaptchaenterprise.GoogleCloudRecaptchaenterpriseV1Key => ({
427 displayName,
428 labels,
429 testingOptions: news.testingOptions,
430 wafSettings: news.wafSettings,
431 ...platformBody(news, kind),
432});
433
434const unspecified = (value: string | undefined) =>
435 value === undefined || value.length === 0 || value.endsWith("_UNSPECIFIED")
436 ? ""
437 : value;
438
439const testingFingerprint = (options: TestingOptions | undefined) =>
440 JSON.stringify({
441 testingScore: options?.testingScore ?? null,
442 testingChallenge: unspecified(options?.testingChallenge),
443 });
444
445const webFingerprint = (settings: WebKeySettings | undefined) =>
446 JSON.stringify({
447 allowedDomains: settings?.allowedDomains
448 ? [...settings.allowedDomains].slice().sort()
449 : [],
450 integrationType: unspecified(settings?.integrationType),
451 challengeSecurityPreference: unspecified(
452 settings?.challengeSecurityPreference,
453 ),
454 allowAllDomains: settings?.allowAllDomains === true,
455 allowAmpTraffic: settings?.allowAmpTraffic === true,
456 challengeSettings: settings?.challengeSettings ?? null,
457 });
458
459const androidFingerprint = (settings: AndroidKeySettings | undefined) =>
460 JSON.stringify({
461 allowedPackageNames: settings?.allowedPackageNames
462 ? [...settings.allowedPackageNames].slice().sort()
463 : [],
464 supportNonGoogleAppStoreDistribution:
465 settings?.supportNonGoogleAppStoreDistribution === true,
466 allowAllPackageNames: settings?.allowAllPackageNames === true,
467 });
468
469const iosFingerprint = (settings: IOSKeySettings | undefined) =>
470 JSON.stringify({
471 allowedBundleIds: settings?.allowedBundleIds
472 ? [...settings.allowedBundleIds].slice().sort()
473 : [],
474 allowAllBundleIds: settings?.allowAllBundleIds === true,
475 teamId: settings?.appleDeveloperId?.teamId ?? "",
476 keyId: settings?.appleDeveloperId?.keyId ?? "",
477 });
478
479const wafFingerprint = (settings: WafSettings | undefined) =>
480 JSON.stringify({
481 wafService: settings?.wafService ?? "",
482 wafFeature: settings?.wafFeature ?? "",
483 });
484
485const toAttrs = (
486 key: recaptchaenterprise.GoogleCloudRecaptchaenterpriseV1Key,
487 project: string,
488) => {
489 const name = key.name ?? "";
490 return {
491 name,
492 keyId: lastSegment(name),
493 project,
494 displayName: key.displayName,
495 labels: userLabels(key.labels),
496 testingOptions: toTestingOptions(key.testingOptions),
497 webSettings: toWebSettings(key.webSettings),
498 androidSettings: toAndroidSettings(key.androidSettings),
499 iosSettings: toIosSettings(key.iosSettings),
500 expressSettings: toExpressSettings(key.expressSettings),
501 wafSettings: toWafSettings(key.wafSettings),
502 createTime: key.createTime,
503 };
504};
505
506export const KeyProvider = () =>
507 Provider.succeed(Key, {
508 stables: ["name", "keyId", "project", "createTime"],
509
510 diff: Effect.fn(function* ({ news, olds, output }) {
511 if (!isResolved(news)) return undefined;
512 const previousId = olds?.keyId ?? output?.keyId;
513 if (
514 news.keyId !== undefined &&
515 previousId !== undefined &&
516 news.keyId !== previousId
517 ) {
518 return { action: "replace" as const, deleteFirst: false };
519 }
520 const previousKind = kindOf(olds ?? output ?? {});
521 const nextKind = kindOf(news, previousKind);
522 if (previousKind !== nextKind) {
523 return { action: "replace" as const, deleteFirst: false };
524 }
525 const previousType =
526 olds?.webSettings?.integrationType ??
527 output?.webSettings?.integrationType;
528 const nextType = news.webSettings?.integrationType;
529 if (
530 previousType !== undefined &&
531 nextType !== undefined &&
532 previousType !== nextType
533 ) {
534 return { action: "replace" as const, deleteFirst: false };
535 }
536 const previousAmp =
537 olds?.webSettings?.allowAmpTraffic ??
538 output?.webSettings?.allowAmpTraffic;
539 if (
540 news.webSettings?.allowAmpTraffic !== undefined &&
541 previousAmp !== undefined &&
542 news.webSettings.allowAmpTraffic !== previousAmp
543 ) {
544 return { action: "replace" as const, deleteFirst: false };
545 }
546 if (
547 (news.wafSettings !== undefined ||
548 olds?.wafSettings !== undefined ||
549 output?.wafSettings !== undefined) &&
550 wafFingerprint(news.wafSettings) !==
551 wafFingerprint(olds?.wafSettings ?? output?.wafSettings)
552 ) {
553 return { action: "replace" as const, deleteFirst: false };
554 }
555 const previousTesting = olds?.testingOptions ?? output?.testingOptions;
556 if (
557 (olds !== undefined || output !== undefined) &&
558 testingFingerprint(toTestingOptions(news.testingOptions)) !==
559 testingFingerprint(toTestingOptions(previousTesting))
560 ) {
561 return { action: "replace" as const, deleteFirst: false };
562 }
563 return undefined;
564 }),
565
566 read: Effect.fn(function* ({ id, olds, output }) {
567 const env = yield* GcpEnvironment.current;
568 const name =
569 output?.name ??
570 (olds?.keyId !== undefined
571 ? keyNameOf(env.project, olds.keyId)
572 : output?.keyId !== undefined
573 ? keyNameOf(env.project, output.keyId)
574 : "");
575 const existing = yield* findOwnedKey(env.project, id, name);
576 if (existing === undefined) return undefined;
577 const attrs = toAttrs(existing, env.project);
578 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
579 ? attrs
580 : Unowned(attrs);
581 }),
582
583 list: () =>
584 Effect.gen(function* () {
585 const env = yield* GcpEnvironment.current;
586 const keys = yield* listKeys(env.project);
587 return keys
588 .filter((key) =>
589 Object.keys(key.labels ?? {}).some((label) =>
590 label.startsWith("alchemy-"),
591 ),
592 )
593 .map((key) => toAttrs(key, env.project));
594 }),
595
596 reconcile: Effect.fn(function* ({ id, news, output }) {
597 const env = yield* GcpEnvironment.current;
598 const desiredLabels = {
599 ...toLabels(news.labels),
600 ...(yield* createInternalLabels(id)),
601 };
602 const lookupName =
603 output?.name ??
604 (news.keyId !== undefined
605 ? keyNameOf(env.project, news.keyId)
606 : output?.keyId !== undefined
607 ? keyNameOf(env.project, output.keyId)
608 : "");
609
610 let current = yield* findOwnedKey(env.project, id, lookupName);
611 const kind = kindOf(news, kindOf(current ?? {}, "web"));
612 const displayName = yield* toDisplayName(
613 id,
614 news.displayName,
615 current?.displayName ?? output?.displayName,
616 );
617 const desired = toKeyBody(news, displayName, desiredLabels, kind);
618
619 if (current === undefined) {
620 const created = yield* recaptchaenterprise
621 .createProjectsKeys({
622 parent: `projects/${env.project}`,
623 body: desired,
624 })
625 .pipe(
626 Effect.catchTag("Conflict", () =>
627 findOwnedKey(env.project, id, lookupName),
628 ),
629 );
630 current = created ?? undefined;
631 }
632
633 if (current === undefined) {
634 return yield* new KeyNotResolved({
635 name: lookupName.length > 0 ? lookupName : displayName,
636 });
637 }
638
639 const name = current.name ?? lookupName;
640 const labelsChanged = (() => {
641 const { upsert, removed } = diffLabels(
642 tagRecord(current.labels),
643 desiredLabels,
644 );
645 return upsert.length > 0 || removed.length > 0;
646 })();
647 const displayChanged = !sameText(current.displayName, displayName);
648 const webChanged =
649 kind === "web" &&
650 webFingerprint(toWebSettings(current.webSettings)) !==
651 webFingerprint(desiredWebSettings(news));
652 const androidChanged =
653 kind === "android" &&
654 androidFingerprint(toAndroidSettings(current.androidSettings)) !==
655 androidFingerprint(toAndroidSettings(news.androidSettings ?? {}));
656 const iosChanged =
657 kind === "ios" &&
658 iosFingerprint(toIosSettings(current.iosSettings)) !==
659 iosFingerprint(toIosSettings(news.iosSettings ?? {}));
660 const expressChanged =
661 kind === "express" && current.expressSettings === undefined;
662 const wafChanged =
663 wafFingerprint(toWafSettings(current.wafSettings)) !==
664 wafFingerprint(toWafSettings(news.wafSettings));
665
666 const updateMask = updateMaskOf(
667 displayChanged ? "displayName" : undefined,
668 labelsChanged ? "labels" : undefined,
669 webChanged ? "webSettings" : undefined,
670 androidChanged ? "androidSettings" : undefined,
671 iosChanged ? "iosSettings" : undefined,
672 expressChanged ? "expressSettings" : undefined,
673 wafChanged ? "wafSettings" : undefined,
674 );
675
676 if (updateMask.length > 0) {
677 current = yield* recaptchaenterprise.patchProjectsKeys({
678 name,
679 updateMask,
680 body: { ...desired, name },
681 });
682 }
683
684 return toAttrs(current, env.project);
685 }),
686
687 delete: Effect.fn(function* ({ output }) {
688 if (output.name.length === 0) return;
689 yield* recaptchaenterprise
690 .deleteProjectsKeys({ name: output.name })
691 .pipe(Effect.catchTag("NotFound", () => Effect.void));
692 }),
693 });