@getvitops/utils 8.2.0 → 8.3.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../../src/reviews/plan.ts","../../src/reviews/cache.ts","../../src/reviews/gbp.ts"],"sourcesContent":["/**\n * Every decision `vitops reviews` makes, as pure functions.\n *\n * Same split as `../indexing/plan.ts`: no `fetch`, no `fs`, no clock. Which\n * locations to fetch (and why one is blocked), and how a location's reviews are\n * filtered down to what a site actually displays, are decided here — `gbp.ts`\n * and `cache.ts` do what they're told and nothing else. That is what lets Astro's\n * `<ReviewCard>` and a future Bricks renderer both call `selectReviews` instead\n * of each re-implementing \"top 5 by rating, minus the ones already featured\".\n */\nimport type {\n LocationReviews,\n Review,\n ReviewCache,\n ReviewLocationConfig,\n ReviewsConfig,\n} from './types.ts';\n\n/** One location's fetch plan, or the reason it was skipped. */\nexport interface LocationPlan {\n key: string;\n accountId?: string;\n locationId?: string;\n /** Present iff this location will not be fetched. */\n skip?: string;\n}\n\nexport interface ReviewsPlan {\n /** Set when the whole run is refused — nothing is fetched. */\n blocked?: string;\n locations: LocationPlan[];\n notes: string[];\n}\n\nexport interface PlanInput {\n config: ReviewsConfig | undefined;\n /** Location keys with a Google listing configured (`organization.locations[].listings.google`). */\n knownLocations: Set<string>;\n /** `--location`: fetch exactly this one, skipping the rest. */\n onlyLocation?: string | undefined;\n /** Whether a Google credential was resolved. */\n hasCredential: boolean;\n}\n\n/** Decide which locations to fetch, and why any are skipped. */\nexport function plan(input: PlanInput): ReviewsPlan {\n const { config, knownLocations, onlyLocation, hasCredential } = input;\n const notes: string[] = [];\n\n if (!config || Object.keys(config.locations).length === 0) {\n return {\n blocked: 'no site.reviews.locations configured — nothing to fetch',\n locations: [],\n notes,\n };\n }\n\n if (!hasCredential) {\n return {\n blocked:\n 'no Google credential — set VITOPS_GOOGLE_CLIENT_ID / _CLIENT_SECRET / _REFRESH_TOKEN, or run `gcloud auth application-default login`',\n locations: [],\n notes,\n };\n }\n\n const entries = onlyLocation\n ? Object.entries(config.locations).filter(([key]) => key === onlyLocation)\n : Object.entries(config.locations);\n\n if (onlyLocation && entries.length === 0) {\n return {\n blocked: `--location \"${onlyLocation}\" is not in site.reviews.locations`,\n locations: [],\n notes,\n };\n }\n\n const locations: LocationPlan[] = entries.map(([key, loc]) => {\n if (!knownLocations.has(key))\n return {\n key,\n skip: `organization.locations has no entry keyed \"${key}\" with a listings.google id`,\n };\n const accountId = loc.accountId ?? config.accountId;\n if (!accountId)\n return {\n key,\n skip: `no accountId — set site.reviews.accountId or locations.${key}.accountId`,\n };\n return { key, accountId, locationId: loc.locationId };\n });\n\n const blocked = locations.filter((l) => l.skip);\n if (blocked.length) for (const l of blocked) notes.push(`${l.key}: skipped — ${l.skip}`);\n\n return { locations, notes };\n}\n\n/** Render a plan as the human-readable block `--dry` prints. */\nexport function formatPlan(p: ReviewsPlan): string {\n if (p.blocked) return `✗ blocked — ${p.blocked}`;\n const lines: string[] = [];\n const active = p.locations.filter((l) => !l.skip);\n lines.push(`locations ${active.length} of ${p.locations.length} will be fetched`);\n for (const l of active) lines.push(` ${l.key} ${l.accountId}/${l.locationId}`);\n for (const n of p.notes) lines.push(` ! ${n}`);\n return lines.join('\\n');\n}\n\n/**\n * Filter and order one location's reviews for display.\n *\n * Featured ids always win and keep the author's order — an explicit choice\n * overrides any automatic ranking. The remainder is filtered by `minRating`\n * (default 4 — a star-rating widget showing a 1-star review it did not have to\n * is a design choice a config field should not have to spell out every time),\n * sorted, then capped by `limit`.\n */\nexport function selectReviews(reviews: Review[], config: ReviewLocationConfig): Review[] {\n const featuredIds = config.featured ?? [];\n const byId = new Map(reviews.map((r) => [r.id, r]));\n const featured = featuredIds.map((id) => byId.get(id)).filter((r): r is Review => !!r);\n const featuredSet = new Set(featured.map((r) => r.id));\n\n const minRating = config.minRating ?? 4;\n const rest = reviews\n .filter((r) => !featuredSet.has(r.id) && r.rating >= minRating)\n .sort((a, b) =>\n config.sort === 'rating'\n ? b.rating - a.rating\n : new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(),\n );\n\n const combined = [...featured, ...rest];\n return config.limit !== undefined ? combined.slice(0, config.limit) : combined;\n}\n\n/**\n * Merge a fresh fetch into the existing cache.\n *\n * Only the locations actually fetched this run are replaced; every other\n * location's prior reviews pass through untouched. This is what makes a partial\n * failure (one location's fetch errors) non-destructive — `cmdReviews` merges\n * only the locations that succeeded and leaves the rest of the cache alone.\n */\nexport function mergeCache(\n existing: ReviewCache | undefined,\n fetched: Record<string, LocationReviews>,\n): ReviewCache {\n return {\n version: 1,\n locations: { ...existing?.locations, ...fetched },\n };\n}\n","/**\n * The cache — what makes a re-render free and offline.\n *\n * Committed, like `../media/manifest.ts` and for the same reason: reviews change\n * slowly, and a build should not need a live Google credential to render them. The\n * one field that keeps it from being byte-identical across runs is `fetchedAt` per\n * location — acceptable because `vitops reviews` is invoked deliberately\n * (scheduled or manual), not rewritten on every build the way `media-manifest.json`\n * is.\n */\nimport type { LocationReviews, ReviewCache } from './types.ts';\nimport { readVersioned, writeVersioned } from '../json-store.ts';\n\n/** Default location, relative to the consumer's project root. */\nexport const REVIEWS_CACHE_PATH = '.vitops/reviews.json';\n\n/**\n * Read the cache, or `undefined` when there isn't a usable one.\n *\n * A corrupt or wrong-version file reads as absent, exactly `readManifest`'s rule:\n * the alternative — reading it as an empty cache — would be indistinguishable\n * from \"this business genuinely has no reviews\" and silently ship an empty\n * carousel.\n */\nexport function readCache(path: string): ReviewCache | undefined {\n return readVersioned<ReviewCache, 'locations'>(path, 'locations');\n}\n\n/** Build a cache with its keys and each location's reviews in a stable order. */\nexport function toCache(locations: Record<string, LocationReviews>): ReviewCache {\n const sorted: Record<string, LocationReviews> = {};\n for (const key of Object.keys(locations).sort()) {\n const entry = locations[key] as LocationReviews;\n sorted[key] = {\n ...entry,\n reviews: [...entry.reviews].sort((a, b) => a.id.localeCompare(b.id)),\n };\n }\n return { version: 1, locations: sorted };\n}\n\n/** Write the cache, creating its directory. */\nexport function writeCache(path: string, cache: ReviewCache): void {\n writeVersioned(path, cache);\n}\n","/**\n * Google Business Profile — fetching one location's reviews.\n *\n * Reviews live only on the legacy v4 Business Information API\n * (`mybusiness.googleapis.com/v4`); the newer split APIs (Account Management,\n * Business Information v1) never grew a reviews resource. **The calling GCP\n * project must be allowlisted by Google before any call here succeeds** — a\n * one-time access-request form, turnaround measured in days to weeks — so a 403\n * is treated as that known state rather than surfaced as a raw failure. Scope:\n * `SCOPES.businessProfile` (`../google/token.ts`).\n *\n * TODO — re-check when Google migrates the Reviews endpoint off v4 onto a\n * successor API. Endpoint, auth, pagination and the review object's fields may\n * all change; this module is the one place that assumption lives.\n *\n * Auth is the shared exchange in `../google/token.ts`, same as `../indexing/gsc.ts`\n * — no `googleapis` dependency, one REST endpoint.\n */\nimport { googleHeaders, type GoogleAuthLike } from '../google/token.ts';\nimport type { Review } from './types.ts';\n\nconst STAR_RATING: Record<string, number> = {\n ONE: 1,\n TWO: 2,\n THREE: 3,\n FOUR: 4,\n FIVE: 5,\n};\n\ninterface RawReview {\n reviewId: string;\n reviewer?: { displayName?: string; profilePhotoUrl?: string; isAnonymous?: boolean };\n starRating?: string;\n comment?: string;\n createTime: string;\n updateTime: string;\n reviewReply?: { comment: string; updateTime: string };\n}\n\nfunction toReview(raw: RawReview, location: string): Review {\n return {\n id: raw.reviewId,\n location,\n rating: STAR_RATING[raw.starRating ?? ''] ?? 0,\n author: raw.reviewer?.isAnonymous\n ? 'A Google user'\n : (raw.reviewer?.displayName ?? 'A Google user'),\n ...(raw.reviewer?.profilePhotoUrl ? { authorPhoto: raw.reviewer.profilePhotoUrl } : {}),\n ...(raw.comment ? { text: raw.comment } : {}),\n publishedAt: raw.createTime,\n updatedAt: raw.updateTime,\n ...(raw.reviewReply\n ? { reply: { text: raw.reviewReply.comment, at: raw.reviewReply.updateTime } }\n : {}),\n // v4 carries no public review URL on the resource itself. The stable link is\n // `organization.locations[].listings.google` / `sameAs` — the whole profile,\n // not the single review — which is what a \"read more on Google\" affordance\n // should point at instead.\n };\n}\n\n/** One page (or, having paginated, the whole set) of a location's reviews. */\nexport interface FetchReviewsResult {\n ok: boolean;\n reviews: Review[];\n averageRating?: number;\n totalReviewCount?: number;\n status?: number;\n message?: string;\n}\n\n/**\n * Fetch every review for one location, paginating to completion.\n *\n * Never throws — a per-location failure is a structured result so a run across\n * several locations can fetch what it can and report the rest, the same contract\n * `../indexing/gsc.ts`'s `inspectUrl` keeps.\n */\nexport async function fetchReviews(\n auth: GoogleAuthLike,\n accountId: string,\n locationId: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<FetchReviewsResult> {\n const location = locationId.startsWith('locations/') ? locationId : `locations/${locationId}`;\n const account = accountId.startsWith('accounts/') ? accountId : `accounts/${accountId}`;\n const reviews: Review[] = [];\n let averageRating: number | undefined;\n let totalReviewCount: number | undefined;\n let pageToken: string | undefined;\n\n do {\n const url = new URL(`https://mybusiness.googleapis.com/v4/${account}/${location}/reviews`);\n url.searchParams.set('pageSize', '50');\n if (pageToken) url.searchParams.set('pageToken', pageToken);\n\n const res = await fetchImpl(url, { headers: googleHeaders(auth) });\n if (!res.ok) {\n const message =\n res.status === 403\n ? `403 — this GCP project is most likely not allowlisted for the Business Profile API yet ` +\n `(a one-time Google approval, requested separately from every other API here), or the ` +\n `credential is not an owner/manager of ${account}/${location}`\n : `${res.status}: ${await res.text()}`;\n return { ok: false, reviews, status: res.status, message };\n }\n\n const body = (await res.json()) as {\n reviews?: RawReview[];\n averageRating?: number;\n totalReviewCount?: number;\n nextPageToken?: string;\n };\n for (const r of body.reviews ?? []) reviews.push(toReview(r, locationId));\n averageRating = body.averageRating ?? averageRating;\n totalReviewCount = body.totalReviewCount ?? totalReviewCount;\n pageToken = body.nextPageToken;\n } while (pageToken);\n\n return {\n ok: true,\n reviews,\n ...(averageRating !== undefined ? { averageRating } : {}),\n ...(totalReviewCount !== undefined ? { totalReviewCount } : {}),\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/reviews/cache.ts","../../src/reviews/gbp.ts"],"sourcesContent":["/**\n * The cache — what makes a re-render free and offline.\n *\n * Committed, like `../media/manifest.ts` and for the same reason: reviews change\n * slowly, and a build should not need a live Google credential to render them. The\n * one field that keeps it from being byte-identical across runs is `fetchedAt` per\n * location — acceptable because `vitops reviews` is invoked deliberately\n * (scheduled or manual), not rewritten on every build the way `media-manifest.json`\n * is.\n */\nimport type { LocationReviews, ReviewCache } from './types.ts';\nimport { readVersioned, writeVersioned } from '../json-store.ts';\n\n/** Default location, relative to the consumer's project root. */\nexport const REVIEWS_CACHE_PATH = '.vitops/reviews.json';\n\n/**\n * Read the cache, or `undefined` when there isn't a usable one.\n *\n * A corrupt or wrong-version file reads as absent, exactly `readManifest`'s rule:\n * the alternative — reading it as an empty cache — would be indistinguishable\n * from \"this business genuinely has no reviews\" and silently ship an empty\n * carousel.\n */\nexport function readCache(path: string): ReviewCache | undefined {\n return readVersioned<ReviewCache, 'locations'>(path, 'locations');\n}\n\n/** Build a cache with its keys and each location's reviews in a stable order. */\nexport function toCache(locations: Record<string, LocationReviews>): ReviewCache {\n const sorted: Record<string, LocationReviews> = {};\n for (const key of Object.keys(locations).sort()) {\n const entry = locations[key] as LocationReviews;\n sorted[key] = {\n ...entry,\n reviews: [...entry.reviews].sort((a, b) => a.id.localeCompare(b.id)),\n };\n }\n return { version: 1, locations: sorted };\n}\n\n/** Write the cache, creating its directory. */\nexport function writeCache(path: string, cache: ReviewCache): void {\n writeVersioned(path, cache);\n}\n","/**\n * Google Business Profile — fetching one location's reviews.\n *\n * Reviews live only on the legacy v4 Business Information API\n * (`mybusiness.googleapis.com/v4`); the newer split APIs (Account Management,\n * Business Information v1) never grew a reviews resource. **The calling GCP\n * project must be allowlisted by Google before any call here succeeds** — a\n * one-time access-request form, turnaround measured in days to weeks — so a 403\n * is treated as that known state rather than surfaced as a raw failure. Scope:\n * `SCOPES.businessProfile` (`../google/token.ts`).\n *\n * TODO — re-check when Google migrates the Reviews endpoint off v4 onto a\n * successor API. Endpoint, auth, pagination and the review object's fields may\n * all change; this module is the one place that assumption lives.\n *\n * Auth is the shared exchange in `../google/token.ts`, same as `../indexing/gsc.ts`\n * — no `googleapis` dependency, one REST endpoint.\n */\nimport { googleHeaders, type GoogleAuthLike } from '../google/token.ts';\nimport type { Review } from './types.ts';\n\nconst STAR_RATING: Record<string, number> = {\n ONE: 1,\n TWO: 2,\n THREE: 3,\n FOUR: 4,\n FIVE: 5,\n};\n\ninterface RawReview {\n reviewId: string;\n reviewer?: { displayName?: string; profilePhotoUrl?: string; isAnonymous?: boolean };\n starRating?: string;\n comment?: string;\n createTime: string;\n updateTime: string;\n reviewReply?: { comment: string; updateTime: string };\n}\n\nfunction toReview(raw: RawReview, location: string): Review {\n return {\n id: raw.reviewId,\n location,\n rating: STAR_RATING[raw.starRating ?? ''] ?? 0,\n author: raw.reviewer?.isAnonymous\n ? 'A Google user'\n : (raw.reviewer?.displayName ?? 'A Google user'),\n ...(raw.reviewer?.profilePhotoUrl ? { authorPhoto: raw.reviewer.profilePhotoUrl } : {}),\n ...(raw.comment ? { text: raw.comment } : {}),\n publishedAt: raw.createTime,\n updatedAt: raw.updateTime,\n ...(raw.reviewReply\n ? { reply: { text: raw.reviewReply.comment, at: raw.reviewReply.updateTime } }\n : {}),\n // v4 carries no public review URL on the resource itself. The stable link is\n // `organization.locations[].listings.google` / `sameAs` — the whole profile,\n // not the single review — which is what a \"read more on Google\" affordance\n // should point at instead.\n };\n}\n\n/** One page (or, having paginated, the whole set) of a location's reviews. */\nexport interface FetchReviewsResult {\n ok: boolean;\n reviews: Review[];\n averageRating?: number;\n totalReviewCount?: number;\n status?: number;\n message?: string;\n}\n\n/**\n * Fetch every review for one location, paginating to completion.\n *\n * Never throws — a per-location failure is a structured result so a run across\n * several locations can fetch what it can and report the rest, the same contract\n * `../indexing/gsc.ts`'s `inspectUrl` keeps.\n */\nexport async function fetchReviews(\n auth: GoogleAuthLike,\n accountId: string,\n locationId: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<FetchReviewsResult> {\n const location = locationId.startsWith('locations/') ? locationId : `locations/${locationId}`;\n const account = accountId.startsWith('accounts/') ? accountId : `accounts/${accountId}`;\n const reviews: Review[] = [];\n let averageRating: number | undefined;\n let totalReviewCount: number | undefined;\n let pageToken: string | undefined;\n\n do {\n const url = new URL(`https://mybusiness.googleapis.com/v4/${account}/${location}/reviews`);\n url.searchParams.set('pageSize', '50');\n if (pageToken) url.searchParams.set('pageToken', pageToken);\n\n const res = await fetchImpl(url, { headers: googleHeaders(auth) });\n if (!res.ok) {\n const message =\n res.status === 403\n ? `403 — this GCP project is most likely not allowlisted for the Business Profile API yet ` +\n `(a one-time Google approval, requested separately from every other API here), or the ` +\n `credential is not an owner/manager of ${account}/${location}`\n : `${res.status}: ${await res.text()}`;\n return { ok: false, reviews, status: res.status, message };\n }\n\n const body = (await res.json()) as {\n reviews?: RawReview[];\n averageRating?: number;\n totalReviewCount?: number;\n nextPageToken?: string;\n };\n for (const r of body.reviews ?? []) reviews.push(toReview(r, locationId));\n averageRating = body.averageRating ?? averageRating;\n totalReviewCount = body.totalReviewCount ?? totalReviewCount;\n pageToken = body.nextPageToken;\n } while (pageToken);\n\n return {\n ok: true,\n reviews,\n ...(averageRating !== undefined ? { averageRating } : {}),\n ...(totalReviewCount !== undefined ? { totalReviewCount } : {}),\n };\n}\n"],"mappings":";;;;;;;AAcA,MAAa,qBAAqB;;;;;;;;;AAUlC,SAAgB,UAAU,MAAuC;CAC/D,OAAO,cAAwC,MAAM,WAAW;AAClE;;AAGA,SAAgB,QAAQ,WAAyD;CAC/E,MAAM,SAA0C,CAAC;CACjD,KAAK,MAAM,OAAO,OAAO,KAAK,SAAS,CAAC,CAAC,KAAK,GAAG;EAC/C,MAAM,QAAQ,UAAU;EACxB,OAAO,OAAO;GACZ,GAAG;GACH,SAAS,CAAC,GAAG,MAAM,OAAO,CAAC,CAAC,MAAM,GAAG,MAAM,EAAE,GAAG,cAAc,EAAE,EAAE,CAAC;EACrE;CACF;CACA,OAAO;EAAE,SAAS;EAAG,WAAW;CAAO;AACzC;;AAGA,SAAgB,WAAW,MAAc,OAA0B;CACjE,eAAe,MAAM,KAAK;AAC5B;;;;;;;;;;;;;;;;;;;;;ACvBA,MAAM,cAAsC;CAC1C,KAAK;CACL,KAAK;CACL,OAAO;CACP,MAAM;CACN,MAAM;AACR;AAYA,SAAS,SAAS,KAAgB,UAA0B;CAC1D,OAAO;EACL,IAAI,IAAI;EACR;EACA,QAAQ,YAAY,IAAI,cAAc,OAAO;EAC7C,QAAQ,IAAI,UAAU,cAClB,kBACC,IAAI,UAAU,eAAe;EAClC,GAAI,IAAI,UAAU,kBAAkB,EAAE,aAAa,IAAI,SAAS,gBAAgB,IAAI,CAAC;EACrF,GAAI,IAAI,UAAU,EAAE,MAAM,IAAI,QAAQ,IAAI,CAAC;EAC3C,aAAa,IAAI;EACjB,WAAW,IAAI;EACf,GAAI,IAAI,cACJ,EAAE,OAAO;GAAE,MAAM,IAAI,YAAY;GAAS,IAAI,IAAI,YAAY;EAAW,EAAE,IAC3E,CAAC;CAKP;AACF;;;;;;;;AAmBA,eAAsB,aACpB,MACA,WACA,YACA,YAA0B,OACG;CAC7B,MAAM,WAAW,WAAW,WAAW,YAAY,IAAI,aAAa,aAAa;CACjF,MAAM,UAAU,UAAU,WAAW,WAAW,IAAI,YAAY,YAAY;CAC5E,MAAM,UAAoB,CAAC;CAC3B,IAAI;CACJ,IAAI;CACJ,IAAI;CAEJ,GAAG;EACD,MAAM,MAAM,IAAI,IAAI,wCAAwC,QAAQ,GAAG,SAAS,SAAS;EACzF,IAAI,aAAa,IAAI,YAAY,IAAI;EACrC,IAAI,WAAW,IAAI,aAAa,IAAI,aAAa,SAAS;EAE1D,MAAM,MAAM,MAAM,UAAU,KAAK,EAAE,SAAS,cAAc,IAAI,EAAE,CAAC;EACjE,IAAI,CAAC,IAAI,IAAI;GACX,MAAM,UACJ,IAAI,WAAW,MACX,qNAEyC,QAAQ,GAAG,aACpD,GAAG,IAAI,OAAO,IAAI,MAAM,IAAI,KAAK;GACvC,OAAO;IAAE,IAAI;IAAO;IAAS,QAAQ,IAAI;IAAQ;GAAQ;EAC3D;EAEA,MAAM,OAAQ,MAAM,IAAI,KAAK;EAM7B,KAAK,MAAM,KAAK,KAAK,WAAW,CAAC,GAAG,QAAQ,KAAK,SAAS,GAAG,UAAU,CAAC;EACxE,gBAAgB,KAAK,iBAAiB;EACtC,mBAAmB,KAAK,oBAAoB;EAC5C,YAAY,KAAK;CACnB,SAAS;CAET,OAAO;EACL,IAAI;EACJ;EACA,GAAI,kBAAkB,SAAY,EAAE,cAAc,IAAI,CAAC;EACvD,GAAI,qBAAqB,SAAY,EAAE,iBAAiB,IAAI,CAAC;CAC/D;AACF"}
@@ -0,0 +1,2 @@
1
+ import { c as LocationReviews, d as ReviewLocationConfig, f as ReviewsConfig, l as Review, s as selectReviews, u as ReviewCache } from "../plan-CddqsmJJ.mjs";
2
+ export { type LocationReviews, type Review, type ReviewCache, type ReviewLocationConfig, type ReviewsConfig, selectReviews };
@@ -0,0 +1,3 @@
1
+ import { i as selectReviews } from "../plan-DpHv6NO8.mjs";
2
+
3
+ export { selectReviews };
@@ -0,0 +1,105 @@
1
+ /** A Turnstile widget as the list endpoint returns it. The list never carries the secret. */
2
+ interface WidgetSummary {
3
+ sitekey: string;
4
+ name: string;
5
+ domains: string[];
6
+ mode: string;
7
+ }
8
+ /** What the config asks for. */
9
+ interface TurnstileSetup {
10
+ /** Widget display name (not unique in Cloudflare). */
11
+ name: string;
12
+ /** Hostnames to protect; the widget also covers their subdomains. */
13
+ domains: string[];
14
+ /** `site.security.turnstile.siteKey`, when already set. */
15
+ siteKey?: string | undefined;
16
+ }
17
+ /** What the executor observed. */
18
+ interface TurnstileState {
19
+ /** Undefined when no account could be resolved. */
20
+ accountId?: string | undefined;
21
+ widgets: WidgetSummary[];
22
+ }
23
+ type StepAction = 'create' | 'skip' | 'blocked';
24
+ interface StepPlan {
25
+ action: StepAction;
26
+ detail: string;
27
+ }
28
+ interface TurnstilePlan {
29
+ /** Create the widget in Cloudflare. */
30
+ widget: StepPlan;
31
+ /** Write the public site key to `site.security.turnstile.siteKey`. */
32
+ config: StepPlan;
33
+ /** The widget the config should point at, when one already exists (adopted). */
34
+ adopt?: WidgetSummary | undefined;
35
+ /** Hostnames the created widget will cover. */
36
+ domains: string[];
37
+ /** What only a human can do — most importantly, where the secret goes. */
38
+ reminders: string[];
39
+ }
40
+ /** Cloudflare caps a widget at 10 domains; more is a plan-time refusal, not an API 400. */
41
+ export declare const MAX_DOMAINS = 10;
42
+ export declare function plan(setup: TurnstileSetup, state: TurnstileState): TurnstilePlan;
43
+ /** Any step that isn't a skip — the live state or the config has drifted. */
44
+ export declare function hasDrift(p: TurnstilePlan): boolean;
45
+ export declare function formatPlan(p: TurnstilePlan): string;
46
+ type StepStatus = 'created' | 'present' | 'blocked' | 'failed';
47
+ interface TurnstileResult {
48
+ step: string;
49
+ status: StepStatus;
50
+ detail: string;
51
+ }
52
+ export declare function formatSummary(results: TurnstileResult[]): string;
53
+ /**
54
+ * The account to create the widget in. `CLOUDFLARE_ACCOUNT_ID` wins; otherwise the token's
55
+ * only account. Cloudflare's docs state no permission for `GET /accounts`, so a token scoped
56
+ * to Turnstile alone may be refused here: the message says to set the id instead.
57
+ */
58
+ export declare function resolveAccountId(token: string, explicit: string | undefined, fetchImpl?: typeof fetch): Promise<{
59
+ ok: boolean;
60
+ accountId?: string;
61
+ message?: string;
62
+ }>;
63
+ /** Every widget in the account, following pagination. */
64
+ export declare function listWidgets(token: string, accountId: string, fetchImpl?: typeof fetch): Promise<{
65
+ ok: boolean;
66
+ widgets: WidgetSummary[];
67
+ message?: string;
68
+ }>;
69
+ /**
70
+ * Create a managed widget. **Create only** — the planner decides whether one is needed. The
71
+ * returned `secret` is the only time Cloudflare shows it; the caller must store it as a Worker
72
+ * secret and must not write it to a config file.
73
+ */
74
+ export declare function createWidget(token: string, accountId: string, widget: {
75
+ name: string;
76
+ domains: string[];
77
+ }, fetchImpl?: typeof fetch): Promise<{
78
+ ok: boolean;
79
+ sitekey?: string;
80
+ secret?: string;
81
+ message?: string;
82
+ }>;
83
+ /**
84
+ * Hostnames a widget should cover, from the site config: the canonical domain and every
85
+ * environment's URL. Cloudflare matches subdomains too, so `example.com` already covers
86
+ * `dev.example.com`. Deduplicated, lowercase, no scheme or port. `localhost` is never added:
87
+ * Cloudflare publishes always-pass test keys for local development.
88
+ */
89
+ export declare function deriveDomains(site: {
90
+ domains?: {
91
+ canonical?: string | undefined;
92
+ } | undefined;
93
+ environments?: Record<string, {
94
+ url?: string | undefined;
95
+ } | undefined> | undefined;
96
+ }): string[];
97
+ /**
98
+ * Merge the site key into a **raw, on-disk** config object — the rule `ads setup`'s
99
+ * `patchRaw` follows: never write from the resolved object, so `extends` and shorthand
100
+ * shapes are not normalised into a copy nothing builds from. Everything outside
101
+ * `site.security.turnstile.siteKey` is left exactly as it was.
102
+ */
103
+ export declare function patchSiteKey(raw: unknown, siteKey: string): unknown;
104
+ export type { StepAction, StepPlan, StepStatus, TurnstilePlan, TurnstileResult, TurnstileSetup, TurnstileState, WidgetSummary };
105
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/turnstile/types.ts","../../src/turnstile/plan.ts","../../src/turnstile/cloudflare.ts","../../src/turnstile/index.ts"],"mappings":";UACiB;EACf;EACA;EACA;EACA;;;UAIe;;EAEf;;EAEA;;EAEA;;;UAIe;;EAEf;EACA,SAAS;;KCJC;UAEK;EACf,QAAQ;EACR;;UAGe;;EAEf,QAAQ;;EAER,QAAQ;;EAER,QAAQ;;EAER;;EAEA;;;qBAIW;wBAKG,KAAK,OAAO,gBAAgB,OAAO,iBAAiB;;wBA4FpD,SAAS,GAAG;wBAMZ,WAAW,GAAG;KAUlB;UAEK;EACf;EACA,QAAQ;EACR;;wBAGc,cAAc,SAAS;;;;;;wBCpHjB,iBACpB,eACA,8BACA,mBAAkB,QACjB;EAAU;EAAa;EAAoB;;;wBAuBxB,YACpB,eACA,mBACA,mBAAkB,QACjB;EAAU;EAAa,SAAS;EAAiB;;;;;;;wBA2B9B,aACpB,eACA,mBACA;EAAU;EAAc;GACxB,mBAAkB,QACjB;EAAU;EAAa;EAAkB;EAAiB;;;;;;;;wBCrF7C,cAAc;EAC5B;IAAY;;EACZ,eAAe;IAAiB;;;;;;;;;wBA0BlB,aAAa,cAAc"}
@@ -0,0 +1,281 @@
1
+ import { t as table } from "../plan-format-CIz6Feg2.mjs";
2
+
3
+ // src/turnstile/plan.ts
4
+ /**
5
+ * Every decision `vitops setup turnstile` makes, as one pure function.
6
+ *
7
+ * Pure in the load-bearing sense: no `fetch`, no `fs`, no clock, no stdin. The
8
+ * executor (`cloudflare.ts`) lists the account's widgets and creates the missing one;
9
+ * this module decides, from that observation, what is left to do. The same split
10
+ * `ads/plan.ts` and `onboarding/plan.ts` make, for the same two payoffs: `--dry` is a
11
+ * *complete* account of a run, and `--check` is an honest drift report.
12
+ *
13
+ * Idempotency lives here. A widget that already covers the site's domains resolves
14
+ * to `skip`, so a re-run is all skips by construction rather than by the executor
15
+ * checking twice. And there is no update or delete verb anywhere in this module's
16
+ * vocabulary: an existing widget is only ever *adopted* (its public site key is
17
+ * written to the config), never edited, rotated or removed.
18
+ */
19
+ /** Cloudflare caps a widget at 10 domains; more is a plan-time refusal, not an API 400. */
20
+ const MAX_DOMAINS = 10;
21
+ const covers = (w, domains) => domains.every((d) => w.domains.some((wd) => d === wd || d.endsWith(`.${wd}`)));
22
+ function plan(setup, state) {
23
+ const reminders = ["Set the secret on the Worker: `npx wrangler secret put VITOPS_TURNSTILE_SECRET` (and on the dev worker with `--env`/`--config` as your setup uses). It is shown once, at creation, and never written to site.json."];
24
+ if (setup.domains.length === 0) return {
25
+ widget: {
26
+ action: "blocked",
27
+ detail: "no hostnames to protect — set site.domains.canonical or site.environments.<env>.url"
28
+ },
29
+ config: {
30
+ action: "blocked",
31
+ detail: "needs a widget first"
32
+ },
33
+ domains: [],
34
+ reminders: []
35
+ };
36
+ if (setup.domains.length > 10) return {
37
+ widget: {
38
+ action: "blocked",
39
+ detail: `${setup.domains.length} hostnames, but Cloudflare allows ${10} per widget`
40
+ },
41
+ config: {
42
+ action: "blocked",
43
+ detail: "needs a widget first"
44
+ },
45
+ domains: setup.domains,
46
+ reminders: []
47
+ };
48
+ if (!state.accountId) return {
49
+ widget: {
50
+ action: "blocked",
51
+ detail: "no Cloudflare account id — set CLOUDFLARE_ACCOUNT_ID (or use a token that can list accounts)"
52
+ },
53
+ config: {
54
+ action: "blocked",
55
+ detail: "needs a widget first"
56
+ },
57
+ domains: setup.domains,
58
+ reminders: []
59
+ };
60
+ const configured = setup.siteKey ? state.widgets.find((w) => w.sitekey === setup.siteKey) : void 0;
61
+ if (setup.siteKey && !configured) return {
62
+ widget: {
63
+ action: "blocked",
64
+ detail: `site.security.turnstile.siteKey ${setup.siteKey} matches no widget in this account — remove it to let setup adopt or create one`
65
+ },
66
+ config: {
67
+ action: "blocked",
68
+ detail: "config key is unverified"
69
+ },
70
+ domains: setup.domains,
71
+ reminders: []
72
+ };
73
+ const existing = configured ?? state.widgets.find((w) => covers(w, setup.domains));
74
+ if (existing) {
75
+ const uncovered = setup.domains.filter((d) => !covers(existing, [d]));
76
+ return {
77
+ widget: {
78
+ action: "skip",
79
+ detail: uncovered.length === 0 ? `widget "${existing.name}" already covers ${setup.domains.join(", ")}` : `widget "${existing.name}" exists but does not cover ${uncovered.join(", ")} — add them in the dashboard; this command never edits a widget`
80
+ },
81
+ config: setup.siteKey === existing.sitekey ? {
82
+ action: "skip",
83
+ detail: "site key already in site.json"
84
+ } : {
85
+ action: "create",
86
+ detail: `write ${existing.sitekey} to site.json`
87
+ },
88
+ adopt: existing,
89
+ domains: setup.domains,
90
+ reminders: ["The secret of an existing widget cannot be read back. Lost it? Rotate it in the dashboard (Turnstile > the widget > Rotate secret), then `wrangler secret put VITOPS_TURNSTILE_SECRET`."]
91
+ };
92
+ }
93
+ return {
94
+ widget: {
95
+ action: "create",
96
+ detail: `create "${setup.name}" (managed) for ${setup.domains.join(", ")}`
97
+ },
98
+ config: {
99
+ action: "create",
100
+ detail: "write the new site key to site.json"
101
+ },
102
+ domains: setup.domains,
103
+ reminders
104
+ };
105
+ }
106
+ /** Any step that isn't a skip — the live state or the config has drifted. */
107
+ function hasDrift(p) {
108
+ return p.widget.action !== "skip" || p.config.action !== "skip";
109
+ }
110
+ const GLYPH = {
111
+ create: "+",
112
+ skip: "·",
113
+ blocked: "!"
114
+ };
115
+ function formatPlan(p) {
116
+ const rows = [["widget", p.widget], ["config", p.config]].map(([name, step]) => [
117
+ GLYPH[step.action],
118
+ name,
119
+ step.detail
120
+ ]);
121
+ return table([
122
+ "",
123
+ "step",
124
+ "detail"
125
+ ], rows, p.reminders);
126
+ }
127
+ function formatSummary(results) {
128
+ return table([
129
+ "step",
130
+ "status",
131
+ "detail"
132
+ ], results.map((r) => [
133
+ r.step,
134
+ r.status,
135
+ r.detail
136
+ ]), []);
137
+ }
138
+
139
+ // src/turnstile/cloudflare.ts
140
+ const API = "https://api.cloudflare.com/client/v4";
141
+ const cfError = (status, body) => {
142
+ const first = body.errors?.[0];
143
+ return first ? `${first.code} ${first.message}` : `HTTP ${status}`;
144
+ };
145
+ const PERMISSION_HINT = " (does the token carry Account > Turnstile > Edit?)";
146
+ async function json(res) {
147
+ try {
148
+ return await res.json();
149
+ } catch {
150
+ return { success: false };
151
+ }
152
+ }
153
+ /**
154
+ * The account to create the widget in. `CLOUDFLARE_ACCOUNT_ID` wins; otherwise the token's
155
+ * only account. Cloudflare's docs state no permission for `GET /accounts`, so a token scoped
156
+ * to Turnstile alone may be refused here: the message says to set the id instead.
157
+ */
158
+ async function resolveAccountId(token, explicit, fetchImpl = fetch) {
159
+ if (explicit) return {
160
+ ok: true,
161
+ accountId: explicit
162
+ };
163
+ const res = await fetchImpl(`${API}/accounts?per_page=5`, { headers: { authorization: `Bearer ${token}` } });
164
+ const body = await json(res);
165
+ if (!res.ok || !body.success) return {
166
+ ok: false,
167
+ message: `${cfError(res.status, body)} — set CLOUDFLARE_ACCOUNT_ID to skip the account lookup`
168
+ };
169
+ const accounts = body.result ?? [];
170
+ if (accounts.length === 1) return {
171
+ ok: true,
172
+ accountId: accounts[0].id
173
+ };
174
+ return {
175
+ ok: false,
176
+ message: accounts.length === 0 ? "the token can see no accounts — set CLOUDFLARE_ACCOUNT_ID" : `the token can see ${accounts.length} accounts — set CLOUDFLARE_ACCOUNT_ID to choose one`
177
+ };
178
+ }
179
+ /** Every widget in the account, following pagination. */
180
+ async function listWidgets(token, accountId, fetchImpl = fetch) {
181
+ const widgets = [];
182
+ for (let page = 1; page <= 20; page++) {
183
+ const res = await fetchImpl(`${API}/accounts/${encodeURIComponent(accountId)}/challenges/widgets?page=${page}&per_page=100`, { headers: { authorization: `Bearer ${token}` } });
184
+ const body = await json(res);
185
+ if (!res.ok || !body.success) return {
186
+ ok: false,
187
+ widgets: [],
188
+ message: cfError(res.status, body) + (res.status === 403 ? PERMISSION_HINT : "")
189
+ };
190
+ for (const w of body.result ?? []) widgets.push({
191
+ sitekey: w.sitekey,
192
+ name: w.name,
193
+ domains: w.domains ?? [],
194
+ mode: w.mode
195
+ });
196
+ const info = body.result_info;
197
+ if (!info || page * info.per_page >= info.total_count) break;
198
+ }
199
+ return {
200
+ ok: true,
201
+ widgets
202
+ };
203
+ }
204
+ /**
205
+ * Create a managed widget. **Create only** — the planner decides whether one is needed. The
206
+ * returned `secret` is the only time Cloudflare shows it; the caller must store it as a Worker
207
+ * secret and must not write it to a config file.
208
+ */
209
+ async function createWidget(token, accountId, widget, fetchImpl = fetch) {
210
+ const res = await fetchImpl(`${API}/accounts/${encodeURIComponent(accountId)}/challenges/widgets`, {
211
+ method: "POST",
212
+ headers: {
213
+ authorization: `Bearer ${token}`,
214
+ "content-type": "application/json"
215
+ },
216
+ body: JSON.stringify({
217
+ name: widget.name,
218
+ domains: widget.domains,
219
+ mode: "managed"
220
+ })
221
+ });
222
+ const body = await json(res);
223
+ if (!res.ok || !body.success || !body.result?.sitekey || !body.result.secret) return {
224
+ ok: false,
225
+ message: cfError(res.status, body) + (res.status === 403 ? PERMISSION_HINT : "")
226
+ };
227
+ return {
228
+ ok: true,
229
+ sitekey: body.result.sitekey,
230
+ secret: body.result.secret
231
+ };
232
+ }
233
+
234
+ // src/turnstile/index.ts
235
+ /**
236
+ * Hostnames a widget should cover, from the site config: the canonical domain and every
237
+ * environment's URL. Cloudflare matches subdomains too, so `example.com` already covers
238
+ * `dev.example.com`. Deduplicated, lowercase, no scheme or port. `localhost` is never added:
239
+ * Cloudflare publishes always-pass test keys for local development.
240
+ */
241
+ function deriveDomains(site) {
242
+ const urls = [site.domains?.canonical, ...Object.values(site.environments ?? {}).map((e) => e?.url)];
243
+ const hosts = /* @__PURE__ */ new Set();
244
+ for (const url of urls) {
245
+ if (!url) continue;
246
+ try {
247
+ const host = new URL(url).hostname.toLowerCase();
248
+ if (host && host !== "localhost") hosts.add(host);
249
+ } catch {}
250
+ }
251
+ return [...hosts].filter((h) => ![...hosts].some((p) => p !== h && h.endsWith(`.${p}`)));
252
+ }
253
+ /**
254
+ * Merge the site key into a **raw, on-disk** config object — the rule `ads setup`'s
255
+ * `patchRaw` follows: never write from the resolved object, so `extends` and shorthand
256
+ * shapes are not normalised into a copy nothing builds from. Everything outside
257
+ * `site.security.turnstile.siteKey` is left exactly as it was.
258
+ */
259
+ function patchSiteKey(raw, siteKey) {
260
+ if (typeof raw !== "object" || raw == null || Array.isArray(raw)) throw new Error("config is not an object");
261
+ const root = raw;
262
+ const site = root["site"] ?? {};
263
+ const security = site["security"] ?? {};
264
+ const turnstile = security["turnstile"] ?? {};
265
+ return {
266
+ ...root,
267
+ site: {
268
+ ...site,
269
+ security: {
270
+ ...security,
271
+ turnstile: {
272
+ ...turnstile,
273
+ siteKey
274
+ }
275
+ }
276
+ }
277
+ };
278
+ }
279
+
280
+ export { MAX_DOMAINS, createWidget, deriveDomains, formatPlan, formatSummary, hasDrift, listWidgets, patchSiteKey, plan, resolveAccountId };
281
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/turnstile/plan.ts","../../src/turnstile/cloudflare.ts","../../src/turnstile/index.ts"],"sourcesContent":["/**\n * Every decision `vitops setup turnstile` makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fetch`, no `fs`, no clock, no stdin. The\n * executor (`cloudflare.ts`) lists the account's widgets and creates the missing one;\n * this module decides, from that observation, what is left to do. The same split\n * `ads/plan.ts` and `onboarding/plan.ts` make, for the same two payoffs: `--dry` is a\n * *complete* account of a run, and `--check` is an honest drift report.\n *\n * Idempotency lives here. A widget that already covers the site's domains resolves\n * to `skip`, so a re-run is all skips by construction rather than by the executor\n * checking twice. And there is no update or delete verb anywhere in this module's\n * vocabulary: an existing widget is only ever *adopted* (its public site key is\n * written to the config), never edited, rotated or removed.\n */\nimport { table } from '../plan-format.ts';\nimport type { TurnstileSetup, TurnstileState, WidgetSummary } from './types.ts';\n\nexport type StepAction = 'create' | 'skip' | 'blocked';\n\nexport interface StepPlan {\n action: StepAction;\n detail: string;\n}\n\nexport interface TurnstilePlan {\n /** Create the widget in Cloudflare. */\n widget: StepPlan;\n /** Write the public site key to `site.security.turnstile.siteKey`. */\n config: StepPlan;\n /** The widget the config should point at, when one already exists (adopted). */\n adopt?: WidgetSummary | undefined;\n /** Hostnames the created widget will cover. */\n domains: string[];\n /** What only a human can do — most importantly, where the secret goes. */\n reminders: string[];\n}\n\n/** Cloudflare caps a widget at 10 domains; more is a plan-time refusal, not an API 400. */\nexport const MAX_DOMAINS = 10;\n\nconst covers = (w: WidgetSummary, domains: string[]): boolean =>\n domains.every((d) => w.domains.some((wd) => d === wd || d.endsWith(`.${wd}`)));\n\nexport function plan(setup: TurnstileSetup, state: TurnstileState): TurnstilePlan {\n const reminders = [\n 'Set the secret on the Worker: `npx wrangler secret put VITOPS_TURNSTILE_SECRET` (and on the dev worker with `--env`/`--config` as your setup uses). It is shown once, at creation, and never written to site.json.',\n ];\n\n if (setup.domains.length === 0)\n return {\n widget: {\n action: 'blocked',\n detail:\n 'no hostnames to protect — set site.domains.canonical or site.environments.<env>.url',\n },\n config: { action: 'blocked', detail: 'needs a widget first' },\n domains: [],\n reminders: [],\n };\n if (setup.domains.length > MAX_DOMAINS)\n return {\n widget: {\n action: 'blocked',\n detail: `${setup.domains.length} hostnames, but Cloudflare allows ${MAX_DOMAINS} per widget`,\n },\n config: { action: 'blocked', detail: 'needs a widget first' },\n domains: setup.domains,\n reminders: [],\n };\n\n if (!state.accountId)\n return {\n widget: {\n action: 'blocked',\n detail:\n 'no Cloudflare account id — set CLOUDFLARE_ACCOUNT_ID (or use a token that can list accounts)',\n },\n config: { action: 'blocked', detail: 'needs a widget first' },\n domains: setup.domains,\n reminders: [],\n };\n\n // The config's own key wins: if it names a widget in this account, that is the one.\n const configured = setup.siteKey\n ? state.widgets.find((w) => w.sitekey === setup.siteKey)\n : undefined;\n if (setup.siteKey && !configured) {\n // A key that no widget in this account owns is drift (typo, another account, a deleted\n // widget). Creating a second widget would hide it, so stop and say so.\n return {\n widget: {\n action: 'blocked',\n detail: `site.security.turnstile.siteKey ${setup.siteKey} matches no widget in this account — remove it to let setup adopt or create one`,\n },\n config: { action: 'blocked', detail: 'config key is unverified' },\n domains: setup.domains,\n reminders: [],\n };\n }\n\n const existing = configured ?? state.widgets.find((w) => covers(w, setup.domains));\n if (existing) {\n const uncovered = setup.domains.filter((d) => !covers(existing, [d]));\n return {\n widget: {\n action: 'skip',\n detail:\n uncovered.length === 0\n ? `widget \"${existing.name}\" already covers ${setup.domains.join(', ')}`\n : `widget \"${existing.name}\" exists but does not cover ${uncovered.join(', ')} — add them in the dashboard; this command never edits a widget`,\n },\n config:\n setup.siteKey === existing.sitekey\n ? { action: 'skip', detail: 'site key already in site.json' }\n : { action: 'create', detail: `write ${existing.sitekey} to site.json` },\n adopt: existing,\n domains: setup.domains,\n reminders: [\n 'The secret of an existing widget cannot be read back. Lost it? Rotate it in the dashboard (Turnstile > the widget > Rotate secret), then `wrangler secret put VITOPS_TURNSTILE_SECRET`.',\n ],\n };\n }\n\n return {\n widget: {\n action: 'create',\n detail: `create \"${setup.name}\" (managed) for ${setup.domains.join(', ')}`,\n },\n config: { action: 'create', detail: 'write the new site key to site.json' },\n domains: setup.domains,\n reminders,\n };\n}\n\n/** Any step that isn't a skip — the live state or the config has drifted. */\nexport function hasDrift(p: TurnstilePlan): boolean {\n return p.widget.action !== 'skip' || p.config.action !== 'skip';\n}\n\nconst GLYPH: Record<StepAction, string> = { create: '+', skip: '·', blocked: '!' };\n\nexport function formatPlan(p: TurnstilePlan): string {\n const rows = (\n [\n ['widget', p.widget],\n ['config', p.config],\n ] as const\n ).map(([name, step]) => [GLYPH[step.action], name, step.detail]);\n return table(['', 'step', 'detail'], rows, p.reminders);\n}\n\nexport type StepStatus = 'created' | 'present' | 'blocked' | 'failed';\n\nexport interface TurnstileResult {\n step: string;\n status: StepStatus;\n detail: string;\n}\n\nexport function formatSummary(results: TurnstileResult[]): string {\n return table(\n ['step', 'status', 'detail'],\n results.map((r) => [r.step, r.status, r.detail]),\n [],\n );\n}\n","/**\n * Cloudflare's Turnstile widget API — the one thing `vitops setup turnstile` writes.\n *\n * Verified against developers.cloudflare.com (2026-11): list is\n * `GET /accounts/{id}/challenges/widgets` (paged, `per_page` 5 to 1000), create is\n * `POST` to the same path with `name`, `domains` (max 10) and `mode` required, and only\n * the create response carries `secret`. The token needs the **Account > Turnstile > Edit**\n * permission (API reference names: `Turnstile Sites Write`, or `Account Settings Write`).\n *\n * As in `onboarding/cloudflare.ts` there is deliberately no update, rotate or delete verb:\n * the destructive operations are absent rather than guarded. `fetchImpl` is injected and\n * every call returns a structured result rather than throwing.\n */\nimport type { WidgetSummary } from './types.ts';\n\nconst API = 'https://api.cloudflare.com/client/v4';\n\ninterface CfEnvelope<T> {\n success: boolean;\n errors?: { code: number; message: string }[];\n result?: T;\n result_info?: { page: number; per_page: number; total_count: number };\n}\n\nconst cfError = (status: number, body: CfEnvelope<unknown>): string => {\n const first = body.errors?.[0];\n return first ? `${first.code} ${first.message}` : `HTTP ${status}`;\n};\n\nconst PERMISSION_HINT = ' (does the token carry Account > Turnstile > Edit?)';\n\nasync function json<T>(res: Response): Promise<CfEnvelope<T>> {\n try {\n return (await res.json()) as CfEnvelope<T>;\n } catch {\n return { success: false };\n }\n}\n\n/**\n * The account to create the widget in. `CLOUDFLARE_ACCOUNT_ID` wins; otherwise the token's\n * only account. Cloudflare's docs state no permission for `GET /accounts`, so a token scoped\n * to Turnstile alone may be refused here: the message says to set the id instead.\n */\nexport async function resolveAccountId(\n token: string,\n explicit: string | undefined,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; accountId?: string; message?: string }> {\n if (explicit) return { ok: true, accountId: explicit };\n const res = await fetchImpl(`${API}/accounts?per_page=5`, {\n headers: { authorization: `Bearer ${token}` },\n });\n const body = await json<{ id: string; name: string }[]>(res);\n if (!res.ok || !body.success)\n return {\n ok: false,\n message: `${cfError(res.status, body)} — set CLOUDFLARE_ACCOUNT_ID to skip the account lookup`,\n };\n const accounts = body.result ?? [];\n if (accounts.length === 1) return { ok: true, accountId: accounts[0]!.id };\n return {\n ok: false,\n message:\n accounts.length === 0\n ? 'the token can see no accounts — set CLOUDFLARE_ACCOUNT_ID'\n : `the token can see ${accounts.length} accounts — set CLOUDFLARE_ACCOUNT_ID to choose one`,\n };\n}\n\n/** Every widget in the account, following pagination. */\nexport async function listWidgets(\n token: string,\n accountId: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; widgets: WidgetSummary[]; message?: string }> {\n const widgets: WidgetSummary[] = [];\n for (let page = 1; page <= 20; page++) {\n const res = await fetchImpl(\n `${API}/accounts/${encodeURIComponent(accountId)}/challenges/widgets?page=${page}&per_page=100`,\n { headers: { authorization: `Bearer ${token}` } },\n );\n const body = await json<WidgetSummary[]>(res);\n if (!res.ok || !body.success)\n return {\n ok: false,\n widgets: [],\n message: cfError(res.status, body) + (res.status === 403 ? PERMISSION_HINT : ''),\n };\n for (const w of body.result ?? [])\n widgets.push({ sitekey: w.sitekey, name: w.name, domains: w.domains ?? [], mode: w.mode });\n const info = body.result_info;\n if (!info || page * info.per_page >= info.total_count) break;\n }\n return { ok: true, widgets };\n}\n\n/**\n * Create a managed widget. **Create only** — the planner decides whether one is needed. The\n * returned `secret` is the only time Cloudflare shows it; the caller must store it as a Worker\n * secret and must not write it to a config file.\n */\nexport async function createWidget(\n token: string,\n accountId: string,\n widget: { name: string; domains: string[] },\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; sitekey?: string; secret?: string; message?: string }> {\n const res = await fetchImpl(\n `${API}/accounts/${encodeURIComponent(accountId)}/challenges/widgets`,\n {\n method: 'POST',\n headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },\n body: JSON.stringify({ name: widget.name, domains: widget.domains, mode: 'managed' }),\n },\n );\n const body = await json<{ sitekey: string; secret: string }>(res);\n if (!res.ok || !body.success || !body.result?.sitekey || !body.result.secret)\n return {\n ok: false,\n message: cfError(res.status, body) + (res.status === 403 ? PERMISSION_HINT : ''),\n };\n return { ok: true, sitekey: body.result.sitekey, secret: body.result.secret };\n}\n","/**\n * `vitops setup turnstile` — create a Cloudflare Turnstile widget and record its public site\n * key in `site.json`.\n *\n * A separate subpath (`@getvitops/utils/turnstile`), matching `./ads` and `./hosting`. It is\n * build-time tooling, unlike `@getvitops/utils/security` (the Worker-side verifier): keep the\n * two apart so a conversion route never pulls this in.\n *\n * The layering is the repo's usual one: `plan.ts` decides everything and touches nothing;\n * `cloudflare.ts` executes and decides nothing.\n */\nexport { MAX_DOMAINS, formatPlan, formatSummary, hasDrift, plan } from './plan.ts';\nexport type { StepAction, StepPlan, StepStatus, TurnstilePlan, TurnstileResult } from './plan.ts';\nexport { createWidget, listWidgets, resolveAccountId } from './cloudflare.ts';\nexport type { TurnstileSetup, TurnstileState, WidgetSummary } from './types.ts';\n\n/**\n * Hostnames a widget should cover, from the site config: the canonical domain and every\n * environment's URL. Cloudflare matches subdomains too, so `example.com` already covers\n * `dev.example.com`. Deduplicated, lowercase, no scheme or port. `localhost` is never added:\n * Cloudflare publishes always-pass test keys for local development.\n */\nexport function deriveDomains(site: {\n domains?: { canonical?: string | undefined } | undefined;\n environments?: Record<string, { url?: string | undefined } | undefined> | undefined;\n}): string[] {\n const urls = [\n site.domains?.canonical,\n ...Object.values(site.environments ?? {}).map((e) => e?.url),\n ];\n const hosts = new Set<string>();\n for (const url of urls) {\n if (!url) continue;\n try {\n const host = new URL(url).hostname.toLowerCase();\n if (host && host !== 'localhost') hosts.add(host);\n } catch {\n // a malformed URL is the config validator's business, not a reason to create a widget for it\n }\n }\n // Drop hostnames already covered by a parent in the set (dev.example.com under example.com).\n return [...hosts].filter((h) => ![...hosts].some((p) => p !== h && h.endsWith(`.${p}`)));\n}\n\n/**\n * Merge the site key into a **raw, on-disk** config object — the rule `ads setup`'s\n * `patchRaw` follows: never write from the resolved object, so `extends` and shorthand\n * shapes are not normalised into a copy nothing builds from. Everything outside\n * `site.security.turnstile.siteKey` is left exactly as it was.\n */\nexport function patchSiteKey(raw: unknown, siteKey: string): unknown {\n if (typeof raw !== 'object' || raw == null || Array.isArray(raw))\n throw new Error('config is not an object');\n const root = raw as Record<string, unknown>;\n const site = (root['site'] ?? {}) as Record<string, unknown>;\n const security = (site['security'] ?? {}) as Record<string, unknown>;\n const turnstile = (security['turnstile'] ?? {}) as Record<string, unknown>;\n return {\n ...root,\n site: { ...site, security: { ...security, turnstile: { ...turnstile, siteKey } } },\n };\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getvitops/utils",
3
- "version": "8.2.0",
3
+ "version": "8.3.0-alpha.1",
4
4
  "description": "Shared build-time utilities for the Vitops toolchain — favicon generation and friends.",
5
5
  "homepage": "https://docs.vitops.ca",
6
6
  "bugs": "https://github.com/getvitops/design-system/issues",
@@ -50,10 +50,22 @@
50
50
  "types": "./dist/reviews/index.d.mts",
51
51
  "import": "./dist/reviews/index.mjs"
52
52
  },
53
+ "./turnstile": {
54
+ "types": "./dist/turnstile/index.d.mts",
55
+ "import": "./dist/turnstile/index.mjs"
56
+ },
57
+ "./reviews/select": {
58
+ "types": "./dist/reviews/select.d.mts",
59
+ "import": "./dist/reviews/select.mjs"
60
+ },
53
61
  "./ads": {
54
62
  "types": "./dist/ads/index.d.mts",
55
63
  "import": "./dist/ads/index.mjs"
56
64
  },
65
+ "./audit": {
66
+ "types": "./dist/audit/index.d.mts",
67
+ "import": "./dist/audit/index.mjs"
68
+ },
57
69
  "./media": {
58
70
  "types": "./dist/media/index.d.mts",
59
71
  "import": "./dist/media/index.mjs"
@@ -95,13 +107,33 @@
95
107
  "access": "public"
96
108
  },
97
109
  "dependencies": {
98
- "@getvitops/config": "8.2.0",
110
+ "@getvitops/config": "8.3.0-alpha.1",
99
111
  "colorjs.io": "^0.5.2",
100
112
  "parse5": "^7.3.0",
101
113
  "png-to-ico": "^3.0.2",
102
114
  "sharp": "^0.35.3"
103
115
  },
104
- "devDependencies": {},
116
+ "devDependencies": {
117
+ "@axe-core/playwright": "^4.10.0",
118
+ "lighthouse": "^13.0.0",
119
+ "playwright": "^1.62.1"
120
+ },
121
+ "peerDependencies": {
122
+ "@axe-core/playwright": ">=4.10.0",
123
+ "lighthouse": ">=13.0.0",
124
+ "playwright": ">=1.50.0"
125
+ },
126
+ "peerDependenciesMeta": {
127
+ "@axe-core/playwright": {
128
+ "optional": true
129
+ },
130
+ "lighthouse": {
131
+ "optional": true
132
+ },
133
+ "playwright": {
134
+ "optional": true
135
+ }
136
+ },
105
137
  "engines": {
106
138
  "node": ">=24"
107
139
  }