create-safest-tools 0.5.0 → 0.5.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.
package/README.md CHANGED
@@ -44,7 +44,7 @@ npm run setup:plan
44
44
  npm run setup
45
45
  ```
46
46
 
47
- `setup:plan` lists exact resource names and the reporter email route, then 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. After confirmation it verifies Email Routing, enables subaddressing, and deploys only the exact reporter-address route.
47
+ `setup:plan` lists exact resource names and the reporter email route, then 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. After confirmation it verifies Email Routing, enables subaddressing, and deploys only the exact reporter-address route. If Cloudflare interrupts the deployment, run `npm run setup` again; it detects the unfinished installation, reuses the saved authentication choices and exact resources, suppresses expected already-exists errors, and continues through the owner-link step.
48
48
 
49
49
  A new installation applies one current D1 schema baseline. Later releases add only forward-compatible upgrade migrations.
50
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-safest-tools",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Create customer-owned abuse-reporting infrastructure on Cloudflare",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -10,7 +10,7 @@ Before deployment:
10
10
 
11
11
  1. Review `reports.config.json` and `npm run setup:plan`.
12
12
  2. Under Cloudflare Email Service → Email Sending, onboard the configured sender domain. Enable Email Routing for that domain and use a real reporter mailbox such as `reports@example.com`; setup creates only that exact Worker route, enables plus-addressing for signed case replies, and never enables catch-all routing. Reporter updates and account mail use separate sender addresses and sender-restricted bindings.
13
- 3. 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.
13
+ 3. 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. If Cloudflare interrupts deployment, run the same command again: setup detects the unfinished installation, reuses the saved choices and exact resources, skips the repeated OAuth questions, and continues to the owner link.
14
14
  4. Type the exact installation confirmation when setup requests it. After deployment, setup prints a 256-bit, single-use owner link that expires after 15 minutes and is never written to disk.
15
15
  5. Open the owner link, create the Safest owner account, then invite administrators and analysts from People. Nobody needs a Cloudflare account to sign in. Names and workspace roles are always displayed separately.
16
16
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "safest-resolve-installation",
3
- "version": "0.5.0",
4
- "safestToolsVersion": "0.5.0-resolve",
3
+ "version": "0.5.1",
4
+ "safestToolsVersion": "0.5.1-resolve",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
7
7
  "type": "module",
@@ -46,5 +46,9 @@
46
46
  },
47
47
  "engines": {
48
48
  "node": ">=20.12.0"
49
+ },
50
+ "allowScripts": {
51
+ "esbuild": true,
52
+ "workerd": true
49
53
  }
50
54
  }
@@ -37,36 +37,83 @@ function parseArguments(argv) {
37
37
  return result;
38
38
  }
39
39
 
40
- function runWrangler(args, label) {
40
+ function runWrangler(args, label, { stdin = "inherit" } = {}) {
41
41
  return new Promise((resolvePromise, reject) => {
42
42
  console.log(`\n${label}`);
43
- const child = spawn(process.execPath, [wrangler, ...args], { cwd: projectRoot, env: process.env, stdio: "inherit" });
43
+ const child = spawn(process.execPath, [wrangler, ...args], {
44
+ cwd: projectRoot,
45
+ env: process.env,
46
+ stdio: [stdin, "inherit", "inherit"],
47
+ });
44
48
  child.once("error", reject);
45
49
  child.once("exit", (code, signal) => code === 0 ? resolvePromise() : reject(new Error(`${label} failed${signal ? ` with signal ${signal}` : ` with exit code ${code}`}`)));
46
50
  });
47
51
  }
48
52
 
53
+ function captureWrangler(args, { stdin = "ignore" } = {}) {
54
+ return new Promise((resolvePromise, reject) => {
55
+ const child = spawn(process.execPath, [wrangler, ...args], {
56
+ cwd: projectRoot,
57
+ env: process.env,
58
+ stdio: [stdin, "pipe", "pipe"],
59
+ });
60
+ let stdout = "";
61
+ let stderr = "";
62
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
63
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
64
+ child.once("error", reject);
65
+ child.once("exit", (code, signal) => resolvePromise({ code, signal, stdout, stderr, output: `${stdout}\n${stderr}` }));
66
+ });
67
+ }
68
+
69
+ function printWranglerOutput({ stdout, stderr }) {
70
+ if (stdout) process.stdout.write(stdout);
71
+ if (stderr) process.stderr.write(stderr);
72
+ }
73
+
49
74
  export function isExistingResourceError(output) {
50
75
  return /already exists|already (?:been )?taken|duplicate resource/iu.test(output);
51
76
  }
52
77
 
53
- function ensureWranglerResource(args, label) {
54
- return new Promise((resolvePromise, reject) => {
55
- console.log(`\n${label}`);
56
- const child = spawn(process.execPath, [wrangler, ...args], { cwd: projectRoot, env: process.env, stdio: ["inherit", "pipe", "pipe"] });
57
- let output = "";
58
- child.stdout.on("data", (chunk) => { output += chunk; process.stdout.write(chunk); });
59
- child.stderr.on("data", (chunk) => { output += chunk; process.stderr.write(chunk); });
60
- child.once("error", reject);
61
- child.once("exit", (code, signal) => {
62
- if (code === 0) return resolvePromise();
63
- if (isExistingResourceError(output)) {
64
- console.log("Resource already exists; reusing the exact configured name.");
65
- return resolvePromise();
66
- }
67
- reject(new Error(`${label} failed${signal ? ` with signal ${signal}` : ` with exit code ${code}`}`));
68
- });
69
- });
78
+ export function isTransientWranglerAuthenticationError(output) {
79
+ return /authentication error[\s\S]{0,200}(?:code\s*:\s*10000|\[code:\s*10000\])/iu.test(output);
80
+ }
81
+
82
+ async function ensureWranglerResource(args, label) {
83
+ console.log(`\n${label}`);
84
+ const result = await captureWrangler(args);
85
+ if (result.code === 0) {
86
+ console.log("Created the exact configured resource.");
87
+ return;
88
+ }
89
+ if (isExistingResourceError(result.output)) {
90
+ console.log("Already exists; reusing the exact configured resource.");
91
+ return;
92
+ }
93
+ printWranglerOutput(result);
94
+ throw new Error(`${label} failed${result.signal ? ` with signal ${result.signal}` : ` with exit code ${result.code}`}`);
95
+ }
96
+
97
+ async function deployWithAuthenticationRetry(args, label) {
98
+ console.log(`\n${label}`);
99
+ let result = await captureWrangler(args, { stdin: "inherit" });
100
+ if (result.code === 0) {
101
+ printWranglerOutput(result);
102
+ return;
103
+ }
104
+ if (!isTransientWranglerAuthenticationError(result.output)) {
105
+ printWranglerOutput(result);
106
+ throw new Error(`${label} failed${result.signal ? ` with signal ${result.signal}` : ` with exit code ${result.code}`}`);
107
+ }
108
+
109
+ console.log("Cloudflare rejected the upload token. Refreshing Wrangler authentication and retrying once.");
110
+ await runWrangler(["whoami"], "Refresh Cloudflare authentication");
111
+ console.log(`\n${label} (automatic retry 1 of 1)`);
112
+ result = await captureWrangler(args, { stdin: "inherit" });
113
+ printWranglerOutput(result);
114
+ if (result.code !== 0) {
115
+ throw new Error(`${label} failed after one automatic authentication retry${result.signal ? ` with signal ${result.signal}` : ` with exit code ${result.code}`}`);
116
+ }
70
117
  }
71
118
 
72
119
  async function validateSecrets(path, config) {
@@ -88,6 +135,23 @@ async function hasDatabaseId() {
88
135
  return typeof config?.d1_databases?.[0]?.database_id === "string" && config.d1_databases[0].database_id.length > 0;
89
136
  }
90
137
 
138
+ async function fileExists(path) {
139
+ try {
140
+ await readFile(path);
141
+ return true;
142
+ } catch (error) {
143
+ if (error?.code === "ENOENT") return false;
144
+ throw error;
145
+ }
146
+ }
147
+
148
+ export function setupInstallationState({ databaseConfigured, installationRecorded }) {
149
+ if (installationRecorded && !databaseConfigured) return "inconsistent";
150
+ if (installationRecorded) return "installed";
151
+ if (databaseConfigured) return "resume";
152
+ return "fresh";
153
+ }
154
+
91
155
  async function confirmation(installationId, yes) {
92
156
  if (yes) return;
93
157
  if (!process.stdin.isTTY) throw new Error("interactive confirmation is required; use --yes only after reviewing setup:plan");
@@ -105,6 +169,20 @@ export async function deployReports(argv = process.argv.slice(2)) {
105
169
  console.log(JSON.stringify({ action: options.upgrade ? "upgrade" : "install", ...plan }, null, 2));
106
170
  if (options.planOnly) return { status: "planned", plan };
107
171
  if (!plan.ready) throw new Error(plan.blockers.join(" "));
172
+ const installationPath = resolve(projectRoot, ".safest/installation.json");
173
+ const databaseConfigured = await hasDatabaseId();
174
+ const installationRecorded = await fileExists(installationPath);
175
+ const setupState = setupInstallationState({ databaseConfigured, installationRecorded });
176
+ if (options.upgrade && !databaseConfigured) throw new Error("upgrade requires an existing D1 database_id in wrangler.jsonc");
177
+ if (!options.upgrade && setupState === "installed") {
178
+ throw new Error("setup is already complete; use npm run owner:setup for a new owner link or npm run upgrade for an existing installation");
179
+ }
180
+ if (!options.upgrade && setupState === "inconsistent") {
181
+ throw new Error("the installation receipt exists but wrangler.jsonc has no D1 database_id; restore the database configuration before continuing");
182
+ }
183
+ if (!options.upgrade && setupState === "resume") {
184
+ console.log("\nInterrupted installation detected. Reusing the saved authentication choices and exact Cloudflare resources, then continuing setup.");
185
+ }
108
186
  const interactive = process.stdin.isTTY && !options.yes;
109
187
  const authentication = await ensureWranglerAuthentication({ wrangler, projectRoot, interactive });
110
188
  const accountSelection = await chooseCloudflareAccount({
@@ -127,7 +205,7 @@ export async function deployReports(argv = process.argv.slice(2)) {
127
205
  await initializeSecrets(secretsPath);
128
206
  console.log(`Created ${secretsPath} with generated owner-only secrets.`);
129
207
  }
130
- if (!options.upgrade) {
208
+ if (!options.upgrade && setupState === "fresh") {
131
209
  const configured = await configureAuthentication({
132
210
  configPath: resolve(projectRoot, options.config),
133
211
  secretsPath,
@@ -144,10 +222,7 @@ export async function deployReports(argv = process.argv.slice(2)) {
144
222
  wranglerToken: authentication.token,
145
223
  });
146
224
  console.log(`\nReporter replies will route through ${reporterRouting.address}; plus-addressing is enabled and no catch-all is created.`);
147
- const databaseConfigured = await hasDatabaseId();
148
- if (options.upgrade && !databaseConfigured) throw new Error("upgrade requires an existing D1 database_id in wrangler.jsonc");
149
- if (!options.upgrade && databaseConfigured) throw new Error("this project already has a D1 database_id; use npm run upgrade instead of setup");
150
- if (!options.upgrade) {
225
+ if (!options.upgrade && setupState === "fresh") {
151
226
  await runWrangler(["d1", "create", config.resources.databaseName, "--binding", "DB", "--update-config"], "Create the customer-owned D1 database");
152
227
  }
153
228
  await ensureWranglerResource(["r2", "bucket", "create", config.resources.profileMediaBucketName], "Ensure the private profile-media R2 bucket exists");
@@ -157,9 +232,13 @@ export async function deployReports(argv = process.argv.slice(2)) {
157
232
  await ensureWranglerResource(["queues", "create", config.resources.reportQueueName], "Ensure the report jobs Queue exists");
158
233
  await ensureWranglerResource(["queues", "create", config.resources.deliveryQueueName], "Ensure the delivery jobs Queue exists");
159
234
  await ensureWranglerResource(["queues", "create", config.resources.operationsDlqName], "Ensure the operations dead-letter Queue exists");
160
- await runWrangler(["d1", "migrations", "apply", "DB", "--remote"], "Apply forward-only report migrations");
161
- await runWrangler(["deploy", "--secrets-file", secretsPath, "--strict"], "Deploy the reports Worker and bindings");
162
- const installationPath = resolve(projectRoot, ".safest/installation.json");
235
+ await runWrangler(
236
+ ["d1", "migrations", "apply", "DB", "--remote"],
237
+ "Apply forward-only report migrations",
238
+ { stdin: "ignore" },
239
+ );
240
+ await deployWithAuthenticationRetry(["deploy", "--secrets-file", secretsPath, "--strict"], "Deploy the reports Worker and bindings");
241
+ const ownerSetup = options.upgrade ? null : await issueOwnerSetup({ config });
163
242
  await mkdir(dirname(installationPath), { recursive: true, mode: 0o700 });
164
243
  await writeFile(installationPath, `${JSON.stringify({
165
244
  schemaVersion: 2,
@@ -168,9 +247,9 @@ export async function deployReports(argv = process.argv.slice(2)) {
168
247
  resources: config.resources,
169
248
  ownerEmail: config.owner.email,
170
249
  }, null, 2)}\n`, { mode: 0o600 });
171
- if (!options.upgrade) printOwnerSetup(await issueOwnerSetup({ config }));
250
+ if (ownerSetup) printOwnerSetup(ownerSetup);
172
251
  console.log("\nDeployment finished. Open the one-time owner setup link, run the authenticated setup feature checks until /ready succeeds, invite an administrator and analyst, then verify one server report and Queue consumption before production traffic.");
173
- return { status: "deployed", plan };
252
+ return { status: "deployed", plan, resumed: !options.upgrade && setupState === "resume" };
174
253
  }
175
254
 
176
255
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {