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.
Files changed (71) hide show
  1. package/README.md +8 -4
  2. package/package.json +1 -1
  3. package/src/cli.mjs +52 -7
  4. package/src/config.mjs +26 -5
  5. package/src/scaffold.mjs +4 -1
  6. package/src/update.mjs +295 -0
  7. package/template/README.md +7 -5
  8. package/template/console/reports/ReportDrawer.tsx +51 -6
  9. package/template/console/reports/controller.ts +11 -5
  10. package/template/console/reports/store.ts +1 -1
  11. package/template/console/reports/types.ts +23 -0
  12. package/template/console/shell/WorkspaceShell.tsx +0 -4
  13. package/template/migrations/0001_safest_resolve_baseline.sql +2446 -0
  14. package/template/package.json +4 -3
  15. package/template/public/console/auth-shell.js +4333 -4243
  16. package/template/public/styles.css +19 -17
  17. package/template/reports.config.example.json +2 -1
  18. package/template/reports.schema.json +2 -1
  19. package/template/scripts/reports-auth-onboarding.mjs +6 -5
  20. package/template/scripts/reports-plan.mjs +14 -5
  21. package/template/src/index.ts +12 -3
  22. package/template/src/installation-admin.ts +2 -1
  23. package/template/src/report-ai.ts +30 -5
  24. package/template/src/report-better-auth.ts +5 -14
  25. package/template/src/report-delivery.ts +4 -3
  26. package/template/src/report-email.ts +106 -0
  27. package/template/src/report-workflow.ts +103 -21
  28. package/template/src/workflow-effects.ts +4 -3
  29. package/template/worker-configuration.d.ts +5 -3
  30. package/template/migrations/0001_reports_foundation.sql +0 -444
  31. package/template/migrations/0002_human_report_loop.sql +0 -65
  32. package/template/migrations/0003_delivery_reliability.sql +0 -18
  33. package/template/migrations/0004_public_intake.sql +0 -14
  34. package/template/migrations/0005_operations_visibility.sql +0 -24
  35. package/template/migrations/0006_ai_governance.sql +0 -198
  36. package/template/migrations/0007_ai_release_gates.sql +0 -6
  37. package/template/migrations/0008_retention_analytics_exports.sql +0 -52
  38. package/template/migrations/0009_retention_derived_copies.sql +0 -14
  39. package/template/migrations/0010_ai_quality_controls.sql +0 -26
  40. package/template/migrations/0011_analyst_presence.sql +0 -28
  41. package/template/migrations/0012_queue_policies.sql +0 -87
  42. package/template/migrations/0013_routing_agents.sql +0 -63
  43. package/template/migrations/0014_webhook_enrichments.sql +0 -122
  44. package/template/migrations/0015_queue_owned_ai.sql +0 -55
  45. package/template/migrations/0016_operator_accounts.sql +0 -67
  46. package/template/migrations/0017_operator_profiles_and_recovery.sql +0 -33
  47. package/template/migrations/0018_platform_configuration.sql +0 -382
  48. package/template/migrations/0019_workflow_authoring_runtime.sql +0 -372
  49. package/template/migrations/0020_tasks_findings_assistant_budgets.sql +0 -427
  50. package/template/migrations/0021_abuse_evidence_operations.sql +0 -324
  51. package/template/migrations/0022_workflow_dispatch_operations.sql +0 -42
  52. package/template/migrations/0023_component_connection_execution.sql +0 -75
  53. package/template/migrations/0024_access_runtime_integrity.sql +0 -72
  54. package/template/migrations/0025_installation_timezone.sql +0 -11
  55. package/template/migrations/0026_ai_and_egress_execution_controls.sql +0 -49
  56. package/template/migrations/0027_prompt_and_ai_registry.sql +0 -37
  57. package/template/migrations/0028_step_attempt_ai_provenance.sql +0 -13
  58. package/template/migrations/0029_evidence_fetch_transport.sql +0 -5
  59. package/template/migrations/0030_evidence_dlq_incidents.sql +0 -45
  60. package/template/migrations/0031_shadow_quality_integrity.sql +0 -7
  61. package/template/migrations/0032_action_delivery_outbox.sql +0 -55
  62. package/template/migrations/0033_configuration_and_assistant_drafts.sql +0 -43
  63. package/template/migrations/0034_installation_integrations.sql +0 -31
  64. package/template/migrations/0035_workspace_governance.sql +0 -21
  65. package/template/migrations/0036_published_routing_baseline.sql +0 -15
  66. package/template/migrations/0037_builtin_phishing_specialist.sql +0 -71
  67. package/template/migrations/0038_remove_deprecated_enrichment_runtime.sql +0 -228
  68. package/template/migrations/0039_secure_reporting_channels.sql +0 -45
  69. package/template/migrations/0040_notification_only_reporting.sql +0 -24
  70. package/template/migrations/0041_better_auth_credentials.sql +0 -17
  71. 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. Domain onboarding permits delivery to arbitrary invited users; invitations, email verification, and forgot-password delivery use this address.
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
- 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. Upgrade runs a verified backup before forward-only migrations and deployment.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-safest-tools",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Create customer-owned abuse-reporting infrastructure on Cloudflare",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/src/cli.mjs CHANGED
@@ -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 Address on an onboarded Email Sending domain (required)
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, "Sender address on a Cloudflare Email Sending domain");
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: config.email.fromAddress,
130
- EMAIL_FROM_ADDRESS: config.email.fromAddress,
131
- EMAIL_SUBJECT_PREFIX: "Safest report update",
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 = [{ name: "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
- await writeFile(resolve(destination, "wrangler.jsonc"), `${JSON.stringify(buildWranglerConfiguration(configuration), null, 2)}\n`);
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
+ }
@@ -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. Invitations and password recovery to arbitrary recipients depend on domain onboarding.
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 report migrations, and deploys the Worker with Dynamic Workflows. 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.
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 invitations, email verification, and password recovery. Participant notifications may still use a configured signed customer notification webhook.
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
- npm run upgrade:plan
31
- npm run upgrade
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.