create-safest-tools 0.3.1 → 0.4.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 +8 -4
- package/package.json +1 -1
- package/src/cli.mjs +52 -7
- package/src/config.mjs +26 -5
- package/src/scaffold.mjs +4 -1
- package/src/update.mjs +295 -0
- package/template/README.md +7 -5
- package/template/console/reports/ReportDrawer.tsx +51 -6
- package/template/console/reports/controller.ts +11 -5
- package/template/console/reports/store.ts +1 -1
- package/template/console/reports/types.ts +23 -0
- package/template/console/shell/WorkspaceShell.tsx +0 -4
- package/template/migrations/0001_safest_resolve_baseline.sql +2446 -0
- package/template/package.json +4 -3
- package/template/public/console/auth-shell.js +4333 -4243
- package/template/public/styles.css +19 -17
- package/template/reports.config.example.json +2 -1
- package/template/reports.schema.json +2 -1
- package/template/scripts/reports-auth-onboarding.mjs +6 -5
- package/template/scripts/reports-plan.mjs +14 -5
- package/template/src/index.ts +12 -3
- package/template/src/installation-admin.ts +2 -1
- package/template/src/report-ai.ts +30 -5
- package/template/src/report-better-auth.ts +5 -14
- package/template/src/report-delivery.ts +4 -3
- package/template/src/report-email.ts +106 -0
- package/template/src/report-workflow.ts +103 -21
- package/template/src/workflow-effects.ts +4 -3
- package/template/worker-configuration.d.ts +5 -3
- package/template/migrations/0001_reports_foundation.sql +0 -444
- package/template/migrations/0002_human_report_loop.sql +0 -65
- package/template/migrations/0003_delivery_reliability.sql +0 -18
- package/template/migrations/0004_public_intake.sql +0 -14
- package/template/migrations/0005_operations_visibility.sql +0 -24
- package/template/migrations/0006_ai_governance.sql +0 -198
- package/template/migrations/0007_ai_release_gates.sql +0 -6
- package/template/migrations/0008_retention_analytics_exports.sql +0 -52
- package/template/migrations/0009_retention_derived_copies.sql +0 -14
- package/template/migrations/0010_ai_quality_controls.sql +0 -26
- package/template/migrations/0011_analyst_presence.sql +0 -28
- package/template/migrations/0012_queue_policies.sql +0 -87
- package/template/migrations/0013_routing_agents.sql +0 -63
- package/template/migrations/0014_webhook_enrichments.sql +0 -122
- package/template/migrations/0015_queue_owned_ai.sql +0 -55
- package/template/migrations/0016_operator_accounts.sql +0 -67
- package/template/migrations/0017_operator_profiles_and_recovery.sql +0 -33
- package/template/migrations/0018_platform_configuration.sql +0 -382
- package/template/migrations/0019_workflow_authoring_runtime.sql +0 -372
- package/template/migrations/0020_tasks_findings_assistant_budgets.sql +0 -427
- package/template/migrations/0021_abuse_evidence_operations.sql +0 -324
- package/template/migrations/0022_workflow_dispatch_operations.sql +0 -42
- package/template/migrations/0023_component_connection_execution.sql +0 -75
- package/template/migrations/0024_access_runtime_integrity.sql +0 -72
- package/template/migrations/0025_installation_timezone.sql +0 -11
- package/template/migrations/0026_ai_and_egress_execution_controls.sql +0 -49
- package/template/migrations/0027_prompt_and_ai_registry.sql +0 -37
- package/template/migrations/0028_step_attempt_ai_provenance.sql +0 -13
- package/template/migrations/0029_evidence_fetch_transport.sql +0 -5
- package/template/migrations/0030_evidence_dlq_incidents.sql +0 -45
- package/template/migrations/0031_shadow_quality_integrity.sql +0 -7
- package/template/migrations/0032_action_delivery_outbox.sql +0 -55
- package/template/migrations/0033_configuration_and_assistant_drafts.sql +0 -43
- package/template/migrations/0034_installation_integrations.sql +0 -31
- package/template/migrations/0035_workspace_governance.sql +0 -21
- package/template/migrations/0036_published_routing_baseline.sql +0 -15
- package/template/migrations/0037_builtin_phishing_specialist.sql +0 -71
- package/template/migrations/0038_remove_deprecated_enrichment_runtime.sql +0 -228
- package/template/migrations/0039_secure_reporting_channels.sql +0 -45
- package/template/migrations/0040_notification_only_reporting.sql +0 -24
- package/template/migrations/0041_better_auth_credentials.sql +0 -17
- package/template/migrations/0042_owner_setup_tokens.sql +0 -29
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ npx create-safest-tools safest-resolve \
|
|
|
18
18
|
--email-from reports@example.com
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
The `--email-from` address must belong to a domain onboarded under Cloudflare Email Service → Email Sending.
|
|
21
|
+
The `--email-from` address is used only for reporter receipts, follow-ups, and outcomes, and must belong to a domain onboarded under Cloudflare Email Service → Email Sending. Account verification, invitations, and password recovery use a distinct `accounts@` address on the same domain by default; pass `--auth-email-from` to choose another address.
|
|
22
22
|
|
|
23
23
|
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.
|
|
24
24
|
|
|
@@ -46,6 +46,8 @@ npm run setup
|
|
|
46
46
|
|
|
47
47
|
`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.
|
|
48
48
|
|
|
49
|
+
A new installation applies one current D1 schema baseline. Later releases add only forward-compatible upgrade migrations.
|
|
50
|
+
|
|
49
51
|
After deployment, setup prints a 256-bit, single-use owner link that expires after 15 minutes. The raw token stays in the URL fragment, D1 stores only its digest, and the installer never writes it to disk. Open it to create the Safest owner account. If it expires, run `npm run owner:setup`. For later break-glass recovery, authenticate Wrangler and run `npm run owner:recover`.
|
|
50
52
|
|
|
51
53
|
Custom-domain installations disable both the Worker’s `workers.dev` route and version preview URLs. A `workers.dev` public URL is available only when explicitly supplied as the installation URL.
|
|
@@ -57,16 +59,18 @@ Invited users may join with a password or any configured OAuth provider, then ch
|
|
|
57
59
|
## Lifecycle
|
|
58
60
|
|
|
59
61
|
```bash
|
|
62
|
+
npx create-safest-tools@latest update . --plan
|
|
63
|
+
npx create-safest-tools@latest update .
|
|
60
64
|
npm run backup:plan
|
|
61
65
|
npm run backup
|
|
62
66
|
npm run restore:verify -- .safest/backups/<backup-directory>
|
|
63
67
|
npm run restore:plan -- .safest/backups/<backup-directory> --target-config reports.restore.config.json
|
|
64
|
-
npm run upgrade:plan
|
|
65
|
-
npm run upgrade
|
|
66
68
|
npm run uninstall:plan
|
|
67
69
|
```
|
|
68
70
|
|
|
69
|
-
|
|
71
|
+
The updater hashes Safest-owned project files and refuses to overwrite local modifications. It preserves `reports.config.json`, owner-only secrets, backups, Cloudflare resource IDs, the Turnstile site key, customer webhook URLs, and the action allowlist; creates a local rollback snapshot; takes a verified D1/R2 backup; installs and validates the release; then applies forward-only migrations and deploys. `update . --plan` is read-only.
|
|
72
|
+
|
|
73
|
+
Backups contain the D1 export and every object in the four private R2 buckets, with stable inventories and SHA-256 verification. They intentionally exclude pending Queue messages, live Workflow engine state, Worker secrets, external provider state, and ephemeral presence. Restore verification is local; restore planning requires a separate target installation and never mutates Cloudflare.
|
|
70
74
|
|
|
71
75
|
The uninstall command is intentionally plan-only: it identifies the exact reporting resources but does not delete them. D1, all private R2 buckets, and secrets are preserved by default.
|
|
72
76
|
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { buildConfiguration } from "./config.mjs";
|
|
7
7
|
import { scaffoldProject, validateGeneratedConfiguration } from "./scaffold.mjs";
|
|
8
|
+
import { applyProjectUpdate, planProjectUpdate } from "./update.mjs";
|
|
8
9
|
|
|
9
10
|
const packageRoot = fileURLToPath(new URL("..", import.meta.url));
|
|
10
11
|
const packageVersion = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8")).version;
|
|
@@ -15,6 +16,8 @@ function usage() {
|
|
|
15
16
|
|
|
16
17
|
Usage:
|
|
17
18
|
npx create-safest-tools [directory]
|
|
19
|
+
npx create-safest-tools update [directory] --plan
|
|
20
|
+
npx create-safest-tools update [directory]
|
|
18
21
|
npx create-safest-tools safest-resolve --yes --name acme-resolve \\
|
|
19
22
|
--public-url https://reports.example.com --origin https://app.example.com \\
|
|
20
23
|
--owner-email infrastructure@example.com \\
|
|
@@ -26,11 +29,13 @@ Options:
|
|
|
26
29
|
--origin origin Allowed embedding/application origin; repeat as needed
|
|
27
30
|
--owner-email email Initial infrastructure owner bound to one-time setup
|
|
28
31
|
--admin-email email Deprecated alias for --owner-email
|
|
29
|
-
--email-from address
|
|
32
|
+
--email-from address Reporter-update sender on an onboarded Email Sending domain
|
|
33
|
+
--auth-email-from addr Account/invitation sender (default: accounts@same-domain)
|
|
30
34
|
--yes Do not prompt for omitted optional values
|
|
31
35
|
--skip-install Create files without running npm install or setup:plan
|
|
32
36
|
--deploy Run setup after scaffolding (still requires exact confirmation)
|
|
33
37
|
--dry-run Print configuration and resource plan without writing
|
|
38
|
+
--plan Preview an update without changing local files or Cloudflare
|
|
34
39
|
--help Show this help
|
|
35
40
|
--version Show the package version
|
|
36
41
|
|
|
@@ -46,12 +51,14 @@ function valueAfter(argv, index, option) {
|
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
export function parseArguments(argv) {
|
|
54
|
+
const commandName = argv[0] === "update" ? "update" : "create";
|
|
49
55
|
const result = {
|
|
56
|
+
command: commandName,
|
|
50
57
|
directory: null, installationName: null, publicBaseUrl: null, allowedOrigins: [],
|
|
51
|
-
ownerEmail: null, emailFromAddress: null, yes: false, skipInstall: false,
|
|
52
|
-
deploy: false, dryRun: false, help: false, version: false,
|
|
58
|
+
ownerEmail: null, emailFromAddress: null, authEmailFromAddress: null, yes: false, skipInstall: false,
|
|
59
|
+
deploy: false, dryRun: false, plan: false, help: false, version: false,
|
|
53
60
|
};
|
|
54
|
-
for (let index = 0; index < argv.length; index += 1) {
|
|
61
|
+
for (let index = commandName === "update" ? 1 : 0; index < argv.length; index += 1) {
|
|
55
62
|
const argument = argv[index];
|
|
56
63
|
if (!argument.startsWith("-")) {
|
|
57
64
|
if (result.directory) throw new Error("only one project directory can be provided");
|
|
@@ -60,9 +67,10 @@ export function parseArguments(argv) {
|
|
|
60
67
|
else if (argument === "--skip-install") result.skipInstall = true;
|
|
61
68
|
else if (argument === "--deploy") result.deploy = true;
|
|
62
69
|
else if (argument === "--dry-run") result.dryRun = true;
|
|
70
|
+
else if (argument === "--plan") result.plan = true;
|
|
63
71
|
else if (argument === "--help" || argument === "-h") result.help = true;
|
|
64
72
|
else if (argument === "--version" || argument === "-v") result.version = true;
|
|
65
|
-
else if (["--name", "--public-url", "--origin", "--owner-email", "--admin-email", "--email-from"].includes(argument)) {
|
|
73
|
+
else if (["--name", "--public-url", "--origin", "--owner-email", "--admin-email", "--email-from", "--auth-email-from"].includes(argument)) {
|
|
66
74
|
const value = valueAfter(argv, index, argument);
|
|
67
75
|
index += 1;
|
|
68
76
|
if (argument === "--name") result.installationName = value;
|
|
@@ -72,10 +80,16 @@ export function parseArguments(argv) {
|
|
|
72
80
|
if (result.ownerEmail && result.ownerEmail !== value) throw new Error("provide only one infrastructure owner email");
|
|
73
81
|
result.ownerEmail = value;
|
|
74
82
|
}
|
|
75
|
-
else result.emailFromAddress = value;
|
|
83
|
+
else if (argument === "--email-from") result.emailFromAddress = value;
|
|
84
|
+
else result.authEmailFromAddress = value;
|
|
76
85
|
} else throw new Error(`unknown option: ${argument}`);
|
|
77
86
|
}
|
|
78
87
|
if (result.deploy && result.skipInstall) throw new Error("--deploy cannot be used with --skip-install");
|
|
88
|
+
if (result.command === "create" && result.plan) throw new Error("--plan is available only with the update command");
|
|
89
|
+
if (result.command === "update" && (result.installationName || result.publicBaseUrl || result.allowedOrigins.length
|
|
90
|
+
|| result.ownerEmail || result.emailFromAddress || result.authEmailFromAddress || result.skipInstall || result.deploy || result.dryRun)) {
|
|
91
|
+
throw new Error("update accepts only a project directory, --plan, and --yes");
|
|
92
|
+
}
|
|
79
93
|
return result;
|
|
80
94
|
}
|
|
81
95
|
|
|
@@ -97,16 +111,47 @@ async function completeInteractive(options, input) {
|
|
|
97
111
|
options.publicBaseUrl ||= await ask(input, "Public reports origin", "https://reports.example.com");
|
|
98
112
|
if (!options.allowedOrigins.length) options.allowedOrigins.push(await ask(input, "Application origin allowed to embed the report form", "https://app.example.com"));
|
|
99
113
|
options.ownerEmail ||= await ask(input, "Infrastructure owner email");
|
|
100
|
-
options.emailFromAddress ||= await ask(input, "
|
|
114
|
+
options.emailFromAddress ||= await ask(input, "Reporter-update sender on a Cloudflare Email Sending domain");
|
|
101
115
|
return options;
|
|
102
116
|
}
|
|
103
117
|
|
|
118
|
+
function publicUpdatePlan(plan) {
|
|
119
|
+
const { manifest: _manifest, targetFiles: _targetFiles, desiredWrangler: _desiredWrangler, ...result } = plan;
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function confirmUpdate(plan, input) {
|
|
124
|
+
const expected = `UPDATE ${plan.fromVersion} TO ${plan.toVersion}`;
|
|
125
|
+
const actual = (await input.question(`Type ${expected} to back up and update this Safest deployment: `)).trim();
|
|
126
|
+
if (actual !== expected) throw new Error("update confirmation did not match");
|
|
127
|
+
}
|
|
128
|
+
|
|
104
129
|
export async function runCli(argv, dependencies = {}) {
|
|
105
130
|
const output = dependencies.output ?? console;
|
|
106
131
|
const options = parseArguments(argv);
|
|
107
132
|
if (options.help) { output.log(usage()); return { status: "help" }; }
|
|
108
133
|
if (options.version) { output.log(packageVersion); return { status: "version" }; }
|
|
109
134
|
const interactive = dependencies.interactive ?? (process.stdin.isTTY && !options.yes);
|
|
135
|
+
if (options.command === "update") {
|
|
136
|
+
const directory = resolve(options.directory ?? ".");
|
|
137
|
+
const templateDirectory = dependencies.templateDirectory ?? defaultTemplateDirectory;
|
|
138
|
+
const planner = dependencies.planProjectUpdate ?? planProjectUpdate;
|
|
139
|
+
const applier = dependencies.applyProjectUpdate ?? applyProjectUpdate;
|
|
140
|
+
const updatePlan = await planner({ directory, templateDirectory });
|
|
141
|
+
output.log(JSON.stringify(publicUpdatePlan(updatePlan), null, 2));
|
|
142
|
+
if (options.plan) return { status: "update-planned", plan: publicUpdatePlan(updatePlan) };
|
|
143
|
+
if (!updatePlan.ready) throw new Error(`update is blocked by locally modified managed files: ${updatePlan.conflicts.join(", ")}`);
|
|
144
|
+
if (!options.yes) {
|
|
145
|
+
if (!interactive) throw new Error("interactive update confirmation is required; use --yes only after reviewing update --plan");
|
|
146
|
+
const input = createInterface({ input: process.stdin, output: process.stdout });
|
|
147
|
+
try { await (dependencies.confirmUpdate ?? confirmUpdate)(updatePlan, input); } finally { input.close(); }
|
|
148
|
+
}
|
|
149
|
+
const result = await applier({ plan: updatePlan, templateDirectory, runner: dependencies.commandRunner });
|
|
150
|
+
output.log(result.status === "current"
|
|
151
|
+
? `Safest Resolve is already current at ${result.toVersion}.`
|
|
152
|
+
: `Updated Safest Resolve from ${result.fromVersion} to ${result.toVersion}.`);
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
110
155
|
if (interactive) {
|
|
111
156
|
const input = createInterface({ input: process.stdin, output: process.stdout });
|
|
112
157
|
try { await completeInteractive(options, input); } finally { input.close(); }
|
package/src/config.mjs
CHANGED
|
@@ -23,6 +23,20 @@ function optionalEmail(value, field) {
|
|
|
23
23
|
return result;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
function defaultAuthEmail(reporterEmail) {
|
|
27
|
+
const at = reporterEmail.lastIndexOf("@");
|
|
28
|
+
if (at < 1) return "";
|
|
29
|
+
const domain = reporterEmail.slice(at + 1);
|
|
30
|
+
return `${reporterEmail.slice(0, at).toLowerCase() === "accounts" ? "auth" : "accounts"}@${domain}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function emailAddresses(config) {
|
|
34
|
+
const reporter = optionalEmail(config.email?.fromAddress, "email.fromAddress");
|
|
35
|
+
const auth = optionalEmail(config.email?.authFromAddress, "email.authFromAddress") || defaultAuthEmail(reporter);
|
|
36
|
+
if (reporter && auth === reporter) throw new Error("authentication and reporter email senders must be different addresses");
|
|
37
|
+
return { reporter, auth };
|
|
38
|
+
}
|
|
39
|
+
|
|
26
40
|
function resource(base, suffix) {
|
|
27
41
|
const prefix = base.slice(0, 63 - suffix.length - 1).replace(/-+$/u, "");
|
|
28
42
|
return `${prefix}-${suffix}`;
|
|
@@ -37,6 +51,9 @@ export function buildConfiguration(options) {
|
|
|
37
51
|
if (!ownerEmail) throw new Error("--owner-email is required");
|
|
38
52
|
const emailFromAddress = optionalEmail(options.emailFromAddress, "email sender");
|
|
39
53
|
if (!emailFromAddress) throw new Error("--email-from is required and its domain must be onboarded to Cloudflare Email Sending");
|
|
54
|
+
const authEmailFromAddress = optionalEmail(options.authEmailFromAddress, "authentication email sender")
|
|
55
|
+
|| defaultAuthEmail(emailFromAddress);
|
|
56
|
+
if (authEmailFromAddress === emailFromAddress) throw new Error("--auth-email-from must differ from --email-from");
|
|
40
57
|
const resources = {
|
|
41
58
|
workerName: resource(installationId, "worker"),
|
|
42
59
|
databaseName: resource(installationId, "db"),
|
|
@@ -61,7 +78,7 @@ export function buildConfiguration(options) {
|
|
|
61
78
|
publicBaseUrl,
|
|
62
79
|
allowedOrigins,
|
|
63
80
|
owner: { email: ownerEmail },
|
|
64
|
-
email: { enabled: true, fromAddress: emailFromAddress },
|
|
81
|
+
email: { enabled: true, fromAddress: emailFromAddress, authFromAddress: authEmailFromAddress },
|
|
65
82
|
auth: { password: true, providers: [] },
|
|
66
83
|
retention: { reportsDays: 365, messagesDays: 180, appealWindowDays: 30 },
|
|
67
84
|
};
|
|
@@ -70,6 +87,7 @@ export function buildConfiguration(options) {
|
|
|
70
87
|
export function buildWranglerConfiguration(config) {
|
|
71
88
|
const publicHostname = new URL(config.publicBaseUrl).hostname.toLowerCase();
|
|
72
89
|
const usesWorkersDev = publicHostname.endsWith(".workers.dev");
|
|
90
|
+
const email = emailAddresses(config);
|
|
73
91
|
const wrangler = {
|
|
74
92
|
$schema: "node_modules/wrangler/config-schema.json",
|
|
75
93
|
name: config.resources.workerName,
|
|
@@ -126,15 +144,18 @@ export function buildWranglerConfiguration(config) {
|
|
|
126
144
|
ACTION_WEBHOOK_URL: "",
|
|
127
145
|
NOTIFICATION_WEBHOOK_URL: "",
|
|
128
146
|
OPERATOR_NOTIFICATION_WEBHOOK_URL: "",
|
|
129
|
-
AUTH_EMAIL_FROM:
|
|
130
|
-
|
|
131
|
-
|
|
147
|
+
AUTH_EMAIL_FROM: email.auth,
|
|
148
|
+
REPORTER_EMAIL_FROM: email.reporter,
|
|
149
|
+
REPORTER_EMAIL_SUBJECT_PREFIX: "Safest report update",
|
|
132
150
|
},
|
|
133
151
|
triggers: { crons: ["*/5 * * * *"] },
|
|
134
152
|
};
|
|
135
153
|
if (!usesWorkersDev) {
|
|
136
154
|
wrangler.routes = [{ pattern: publicHostname, custom_domain: true }];
|
|
137
155
|
}
|
|
138
|
-
wrangler.send_email = [
|
|
156
|
+
wrangler.send_email = [
|
|
157
|
+
{ name: "AUTH_EMAIL", allowed_sender_addresses: [email.auth] },
|
|
158
|
+
{ name: "REPORTER_EMAIL", allowed_sender_addresses: [email.reporter] },
|
|
159
|
+
];
|
|
139
160
|
return wrangler;
|
|
140
161
|
}
|
package/src/scaffold.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { cp, mkdir, readdir, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
import { buildWranglerConfiguration } from "./config.mjs";
|
|
5
|
+
import { createManagedManifest } from "./update.mjs";
|
|
5
6
|
|
|
6
7
|
export async function assertEmptyDestination(directory) {
|
|
7
8
|
try {
|
|
@@ -18,8 +19,10 @@ export async function scaffoldProject({ directory, templateDirectory, configurat
|
|
|
18
19
|
await mkdir(destination, { recursive: true });
|
|
19
20
|
await cp(templateDirectory, destination, { recursive: true });
|
|
20
21
|
await writeFile(resolve(destination, "reports.config.json"), `${JSON.stringify(configuration, null, 2)}\n`);
|
|
21
|
-
|
|
22
|
+
const wranglerConfiguration = buildWranglerConfiguration(configuration);
|
|
23
|
+
await writeFile(resolve(destination, "wrangler.jsonc"), `${JSON.stringify(wranglerConfiguration, null, 2)}\n`);
|
|
22
24
|
try { await cp(resolve(destination, "gitignore.template"), resolve(destination, ".gitignore")); } catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
25
|
+
await createManagedManifest({ directory: destination, templateDirectory, wranglerConfiguration });
|
|
23
26
|
return destination;
|
|
24
27
|
}
|
|
25
28
|
|
package/src/update.mjs
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { buildWranglerConfiguration } from "./config.mjs";
|
|
6
|
+
|
|
7
|
+
const MANIFEST_PATH = ".safest-managed.json";
|
|
8
|
+
const MANIFEST_SCHEMA_VERSION = 1;
|
|
9
|
+
const REBUILDABLE_PATHS = new Set(["public/console/auth-shell.js"]);
|
|
10
|
+
const CUSTOMER_RUNTIME_VAR_KEYS = new Set([
|
|
11
|
+
"ALLOWED_ACTION_CODES",
|
|
12
|
+
"TURNSTILE_SITE_KEY",
|
|
13
|
+
"ACTION_WEBHOOK_URL",
|
|
14
|
+
"NOTIFICATION_WEBHOOK_URL",
|
|
15
|
+
"OPERATOR_NOTIFICATION_WEBHOOK_URL",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
function stableValue(value) {
|
|
19
|
+
if (Array.isArray(value)) return value.map(stableValue);
|
|
20
|
+
if (value && typeof value === "object") {
|
|
21
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])]));
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function stableJson(value) {
|
|
27
|
+
return JSON.stringify(stableValue(value));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sha256(value) {
|
|
31
|
+
return createHash("sha256").update(value).digest("hex");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function fileHash(path) {
|
|
35
|
+
return sha256(await readFile(path));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function portablePath(path) {
|
|
39
|
+
return path.split(sep).join("/");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function filesBelow(directory, current = directory) {
|
|
43
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
44
|
+
const result = [];
|
|
45
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
46
|
+
const path = join(current, entry.name);
|
|
47
|
+
if (entry.isSymbolicLink()) throw new Error(`template contains an unsupported symbolic link: ${path}`);
|
|
48
|
+
if (entry.isDirectory()) result.push(...await filesBelow(directory, path));
|
|
49
|
+
else if (entry.isFile()) result.push(portablePath(relative(directory, path)));
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function templateFileHashes(templateDirectory) {
|
|
55
|
+
const result = {};
|
|
56
|
+
for (const path of await filesBelow(templateDirectory)) result[path] = await fileHash(resolve(templateDirectory, path));
|
|
57
|
+
result[".gitignore"] = result["gitignore.template"];
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizedWranglerConfiguration(configuration) {
|
|
62
|
+
const normalized = structuredClone(configuration);
|
|
63
|
+
delete normalized.account_id;
|
|
64
|
+
for (const database of normalized.d1_databases ?? []) {
|
|
65
|
+
delete database.database_id;
|
|
66
|
+
delete database.preview_database_id;
|
|
67
|
+
}
|
|
68
|
+
for (const key of CUSTOMER_RUNTIME_VAR_KEYS) delete normalized.vars?.[key];
|
|
69
|
+
return normalized;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function wranglerHash(configuration) {
|
|
73
|
+
return sha256(stableJson(normalizedWranglerConfiguration(configuration)));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function legacyWranglerHash(configuration) {
|
|
77
|
+
const normalized = structuredClone(configuration);
|
|
78
|
+
delete normalized.account_id;
|
|
79
|
+
for (const database of normalized.d1_databases ?? []) {
|
|
80
|
+
delete database.database_id;
|
|
81
|
+
delete database.preview_database_id;
|
|
82
|
+
}
|
|
83
|
+
return sha256(stableJson(normalized));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function wranglerMatchesManifest(configuration, expectedHash) {
|
|
87
|
+
if (wranglerHash(configuration) === expectedHash || legacyWranglerHash(configuration) === expectedHash) return true;
|
|
88
|
+
const preUpdateDefaults = structuredClone(configuration);
|
|
89
|
+
for (const key of CUSTOMER_RUNTIME_VAR_KEYS) {
|
|
90
|
+
if (key in (preUpdateDefaults.vars ?? {})) preUpdateDefaults.vars[key] = "";
|
|
91
|
+
}
|
|
92
|
+
return legacyWranglerHash(preUpdateDefaults) === expectedHash;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function mergeWranglerRuntimeState(desired, current) {
|
|
96
|
+
const result = structuredClone(desired);
|
|
97
|
+
if (typeof current.account_id === "string" && current.account_id) result.account_id = current.account_id;
|
|
98
|
+
for (const database of result.d1_databases ?? []) {
|
|
99
|
+
const installed = (current.d1_databases ?? []).find(({ binding }) => binding === database.binding);
|
|
100
|
+
if (!installed) continue;
|
|
101
|
+
for (const field of ["database_id", "preview_database_id"]) {
|
|
102
|
+
if (typeof installed[field] === "string" && installed[field]) database[field] = installed[field];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const key of CUSTOMER_RUNTIME_VAR_KEYS) {
|
|
106
|
+
if (typeof current.vars?.[key] === "string") result.vars[key] = current.vars[key];
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function templateVersion(templateDirectory) {
|
|
112
|
+
const metadata = JSON.parse(await readFile(resolve(templateDirectory, "package.json"), "utf8"));
|
|
113
|
+
if (typeof metadata.version !== "string" || !metadata.version) throw new Error("update template has no valid version");
|
|
114
|
+
return metadata.version;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function writeManifest(directory, manifest) {
|
|
118
|
+
const path = resolve(directory, MANIFEST_PATH);
|
|
119
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
120
|
+
await writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o644 });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function createManagedManifest({ directory, templateDirectory, wranglerConfiguration }) {
|
|
124
|
+
const manifest = {
|
|
125
|
+
schemaVersion: MANIFEST_SCHEMA_VERSION,
|
|
126
|
+
installedVersion: await templateVersion(templateDirectory),
|
|
127
|
+
managedFiles: await templateFileHashes(templateDirectory),
|
|
128
|
+
wranglerHash: wranglerHash(wranglerConfiguration),
|
|
129
|
+
};
|
|
130
|
+
await writeManifest(directory, manifest);
|
|
131
|
+
return manifest;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function readManifest(directory) {
|
|
135
|
+
let manifest;
|
|
136
|
+
try {
|
|
137
|
+
manifest = JSON.parse(await readFile(resolve(directory, MANIFEST_PATH), "utf8"));
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error?.code === "ENOENT") {
|
|
140
|
+
throw new Error("this project predates safe updates; create a fresh project with create-safest-tools 0.4.0 or later");
|
|
141
|
+
}
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
if (manifest?.schemaVersion !== MANIFEST_SCHEMA_VERSION || typeof manifest.installedVersion !== "string"
|
|
145
|
+
|| !manifest.managedFiles || typeof manifest.managedFiles !== "object" || typeof manifest.wranglerHash !== "string") {
|
|
146
|
+
throw new Error("the Safest managed-file manifest is invalid or unsupported");
|
|
147
|
+
}
|
|
148
|
+
return manifest;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function optionalFileHash(path) {
|
|
152
|
+
try { return await fileHash(path); }
|
|
153
|
+
catch (error) { if (error?.code === "ENOENT") return null; throw error; }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function numericVersion(version) {
|
|
157
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/u.exec(version);
|
|
158
|
+
if (!match) throw new Error(`unsupported Safest version: ${version}`);
|
|
159
|
+
return match.slice(1).map(Number);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function compareVersions(left, right) {
|
|
163
|
+
const leftParts = numericVersion(left);
|
|
164
|
+
const rightParts = numericVersion(right);
|
|
165
|
+
for (let index = 0; index < leftParts.length; index += 1) {
|
|
166
|
+
if (leftParts[index] !== rightParts[index]) return leftParts[index] - rightParts[index];
|
|
167
|
+
}
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function readProjectJson(directory, name) {
|
|
172
|
+
try { return JSON.parse(await readFile(resolve(directory, name), "utf8")); }
|
|
173
|
+
catch (error) { throw new Error(`cannot read ${name} from the generated project: ${error instanceof Error ? error.message : String(error)}`); }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function planProjectUpdate({ directory, templateDirectory }) {
|
|
177
|
+
const projectDirectory = resolve(directory);
|
|
178
|
+
const [manifest, targetVersion, targetFiles, reportsConfiguration, currentWrangler] = await Promise.all([
|
|
179
|
+
readManifest(projectDirectory),
|
|
180
|
+
templateVersion(templateDirectory),
|
|
181
|
+
templateFileHashes(templateDirectory),
|
|
182
|
+
readProjectJson(projectDirectory, "reports.config.json"),
|
|
183
|
+
readProjectJson(projectDirectory, "wrangler.jsonc"),
|
|
184
|
+
]);
|
|
185
|
+
if (compareVersions(targetVersion, manifest.installedVersion) < 0) {
|
|
186
|
+
throw new Error(`refusing to downgrade Safest Resolve from ${manifest.installedVersion} to ${targetVersion}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const added = [];
|
|
190
|
+
const updated = [];
|
|
191
|
+
const removed = [];
|
|
192
|
+
const conflicts = [];
|
|
193
|
+
for (const [path, installedHash] of Object.entries(manifest.managedFiles)) {
|
|
194
|
+
const currentHash = await optionalFileHash(resolve(projectDirectory, path));
|
|
195
|
+
if (!REBUILDABLE_PATHS.has(path) && currentHash !== installedHash) conflicts.push(path);
|
|
196
|
+
if (!(path in targetFiles)) removed.push(path);
|
|
197
|
+
else if (targetFiles[path] !== installedHash) updated.push(path);
|
|
198
|
+
}
|
|
199
|
+
for (const [path, targetHash] of Object.entries(targetFiles)) {
|
|
200
|
+
if (path in manifest.managedFiles) continue;
|
|
201
|
+
const currentHash = await optionalFileHash(resolve(projectDirectory, path));
|
|
202
|
+
if (currentHash !== null && currentHash !== targetHash) conflicts.push(path);
|
|
203
|
+
added.push(path);
|
|
204
|
+
}
|
|
205
|
+
if (!wranglerMatchesManifest(currentWrangler, manifest.wranglerHash)) conflicts.push("wrangler.jsonc");
|
|
206
|
+
|
|
207
|
+
const desiredWrangler = mergeWranglerRuntimeState(buildWranglerConfiguration(reportsConfiguration), currentWrangler);
|
|
208
|
+
const deployed = (currentWrangler.d1_databases ?? []).some(({ database_id: id }) => typeof id === "string" && id.length > 0);
|
|
209
|
+
const uniqueConflicts = [...new Set(conflicts)].sort();
|
|
210
|
+
return {
|
|
211
|
+
action: "update",
|
|
212
|
+
projectDirectory,
|
|
213
|
+
fromVersion: manifest.installedVersion,
|
|
214
|
+
toVersion: targetVersion,
|
|
215
|
+
deployed,
|
|
216
|
+
ready: uniqueConflicts.length === 0,
|
|
217
|
+
changes: { added: added.sort(), updated: updated.sort(), removed: removed.sort() },
|
|
218
|
+
conflicts: uniqueConflicts,
|
|
219
|
+
preserves: ["reports.config.json", ".safest/secrets.env", ".safest/backups/", "Cloudflare account_id", "D1 database_id", "Turnstile site key", "customer webhook URLs and action allowlist"],
|
|
220
|
+
manifest,
|
|
221
|
+
targetFiles,
|
|
222
|
+
desiredWrangler,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function defaultRunner(program, args, { cwd, label }) {
|
|
227
|
+
return new Promise((resolvePromise, reject) => {
|
|
228
|
+
const child = spawn(program, args, { cwd, env: process.env, stdio: "inherit" });
|
|
229
|
+
child.once("error", reject);
|
|
230
|
+
child.once("exit", (code, signal) => code === 0
|
|
231
|
+
? resolvePromise()
|
|
232
|
+
: reject(new Error(`${label} failed${signal ? ` with signal ${signal}` : ` with exit code ${code}`}`)));
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function copyWithParents(source, destination) {
|
|
237
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
238
|
+
await copyFile(source, destination);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function createRollbackSnapshot(plan, now) {
|
|
242
|
+
const stamp = now.toISOString().replace(/[^0-9A-Z]+/giu, "-").replace(/-+$/u, "");
|
|
243
|
+
const snapshot = resolve(plan.projectDirectory, ".safest", "update-rollback", `${plan.fromVersion}-to-${plan.toVersion}-${stamp}`);
|
|
244
|
+
for (const path of Object.keys(plan.manifest.managedFiles)) {
|
|
245
|
+
const source = resolve(plan.projectDirectory, path);
|
|
246
|
+
try { await copyWithParents(source, resolve(snapshot, path)); }
|
|
247
|
+
catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
248
|
+
}
|
|
249
|
+
for (const path of ["reports.config.json", "wrangler.jsonc", "package-lock.json", MANIFEST_PATH]) {
|
|
250
|
+
const source = resolve(plan.projectDirectory, path);
|
|
251
|
+
try { await copyWithParents(source, resolve(snapshot, path)); }
|
|
252
|
+
catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
253
|
+
}
|
|
254
|
+
return snapshot;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function syncManagedFiles(plan, templateDirectory) {
|
|
258
|
+
for (const path of plan.changes.removed) await rm(resolve(plan.projectDirectory, path), { force: true });
|
|
259
|
+
for (const path of Object.keys(plan.targetFiles)) {
|
|
260
|
+
const sourcePath = path === ".gitignore" ? "gitignore.template" : path;
|
|
261
|
+
await copyWithParents(resolve(templateDirectory, sourcePath), resolve(plan.projectDirectory, path));
|
|
262
|
+
}
|
|
263
|
+
await writeFile(resolve(plan.projectDirectory, "wrangler.jsonc"), `${JSON.stringify(plan.desiredWrangler, null, 2)}\n`);
|
|
264
|
+
await writeManifest(plan.projectDirectory, {
|
|
265
|
+
schemaVersion: MANIFEST_SCHEMA_VERSION,
|
|
266
|
+
installedVersion: plan.toVersion,
|
|
267
|
+
managedFiles: plan.targetFiles,
|
|
268
|
+
wranglerHash: wranglerHash(plan.desiredWrangler),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export async function applyProjectUpdate({ plan, templateDirectory, runner = defaultRunner, now = new Date() }) {
|
|
273
|
+
if (!plan.ready) throw new Error(`update is blocked by locally modified managed files: ${plan.conflicts.join(", ")}`);
|
|
274
|
+
const changed = plan.fromVersion !== plan.toVersion
|
|
275
|
+
|| Object.values(plan.changes).some((paths) => paths.length > 0);
|
|
276
|
+
if (!changed) return { status: "current", fromVersion: plan.fromVersion, toVersion: plan.toVersion };
|
|
277
|
+
const snapshot = await createRollbackSnapshot(plan, now);
|
|
278
|
+
try {
|
|
279
|
+
if (plan.deployed) {
|
|
280
|
+
await runner("npm", ["run", "backup"], { cwd: plan.projectDirectory, label: "pre-update customer-data backup" });
|
|
281
|
+
}
|
|
282
|
+
await syncManagedFiles(plan, templateDirectory);
|
|
283
|
+
await runner("npm", ["install"], { cwd: plan.projectDirectory, label: "updated dependency installation" });
|
|
284
|
+
await runner("npm", ["run", "check"], { cwd: plan.projectDirectory, label: "updated project validation" });
|
|
285
|
+
if (plan.deployed) {
|
|
286
|
+
await runner("npm", ["run", "upgrade:plan"], { cwd: plan.projectDirectory, label: "Cloudflare upgrade plan" });
|
|
287
|
+
await runner("npm", ["run", "upgrade:deploy", "--", "--yes"], { cwd: plan.projectDirectory, label: "Cloudflare upgrade deployment" });
|
|
288
|
+
} else {
|
|
289
|
+
await runner("npm", ["run", "setup:plan"], { cwd: plan.projectDirectory, label: "updated setup plan" });
|
|
290
|
+
}
|
|
291
|
+
} catch (error) {
|
|
292
|
+
throw new Error(`Safest update stopped. The local rollback snapshot is ${snapshot}. ${error instanceof Error ? error.message : String(error)}`);
|
|
293
|
+
}
|
|
294
|
+
return { status: plan.deployed ? "updated-and-deployed" : "updated-locally", fromVersion: plan.fromVersion, toVersion: plan.toVersion, snapshot };
|
|
295
|
+
}
|
package/template/README.md
CHANGED
|
@@ -9,28 +9,30 @@ 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. Under Cloudflare Email Service → Email Sending, onboard the configured sender domain.
|
|
12
|
+
2. Under Cloudflare Email Service → Email Sending, onboard the configured sender domain. Reporter updates and account mail use separate sender addresses and sender-restricted bindings.
|
|
13
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.
|
|
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
|
|
|
17
|
-
Setup creates D1, four private R2 buckets, and three Queues, applies
|
|
17
|
+
Setup creates D1, four private R2 buckets, and three Queues, applies one current D1 schema baseline, and deploys the Worker with Dynamic Workflows. Later releases add only forward-compatible upgrade migrations. After owner setup, verify the `database`, `workflow_runtime`, `workers_ai`, `object_storage`, and `queues` setup features, then require an authenticated `GET /ready` response of 200. Public `GET /health` is liveness only. Better Auth owns password hashing, OAuth accounts, sessions, and one-time password-reset tokens in D1. Upgrades always back up D1 before migrations and deployment.
|
|
18
18
|
|
|
19
19
|
If the setup link expires, run `npm run owner:setup`. If the owner loses access later, authenticate Wrangler as the Cloudflare account owner and run `npm run owner:recover`; the resulting single-use link creates a 12-hour break-glass session and is recorded in the audit log.
|
|
20
20
|
|
|
21
21
|
Administrators build and preview questions in **Configuration → Intake forms**, set the logo, colours, fonts, and shape in **Settings → Branding**, then choose a private hosted page, signed-in product widget, or anonymous product widget in **Settings → Reporting channels**. Hosted pages and anonymous widgets need no API key. Signed-in widgets use one small customer-backend endpoint; verified reporter, target, and registered trusted facts stay server-side while the browser receives only a short-lived opaque token. Every public submission is verified with Turnstile inside a Resolve-owned isolated frame.
|
|
22
22
|
|
|
23
|
-
Cloudflare Email Service and an `--email-from reports@example.com` address on an onboarded Email Sending domain are required for
|
|
23
|
+
Cloudflare Email Service and an `--email-from reports@example.com` address on an onboarded Email Sending domain are required for reporter receipts, follow-ups, and outcomes. Invitations, email verification, and password recovery use a separate `accounts@` sender by default; `--auth-email-from` overrides it. A configured signed customer notification webhook remains the fallback for reference-only participants.
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
26
|
npm run backup:plan
|
|
27
27
|
npm run backup
|
|
28
28
|
npm run restore:verify -- .safest/backups/<backup-directory>
|
|
29
29
|
npm run restore:plan -- .safest/backups/<backup-directory> --target-config reports.restore.config.json
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
npx create-safest-tools@latest update . --plan
|
|
31
|
+
npx create-safest-tools@latest update .
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
The updater preserves customer configuration, secrets, backups, Cloudflare resource IDs, Turnstile configuration, customer webhook URLs, and the action allowlist. It blocks on locally modified Safest-owned files, creates a local rollback snapshot, takes a verified D1/R2 backup, validates the new release, then applies forward-only migrations and deploys. `update . --plan` is read-only.
|
|
35
|
+
|
|
34
36
|
`npm run uninstall:plan` is deliberately read-only. It lists only the resources named by this installation and preserves D1 and all private R2 buckets by default.
|
|
35
37
|
|
|
36
38
|
Verified backups contain the D1 export plus all objects from the four private R2 buckets, with a stable inventory and SHA-256 digests. They intentionally exclude pending Queue messages, live Workflow engine state, Worker secrets, external webhook state, and ephemeral analyst presence. Restore planning requires a separately named target installation and does not mutate Cloudflare.
|