@getvitops/utils 8.2.0 → 8.3.0-alpha.0

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.
@@ -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.0",
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.0",
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
  }