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 +1 -1
- package/package.json +1 -1
- package/template/README.md +1 -1
- package/template/package.json +6 -2
- package/template/scripts/reports-deploy.mjs +108 -29
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
package/template/README.md
CHANGED
|
@@ -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
|
|
package/template/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safest-resolve-installation",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"safestToolsVersion": "0.5.
|
|
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], {
|
|
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
|
|
54
|
-
return
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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(
|
|
161
|
-
|
|
162
|
-
|
|
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 (
|
|
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)) {
|