create-safest-tools 0.2.1 → 0.2.2

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.
package/README.md CHANGED
@@ -50,7 +50,7 @@ npm run setup:plan
50
50
  npm run setup
51
51
  ```
52
52
 
53
- `setup:plan` lists exact resource names and exits without modifying Cloudflare. `setup` opens Wrangler's Cloudflare login when needed, lets you choose the owning account, and fails closed unless it can verify an active Workers Paid subscription. It then guides Google, GitHub, and Cloudflare OAuth configuration with exact callback URLs and masked secret input. Nothing is provisioned until the exact `DEPLOY <installation-id>` confirmation.
53
+ `setup:plan` lists exact resource names and exits without modifying Cloudflare. `setup` opens Wrangler's Cloudflare login when needed, lets you choose the owning account, and verifies Workers Paid from the account's Workers usage model. Standard accounts need no separate billing token; only legacy or ambiguous account models use the temporary Billing Read fallback. It then guides Google, GitHub, and Cloudflare OAuth configuration with exact callback URLs and masked secret input. Nothing is provisioned until the exact `DEPLOY <installation-id>` confirmation.
54
54
 
55
55
  Create a Cloudflare Access self-hosted application for `<reports-host>/v1/infrastructure/*` first and copy its 64-character audience into `reports.config.json`. The allowlisted infrastructure engineer uses it to bootstrap the Safest owner account. The owner then invites administrators, and administrators invite analysts. Those invited users sign in with Safest accounts and do not need Cloudflare accounts.
56
56
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-safest-tools",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Create customer-owned abuse-reporting infrastructure on Cloudflare",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/src/cli.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
2
3
  import { basename, resolve } from "node:path";
3
4
  import { createInterface } from "node:readline/promises";
4
5
  import { fileURLToPath } from "node:url";
@@ -6,6 +7,7 @@ import { buildConfiguration } from "./config.mjs";
6
7
  import { scaffoldProject, validateGeneratedConfiguration } from "./scaffold.mjs";
7
8
 
8
9
  const packageRoot = fileURLToPath(new URL("..", import.meta.url));
10
+ const packageVersion = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8")).version;
9
11
  const defaultTemplateDirectory = resolve(packageRoot, "template");
10
12
  const accessAudienceDocumentation = "https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/#get-your-aud-tag";
11
13
 
@@ -124,7 +126,7 @@ export async function runCli(argv, dependencies = {}) {
124
126
  const output = dependencies.output ?? console;
125
127
  const options = parseArguments(argv);
126
128
  if (options.help) { output.log(usage()); return { status: "help" }; }
127
- if (options.version) { output.log("0.2.0"); return { status: "version" }; }
129
+ if (options.version) { output.log(packageVersion); return { status: "version" }; }
128
130
  const interactive = dependencies.interactive ?? (process.stdin.isTTY && !options.yes);
129
131
  if (interactive) {
130
132
  const input = createInterface({ input: process.stdin, output: process.stdout });
@@ -11,7 +11,7 @@ Before deployment:
11
11
  1. Review `reports.config.json` and `npm run setup:plan`.
12
12
  2. In Cloudflare, go to **Zero Trust → Access controls → Applications** and create a **Self-hosted and private** application named **Safest Resolve infrastructure**. Add the reports hostname with **Path** `v1/infrastructure/*` only; do not protect the entire hostname. Add a reusable policy named **Safest infrastructure owners** with **Action: Allow** and an **Include → Emails** rule containing only the email addresses in `access.ownerEmails`; do not use **Everyone**. Create the application, select **Configure → Additional settings**, and copy the 64-character **Application Audience (AUD) Tag** into `access.audience`. The tag stays stable unless the Access application is deleted or recreated. See Cloudflare's [application-path](https://developers.cloudflare.com/cloudflare-one/access-controls/policies/app-paths/), [policy](https://developers.cloudflare.com/cloudflare-one/access-controls/policies/policy-management/), and [Get your AUD tag](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/#get-your-aud-tag) instructions.
13
13
  3. Under Cloudflare Email Service → Email Sending, onboard the configured sender domain. Password verification, invitations, and password recovery to arbitrary recipients depend on domain onboarding.
14
- 4. Run `npm run setup`. The guided setup signs in through Wrangler, verifies Workers Paid before provisioning, creates owner-only local secrets, and walks through optional Google, GitHub, and Cloudflare OAuth credentials with exact callback URLs.
14
+ 4. Run `npm run setup`. The guided setup signs in through Wrangler and verifies Workers Paid from the account's Workers usage model before provisioning. Standard accounts need no separate billing token; only legacy or ambiguous models use the temporary Billing Read fallback. Setup then creates owner-only local secrets and walks through optional Google, GitHub, and Cloudflare OAuth credentials with exact callback URLs.
15
15
  5. Bootstrap the infrastructure owner, then invite administrators and analysts from People. Invited users do not need Cloudflare accounts and can upload a JPEG, PNG, or WebP profile picture up to 2 MB. Names and workspace roles are always displayed separately.
16
16
  6. Type the exact installation confirmation when setup requests it.
17
17
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "safest-resolve-installation",
3
- "version": "0.2.0",
4
- "safestToolsVersion": "0.2.0-resolve",
3
+ "version": "0.2.2",
4
+ "safestToolsVersion": "0.2.2-resolve",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
7
7
  "type": "module",
@@ -55,10 +55,33 @@ async function fetchSubscriptions(accountId, token, fetchImpl) {
55
55
  return { ok: true, subscriptions: body.result };
56
56
  }
57
57
 
58
- async function billingTokenFromUser(account, ui) {
58
+ async function fetchWorkersAccountSettings(accountId, token, fetchImpl) {
59
+ const response = await fetchImpl(`https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/workers/account-settings`, {
60
+ headers: { authorization: `Bearer ${token}`, accept: "application/json" },
61
+ });
62
+ const body = await response.json().catch(() => null);
63
+ if (!response.ok || body?.success !== true || !body.result || typeof body.result !== "object") {
64
+ return { ok: false, status: response.status, body };
65
+ }
66
+ return { ok: true, settings: body.result };
67
+ }
68
+
69
+ export function isWorkersPaidUsageModel(value) {
70
+ return String(value ?? "").trim().toLowerCase() === "standard";
71
+ }
72
+
73
+ function accountSettingsFailureDetail(verification) {
74
+ if (!verification.ok) return `the Workers account-settings request returned Cloudflare API ${verification.status}`;
75
+ const usageModel = String(verification.settings.default_usage_model ?? "").trim();
76
+ return usageModel
77
+ ? `Cloudflare reported the legacy or ambiguous usage model “${usageModel}”`
78
+ : "Cloudflare did not report a Workers usage model";
79
+ }
80
+
81
+ async function billingTokenFromUser(account, detail, ui) {
59
82
  ui.note(
60
- `Wrangler is authenticated, but its OAuth grant cannot read billing subscriptions.\n\n` +
61
- `Create a temporary read-only token for account “${account.name}”:\n` +
83
+ `Wrangler is authenticated, but ${detail}.\n\n` +
84
+ `Standard Workers Paid accounts need no additional token. For this legacy or ambiguous account, create a temporary read-only token for “${account.name}”:\n` +
62
85
  `1. Open https://dash.cloudflare.com/profile/api-tokens\n` +
63
86
  `2. Create a custom token.\n` +
64
87
  `3. Add Account → Billing → Read.\n` +
@@ -81,16 +104,23 @@ async function billingTokenFromUser(account, ui) {
81
104
  return clack.isCancel(token) ? null : token.trim();
82
105
  }
83
106
 
84
- export async function verifyWorkersPaid({ account, wranglerToken, interactive, fetchImpl = fetch, ui = clack }) {
85
- let verification = await fetchSubscriptions(account.id, process.env.CLOUDFLARE_BILLING_API_TOKEN || wranglerToken, fetchImpl);
86
- if (!verification.ok && (verification.status === 401 || verification.status === 403) && !process.env.CLOUDFLARE_BILLING_API_TOKEN) {
107
+ export async function verifyWorkersPaid({ account, wranglerToken, interactive, fetchImpl = fetch, ui = clack, env = process.env }) {
108
+ const accountSettings = await fetchWorkersAccountSettings(account.id, wranglerToken, fetchImpl);
109
+ if (accountSettings.ok && isWorkersPaidUsageModel(accountSettings.settings.default_usage_model)) {
110
+ return { source: "workers-account-settings", label: "Workers Standard", usageModel: "standard" };
111
+ }
112
+
113
+ const detail = accountSettingsFailureDetail(accountSettings);
114
+ let billingToken = env.CLOUDFLARE_BILLING_API_TOKEN;
115
+ if (!billingToken) {
87
116
  if (!interactive) {
88
- throw new Error("Workers Paid could not be verified. Set CLOUDFLARE_BILLING_API_TOKEN to a temporary Account Billing Read token and run setup again.");
117
+ throw new Error(`Workers Paid could not be verified because ${detail}. Set CLOUDFLARE_BILLING_API_TOKEN to a temporary Account Billing Read token and run setup again.`);
89
118
  }
90
- const billingToken = await billingTokenFromUser(account, ui);
119
+ billingToken = await billingTokenFromUser(account, detail, ui);
91
120
  if (!billingToken) throw new Error("Setup cancelled before provisioning: Workers Paid was not verified.");
92
- verification = await fetchSubscriptions(account.id, billingToken, fetchImpl);
93
121
  }
122
+
123
+ const verification = await fetchSubscriptions(account.id, billingToken, fetchImpl);
94
124
  if (!verification.ok) {
95
125
  throw new Error(`Workers Paid could not be verified for “${account.name}” (Cloudflare API ${verification.status}). No resources were provisioned.`);
96
126
  }
@@ -98,7 +128,11 @@ export async function verifyWorkersPaid({ account, wranglerToken, interactive, f
98
128
  if (!subscription) {
99
129
  throw new Error(`“${account.name}” does not have an active Workers Paid subscription. Upgrade it in Cloudflare, then run setup again. No resources were provisioned.`);
100
130
  }
101
- return subscription;
131
+ return {
132
+ source: "billing-subscription",
133
+ label: subscription.rate_plan?.public_name ?? "active Workers subscription",
134
+ subscription,
135
+ };
102
136
  }
103
137
 
104
138
  export async function ensureWranglerAuthentication({ wrangler, projectRoot, interactive, ui = clack, runner = runProcess }) {
@@ -110,13 +110,13 @@ export async function deployReports(argv = process.argv.slice(2)) {
110
110
  wranglerConfigPath: resolve(projectRoot, "wrangler.jsonc"),
111
111
  interactive,
112
112
  });
113
- const subscription = await verifyWorkersPaid({
113
+ const workersPlan = await verifyWorkersPaid({
114
114
  account: accountSelection.account,
115
115
  wranglerToken: authentication.token,
116
116
  interactive,
117
117
  });
118
118
  await saveCloudflareAccountSelection(accountSelection);
119
- console.log(`\nWorkers Paid verified for ${accountSelection.account.name} (${subscription.rate_plan?.public_name ?? "active Workers subscription"}).`);
119
+ console.log(`\nWorkers Paid verified for ${accountSelection.account.name} (${workersPlan.label}).`);
120
120
  const secretsPath = resolve(projectRoot, options.secrets);
121
121
  try {
122
122
  await readFile(secretsPath, "utf8");