1import * as siteVerification from "@distilled.cloud/gcp/siteVerification_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 { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 DEFAULT_SITE_TYPE,
12 defaultVerificationMethod,
13 deleteWebResource,
14 findWebResource,
15 getWebResource,
16 getWebResourceToken,
17 hasOwnershipMarker,
18 listWebResources,
19 sameOwners,
20 sameText,
21 sortedOwners,
22 toGeneratedIdentifier,
23 toPathId,
24 type SiteType,
25 type VerificationMethod,
26} from "./internal.ts";
27
28export type { SiteType, VerificationMethod };
29export { getWebResourceToken };
30
31export type WebResourceProps = {
32 /**
33 * Site or domain identifier. For `SITE`, a URL such as
34 * `https://www.example.com/`. For `INET_DOMAIN`, a domain name such
35 * as `example.com`. If omitted, a unique hostname under
36 * `alchemy-site-verification.test` is generated from the stack,
37 * stage, and logical id so `list` / nuke can find it. Immutable —
38 * changing it replaces the web resource.
39 */
40 identifier?: string;
41 /**
42 * Resource type. `SITE` is a URL; `INET_DOMAIN` is a domain.
43 * Immutable — changing it replaces the web resource.
44 * @default "SITE"
45 */
46 siteType?: SiteType;
47 /**
48 * Verification method Google should check when inserting the
49 * resource. Must match the token previously placed on the site
50 * (from {@link getWebResourceToken}). Sites use `FILE`, `META`,
51 * `ANALYTICS`, or `TAG_MANAGER`. Domains use `DNS_TXT` or
52 * `DNS_CNAME`. Create-only — Google does not store it after
53 * verification.
54 * @default "FILE" for SITE, "DNS_TXT" for INET_DOMAIN
55 */
56 verificationMethod?: VerificationMethod;
57 /**
58 * Email addresses of verified owners. The authenticated user is
59 * always an owner after a successful insert. Omitted means leave
60 * the owner list unchanged on update. Replacing the list in place
61 * notifies every owner by email.
62 */
63 owners?: string[];
64};
65
66export type WebResource = Resource<
67 "GCP.SiteVerification.WebResource",
68 WebResourceProps,
69 {
70 /**
71 * API id for get / update / delete. Google returns this
72 * percent-encoded (`http%3A%2F%2F…`).
73 */
74 webResourceId: string;
75 /** Project id used when the resource was reconciled. */
76 project: string;
77 /** Site or domain identifier as stored by Google. */
78 identifier: string;
79 /** `SITE` or `INET_DOMAIN`. */
80 siteType: string;
81 /**
82 * Verification method used on insert, if known. The API does not
83 * return this after create.
84 */
85 verificationMethod: string | undefined;
86 /** Email addresses of verified owners. */
87 owners: string[];
88 },
89 never,
90 Providers
91>;
92
93/**
94 * A Google Site Verification web resource (verified site or domain).
95 *
96 * Site Verification has no labels or description field, so Alchemy
97 * stamps ownership into generated identifiers under
98 * `alchemy-site-verification.test` for `list` / nuke. User-provided
99 * identifiers are identity — they are not rewritten — and are not
100 * listed for nuke unless they contain that host. `identifier` and
101 * `siteType` are identity (changing either replaces the resource).
102 * `owners` update in place via `webResource.update`.
103 *
104 * Place the verification token from {@link getWebResourceToken} on
105 * the site or domain before the first reconcile. Insert checks that
106 * token; it fails with `BadRequest` if Google cannot find it. Creating
107 * web resources as a service account requires a user OAuth token with
108 * the `https://www.googleapis.com/auth/siteverification` scope (or
109 * domain-wide delegation).
110 *
111 * ### Creating a Web Resource
112 * **Example:** Verify a site with a FILE token
113 * ```typescript
114 * const token = yield* GCP.SiteVerification.getWebResourceToken({
115 * identifier: "https://www.example.com/",
116 * siteType: "SITE",
117 * verificationMethod: "FILE",
118 * });
119 * // Place token.token as a file at the site root, then:
120 * const site = yield* GCP.SiteVerification.WebResource("Docs", {
121 * identifier: "https://www.example.com/",
122 * siteType: "SITE",
123 * verificationMethod: "FILE",
124 * });
125 * ```
126 *
127 * **Example:** Verify a domain with DNS TXT
128 * ```typescript
129 * const token = yield* GCP.SiteVerification.getWebResourceToken({
130 * identifier: "example.com",
131 * siteType: "INET_DOMAIN",
132 * verificationMethod: "DNS_TXT",
133 * });
134 * const domain = yield* GCP.SiteVerification.WebResource("Apex", {
135 * identifier: "example.com",
136 * siteType: "INET_DOMAIN",
137 * verificationMethod: "DNS_TXT",
138 * });
139 * ```
140 *
141 * ### Updating Owners
142 * Re-declare the same logical id with changed props; the engine keeps the
143 * physical resource and updates it in place.
144 *
145 * **Example:** Delegate a co-owner
146 * ```typescript
147 * const site = yield* GCP.SiteVerification.WebResource("Docs", {
148 * identifier: "https://www.example.com/",
149 * siteType: "SITE",
150 * owners: ["admin@example.com", "teammate@example.com"],
151 * });
152 * ```
153 *
154 * @resource
155 * @category SiteVerification
156 */
157export const WebResource = Resource<WebResource>(
158 "GCP.SiteVerification.WebResource",
159);
160
161export class WebResourceNotResolved extends Data.TaggedError(
162 "GCP.SiteVerification.WebResourceNotResolved",
163)<{
164 identifier: string;
165 siteType: string;
166}> {}
167
168const toAttrs = (
169 resource: siteVerification.SiteVerificationWebResourceResource,
170 project: string,
171 verificationMethod: string | undefined,
172) => ({
173 webResourceId: resource.id ?? toPathId(resource.site?.identifier),
174 project,
175 identifier: resource.site?.identifier ?? "",
176 siteType: resource.site?.type ?? DEFAULT_SITE_TYPE,
177 verificationMethod,
178 owners: sortedOwners(resource.owners),
179});
180
181const desiredSiteType = (
182 news: WebResourceProps,
183 current: siteVerification.SiteVerificationWebResourceResource | undefined,
184 outputType: string | undefined,
185) => news.siteType ?? current?.site?.type ?? outputType ?? DEFAULT_SITE_TYPE;
186
187const desiredMethod = (
188 news: WebResourceProps,
189 siteType: string,
190 outputMethod: string | undefined,
191) =>
192 news.verificationMethod ??
193 outputMethod ??
194 defaultVerificationMethod(siteType);
195
196export const WebResourceProvider = () =>
197 Provider.succeed(WebResource, {
198 stables: ["webResourceId", "project", "identifier", "siteType"],
199
200 diff: Effect.fn(function* ({ news, olds, output }) {
201 if (!isResolved(news)) return undefined;
202 const previousType = olds?.siteType ?? output?.siteType;
203 if (
204 previousType !== undefined &&
205 news.siteType !== undefined &&
206 news.siteType !== previousType
207 ) {
208 return { action: "replace" as const, deleteFirst: false };
209 }
210 const previousId = olds?.identifier ?? output?.identifier;
211 if (
212 previousId !== undefined &&
213 news.identifier !== undefined &&
214 !sameText(news.identifier, previousId) &&
215 !sameText(
216 news.identifier.endsWith("/")
217 ? news.identifier
218 : `${news.identifier}/`,
219 previousId,
220 )
221 ) {
222 return { action: "replace" as const, deleteFirst: false };
223 }
224 return undefined;
225 }),
226
227 read: Effect.fn(function* ({ id, olds, output }) {
228 const env = yield* GcpEnvironment.current;
229 const siteType = olds?.siteType ?? output?.siteType ?? DEFAULT_SITE_TYPE;
230 const identifier = yield* toGeneratedIdentifier(
231 id,
232 siteType,
233 olds?.identifier,
234 output?.identifier,
235 );
236 let existing = yield* getWebResource(output?.webResourceId);
237 const foundById = existing !== undefined;
238 if (existing === undefined) {
239 existing = yield* findWebResource(identifier, siteType);
240 }
241 if (existing === undefined) return undefined;
242 const attrs = toAttrs(
243 existing,
244 env.project,
245 olds?.verificationMethod ?? output?.verificationMethod,
246 );
247 return foundById || hasOwnershipMarker(existing.site?.identifier)
248 ? attrs
249 : Unowned(attrs);
250 }),
251
252 list: () =>
253 Effect.gen(function* () {
254 const env = yield* GcpEnvironment.current;
255 const items = yield* listWebResources();
256 return items
257 .filter((item) => hasOwnershipMarker(item.site?.identifier))
258 .map((item) => toAttrs(item, env.project, undefined));
259 }),
260
261 reconcile: Effect.fn(function* ({ id, news, output }) {
262 const env = yield* GcpEnvironment.current;
263 const siteType = desiredSiteType(news, undefined, output?.siteType);
264 const identifier = yield* toGeneratedIdentifier(
265 id,
266 siteType,
267 news.identifier,
268 output?.identifier,
269 );
270 const verificationMethod = desiredMethod(
271 news,
272 siteType,
273 output?.verificationMethod,
274 );
275
276 let current = yield* getWebResource(output?.webResourceId);
277 if (current === undefined) {
278 current = yield* findWebResource(identifier, siteType);
279 }
280
281 if (current === undefined) {
282 const created = yield* siteVerification
283 .insertWebResource({
284 verificationMethod,
285 body: {
286 site: { identifier, type: siteType },
287 owners: news.owners,
288 },
289 })
290 .pipe(
291 Effect.catchTag("Conflict", () =>
292 findWebResource(identifier, siteType),
293 ),
294 );
295 current = created ?? undefined;
296 }
297
298 if (current === undefined) {
299 return yield* new WebResourceNotResolved({ identifier, siteType });
300 }
301
302 if (
303 news.owners !== undefined &&
304 !sameOwners(current.owners, news.owners)
305 ) {
306 const pathId = toPathId(current.id ?? output?.webResourceId);
307 current = yield* siteVerification.updateWebResource({
308 id: pathId,
309 body: {
310 site: {
311 identifier: current.site?.identifier ?? identifier,
312 type: current.site?.type ?? siteType,
313 },
314 owners: sortedOwners(news.owners),
315 },
316 });
317 }
318
319 return toAttrs(current, env.project, verificationMethod);
320 }),
321
322 delete: Effect.fn(function* ({ output }) {
323 yield* deleteWebResource(output.webResourceId);
324 }),
325 });