create-safest-tools 0.2.0 → 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
|
@@ -21,6 +21,10 @@ npx create-safest-tools safest-resolve \
|
|
|
21
21
|
|
|
22
22
|
The `--email-from` address must belong to a domain onboarded under Cloudflare Email Service → Email Sending. Domain onboarding permits delivery to arbitrary invited users; invitations, email verification, and forgot-password delivery use this address.
|
|
23
23
|
|
|
24
|
+
### Where to find the Cloudflare Access AUD
|
|
25
|
+
|
|
26
|
+
In the Cloudflare dashboard, 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 or create a policy named **Safest infrastructure owners** with **Action: Allow** and an **Include → Emails** rule containing only the infrastructure-owner email supplied to `--admin-email`. Do not use **Everyone**. Create the application, select **Configure → Additional settings**, and copy the 64-character **Application Audience (AUD) Tag** into `--access-aud`. The tag stays the same unless the Access application is deleted or recreated. See Cloudflare's [application-path](https://developers.cloudflare.com/cloudflare-one/access-controls/policies/app-paths/) 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.
|
|
27
|
+
|
|
24
28
|
The command creates a local project and prints a read-only infrastructure plan. It does not change Cloudflare unless `--deploy` is supplied or the generated project’s `npm run setup` command is run and explicitly confirmed.
|
|
25
29
|
|
|
26
30
|
The generated installation owns:
|
|
@@ -46,7 +50,7 @@ npm run setup:plan
|
|
|
46
50
|
npm run setup
|
|
47
51
|
```
|
|
48
52
|
|
|
49
|
-
`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
|
|
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.
|
|
50
54
|
|
|
51
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.
|
|
52
56
|
|
package/package.json
CHANGED
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,7 +7,25 @@ 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");
|
|
12
|
+
const accessAudienceDocumentation = "https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/#get-your-aud-tag";
|
|
13
|
+
|
|
14
|
+
export function accessAudienceGuidance(publicBaseUrl = "", adminEmails = []) {
|
|
15
|
+
let reportsHost = "<reports-host>";
|
|
16
|
+
try { reportsHost = new URL(publicBaseUrl).host || reportsHost; } catch {}
|
|
17
|
+
const ownerEmail = adminEmails[0] || "<infrastructure-owner-email>";
|
|
18
|
+
return `Before entering the Cloudflare Access application AUD:
|
|
19
|
+
1. In Cloudflare, go to Zero Trust > Access controls > Applications.
|
|
20
|
+
2. Create a Self-hosted and private application named Safest Resolve infrastructure.
|
|
21
|
+
3. Add public hostname ${reportsHost} with Path v1/infrastructure/* only.
|
|
22
|
+
4. Add a policy named Safest infrastructure owners: Action Allow; Include Emails; Value ${ownerEmail}.
|
|
23
|
+
Do not select Everyone and do not protect the entire reporting hostname.
|
|
24
|
+
5. Create the application, then select Configure > Additional settings.
|
|
25
|
+
6. Copy the 64-character Application Audience (AUD) Tag.
|
|
26
|
+
The tag stays the same unless you delete or recreate the Access application.
|
|
27
|
+
Cloudflare guide: ${accessAudienceDocumentation}`;
|
|
28
|
+
}
|
|
10
29
|
|
|
11
30
|
function usage() {
|
|
12
31
|
return `Create customer-owned abuse-reporting infrastructure on Cloudflare
|
|
@@ -32,6 +51,8 @@ Options:
|
|
|
32
51
|
--help Show this help
|
|
33
52
|
--version Show the package version
|
|
34
53
|
|
|
54
|
+
${accessAudienceGuidance()}
|
|
55
|
+
|
|
35
56
|
This product does not proxy or inspect application requests. Applications submit
|
|
36
57
|
reports explicitly through the widget, public form, or server API.
|
|
37
58
|
`;
|
|
@@ -88,12 +109,15 @@ async function ask(input, prompt, fallback = "") {
|
|
|
88
109
|
return answer || fallback;
|
|
89
110
|
}
|
|
90
111
|
|
|
91
|
-
async function completeInteractive(options, input) {
|
|
112
|
+
async function completeInteractive(options, input, output) {
|
|
92
113
|
options.installationName ||= await ask(input, "Installation name", "safest-resolve");
|
|
93
114
|
options.publicBaseUrl ||= await ask(input, "Public reports origin", "https://reports.example.com");
|
|
94
115
|
if (!options.allowedOrigins.length) options.allowedOrigins.push(await ask(input, "Application origin allowed to embed the report form", "https://app.example.com"));
|
|
95
116
|
if (!options.adminEmails.length) options.adminEmails.push(await ask(input, "Infrastructure owner email"));
|
|
96
|
-
options.accessAudience
|
|
117
|
+
if (!options.accessAudience) {
|
|
118
|
+
output.log(`\n${accessAudienceGuidance(options.publicBaseUrl, options.adminEmails)}\n`);
|
|
119
|
+
options.accessAudience = await ask(input, "Cloudflare Access application AUD (press Enter to use a deployment-blocking placeholder)", "replace-with-the-cloudflare-access-application-aud");
|
|
120
|
+
}
|
|
97
121
|
options.emailFromAddress ||= await ask(input, "Sender address on a Cloudflare Email Sending domain");
|
|
98
122
|
return options;
|
|
99
123
|
}
|
|
@@ -102,11 +126,11 @@ export async function runCli(argv, dependencies = {}) {
|
|
|
102
126
|
const output = dependencies.output ?? console;
|
|
103
127
|
const options = parseArguments(argv);
|
|
104
128
|
if (options.help) { output.log(usage()); return { status: "help" }; }
|
|
105
|
-
if (options.version) { output.log(
|
|
129
|
+
if (options.version) { output.log(packageVersion); return { status: "version" }; }
|
|
106
130
|
const interactive = dependencies.interactive ?? (process.stdin.isTTY && !options.yes);
|
|
107
131
|
if (interactive) {
|
|
108
132
|
const input = createInterface({ input: process.stdin, output: process.stdout });
|
|
109
|
-
try { await completeInteractive(options, input); } finally { input.close(); }
|
|
133
|
+
try { await completeInteractive(options, input, output); } finally { input.close(); }
|
|
110
134
|
}
|
|
111
135
|
const configuration = buildConfiguration(options);
|
|
112
136
|
const templateDirectory = dependencies.templateDirectory ?? defaultTemplateDirectory;
|
package/template/README.md
CHANGED
|
@@ -9,9 +9,9 @@ This project deploys one Worker, one D1 database, four private R2 buckets, a Dyn
|
|
|
9
9
|
Before deployment:
|
|
10
10
|
|
|
11
11
|
1. Review `reports.config.json` and `npm run setup:plan`.
|
|
12
|
-
2.
|
|
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
|
|
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
|
|
package/template/package.json
CHANGED
|
@@ -3,11 +3,15 @@ import { spawn } from "node:child_process";
|
|
|
3
3
|
import { readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
5
|
|
|
6
|
-
function
|
|
6
|
+
export function wranglerEnvironment(logLevel = "none", env = process.env) {
|
|
7
|
+
return { ...env, WRANGLER_LOG: logLevel, WRANGLER_WRITE_LOGS: "false" };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function runProcess(program, args, { cwd, inherit = false, wranglerLog = "none" } = {}) {
|
|
7
11
|
return new Promise((resolvePromise, reject) => {
|
|
8
12
|
const child = spawn(program, args, {
|
|
9
13
|
cwd,
|
|
10
|
-
env:
|
|
14
|
+
env: wranglerEnvironment(wranglerLog),
|
|
11
15
|
stdio: inherit ? "inherit" : ["ignore", "pipe", "pipe"],
|
|
12
16
|
});
|
|
13
17
|
let stdout = "";
|
|
@@ -51,10 +55,33 @@ async function fetchSubscriptions(accountId, token, fetchImpl) {
|
|
|
51
55
|
return { ok: true, subscriptions: body.result };
|
|
52
56
|
}
|
|
53
57
|
|
|
54
|
-
async function
|
|
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) {
|
|
55
82
|
ui.note(
|
|
56
|
-
`Wrangler is authenticated, but
|
|
57
|
-
`
|
|
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` +
|
|
58
85
|
`1. Open https://dash.cloudflare.com/profile/api-tokens\n` +
|
|
59
86
|
`2. Create a custom token.\n` +
|
|
60
87
|
`3. Add Account → Billing → Read.\n` +
|
|
@@ -77,16 +104,23 @@ async function billingTokenFromUser(account, ui) {
|
|
|
77
104
|
return clack.isCancel(token) ? null : token.trim();
|
|
78
105
|
}
|
|
79
106
|
|
|
80
|
-
export async function verifyWorkersPaid({ account, wranglerToken, interactive, fetchImpl = fetch, ui = clack }) {
|
|
81
|
-
|
|
82
|
-
if (
|
|
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) {
|
|
83
116
|
if (!interactive) {
|
|
84
|
-
throw new Error(
|
|
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.`);
|
|
85
118
|
}
|
|
86
|
-
|
|
119
|
+
billingToken = await billingTokenFromUser(account, detail, ui);
|
|
87
120
|
if (!billingToken) throw new Error("Setup cancelled before provisioning: Workers Paid was not verified.");
|
|
88
|
-
verification = await fetchSubscriptions(account.id, billingToken, fetchImpl);
|
|
89
121
|
}
|
|
122
|
+
|
|
123
|
+
const verification = await fetchSubscriptions(account.id, billingToken, fetchImpl);
|
|
90
124
|
if (!verification.ok) {
|
|
91
125
|
throw new Error(`Workers Paid could not be verified for “${account.name}” (Cloudflare API ${verification.status}). No resources were provisioned.`);
|
|
92
126
|
}
|
|
@@ -94,7 +128,11 @@ export async function verifyWorkersPaid({ account, wranglerToken, interactive, f
|
|
|
94
128
|
if (!subscription) {
|
|
95
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.`);
|
|
96
130
|
}
|
|
97
|
-
return
|
|
131
|
+
return {
|
|
132
|
+
source: "billing-subscription",
|
|
133
|
+
label: subscription.rate_plan?.public_name ?? "active Workers subscription",
|
|
134
|
+
subscription,
|
|
135
|
+
};
|
|
98
136
|
}
|
|
99
137
|
|
|
100
138
|
export async function ensureWranglerAuthentication({ wrangler, projectRoot, interactive, ui = clack, runner = runProcess }) {
|
|
@@ -113,7 +151,10 @@ export async function ensureWranglerAuthentication({ wrangler, projectRoot, inte
|
|
|
113
151
|
if (identity.loggedIn !== true || !Array.isArray(identity.accounts) || !identity.accounts.length) {
|
|
114
152
|
throw new Error("Wrangler is authenticated but no accessible Cloudflare account was found.");
|
|
115
153
|
}
|
|
116
|
-
|
|
154
|
+
// Wrangler emits this JSON through its logger. `WRANGLER_LOG=none` suppresses
|
|
155
|
+
// the response in Wrangler 4.127+, so allow log output only for this captured
|
|
156
|
+
// command. Disable Wrangler's disk logs so the OAuth token stays in memory.
|
|
157
|
+
const tokenResult = await runner(process.execPath, [wrangler, "auth", "token", "--json"], { cwd: projectRoot, wranglerLog: "log" });
|
|
117
158
|
const token = jsonOutput(tokenResult, "Wrangler token lookup");
|
|
118
159
|
if (typeof token.token !== "string" || !token.token) throw new Error("Wrangler did not provide an API token for preflight checks.");
|
|
119
160
|
return { identity, token: token.token };
|
|
@@ -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
|
|
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} (${
|
|
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");
|