create-safest-tools 0.3.0 → 0.4.0
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 +9 -3
- package/package.json +1 -1
- package/src/cli.mjs +45 -2
- package/src/config.mjs +4 -3
- package/src/scaffold.mjs +4 -1
- package/src/update.mjs +265 -0
- package/template/README.md +5 -3
- 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 +3 -9
- package/template/public/styles.css +2 -15
- package/template/scripts/reports-owner-setup.mjs +22 -7
- package/template/scripts/reports-plan.mjs +1 -1
- 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
|
@@ -46,8 +46,12 @@ 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
|
|
|
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.
|
|
54
|
+
|
|
51
55
|
The owner then invites administrators, and administrators invite analysts. All users sign in with Safest accounts and do not need Cloudflare accounts.
|
|
52
56
|
|
|
53
57
|
Invited users may join with a password or any configured OAuth provider, then choose a natural display name and optional JPEG, PNG, or WebP profile picture up to 2 MB. Better Auth stores credentials, OAuth accounts, sessions, verification state, and one-time password-reset tokens in D1. Cloudflare Email Service sends invitations, verification links, and 30-minute password-reset links.
|
|
@@ -55,16 +59,18 @@ Invited users may join with a password or any configured OAuth provider, then ch
|
|
|
55
59
|
## Lifecycle
|
|
56
60
|
|
|
57
61
|
```bash
|
|
62
|
+
npx create-safest-tools@latest update . --plan
|
|
63
|
+
npx create-safest-tools@latest update .
|
|
58
64
|
npm run backup:plan
|
|
59
65
|
npm run backup
|
|
60
66
|
npm run restore:verify -- .safest/backups/<backup-directory>
|
|
61
67
|
npm run restore:plan -- .safest/backups/<backup-directory> --target-config reports.restore.config.json
|
|
62
|
-
npm run upgrade:plan
|
|
63
|
-
npm run upgrade
|
|
64
68
|
npm run uninstall:plan
|
|
65
69
|
```
|
|
66
70
|
|
|
67
|
-
|
|
71
|
+
The updater hashes Safest-owned project files and refuses to overwrite local modifications. It preserves `reports.config.json`, owner-only secrets, backups, the Cloudflare account selection, and the D1 database ID; 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.
|
|
68
74
|
|
|
69
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.
|
|
70
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 \\
|
|
@@ -31,6 +34,7 @@ Options:
|
|
|
31
34
|
--skip-install Create files without running npm install or setup:plan
|
|
32
35
|
--deploy Run setup after scaffolding (still requires exact confirmation)
|
|
33
36
|
--dry-run Print configuration and resource plan without writing
|
|
37
|
+
--plan Preview an update without changing local files or Cloudflare
|
|
34
38
|
--help Show this help
|
|
35
39
|
--version Show the package version
|
|
36
40
|
|
|
@@ -46,12 +50,14 @@ function valueAfter(argv, index, option) {
|
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
export function parseArguments(argv) {
|
|
53
|
+
const commandName = argv[0] === "update" ? "update" : "create";
|
|
49
54
|
const result = {
|
|
55
|
+
command: commandName,
|
|
50
56
|
directory: null, installationName: null, publicBaseUrl: null, allowedOrigins: [],
|
|
51
57
|
ownerEmail: null, emailFromAddress: null, yes: false, skipInstall: false,
|
|
52
|
-
deploy: false, dryRun: false, help: false, version: false,
|
|
58
|
+
deploy: false, dryRun: false, plan: false, help: false, version: false,
|
|
53
59
|
};
|
|
54
|
-
for (let index = 0; index < argv.length; index += 1) {
|
|
60
|
+
for (let index = commandName === "update" ? 1 : 0; index < argv.length; index += 1) {
|
|
55
61
|
const argument = argv[index];
|
|
56
62
|
if (!argument.startsWith("-")) {
|
|
57
63
|
if (result.directory) throw new Error("only one project directory can be provided");
|
|
@@ -60,6 +66,7 @@ export function parseArguments(argv) {
|
|
|
60
66
|
else if (argument === "--skip-install") result.skipInstall = true;
|
|
61
67
|
else if (argument === "--deploy") result.deploy = true;
|
|
62
68
|
else if (argument === "--dry-run") result.dryRun = true;
|
|
69
|
+
else if (argument === "--plan") result.plan = true;
|
|
63
70
|
else if (argument === "--help" || argument === "-h") result.help = true;
|
|
64
71
|
else if (argument === "--version" || argument === "-v") result.version = true;
|
|
65
72
|
else if (["--name", "--public-url", "--origin", "--owner-email", "--admin-email", "--email-from"].includes(argument)) {
|
|
@@ -76,6 +83,11 @@ export function parseArguments(argv) {
|
|
|
76
83
|
} else throw new Error(`unknown option: ${argument}`);
|
|
77
84
|
}
|
|
78
85
|
if (result.deploy && result.skipInstall) throw new Error("--deploy cannot be used with --skip-install");
|
|
86
|
+
if (result.command === "create" && result.plan) throw new Error("--plan is available only with the update command");
|
|
87
|
+
if (result.command === "update" && (result.installationName || result.publicBaseUrl || result.allowedOrigins.length
|
|
88
|
+
|| result.ownerEmail || result.emailFromAddress || result.skipInstall || result.deploy || result.dryRun)) {
|
|
89
|
+
throw new Error("update accepts only a project directory, --plan, and --yes");
|
|
90
|
+
}
|
|
79
91
|
return result;
|
|
80
92
|
}
|
|
81
93
|
|
|
@@ -101,12 +113,43 @@ async function completeInteractive(options, input) {
|
|
|
101
113
|
return options;
|
|
102
114
|
}
|
|
103
115
|
|
|
116
|
+
function publicUpdatePlan(plan) {
|
|
117
|
+
const { manifest: _manifest, targetFiles: _targetFiles, desiredWrangler: _desiredWrangler, ...result } = plan;
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function confirmUpdate(plan, input) {
|
|
122
|
+
const expected = `UPDATE ${plan.fromVersion} TO ${plan.toVersion}`;
|
|
123
|
+
const actual = (await input.question(`Type ${expected} to back up and update this Safest deployment: `)).trim();
|
|
124
|
+
if (actual !== expected) throw new Error("update confirmation did not match");
|
|
125
|
+
}
|
|
126
|
+
|
|
104
127
|
export async function runCli(argv, dependencies = {}) {
|
|
105
128
|
const output = dependencies.output ?? console;
|
|
106
129
|
const options = parseArguments(argv);
|
|
107
130
|
if (options.help) { output.log(usage()); return { status: "help" }; }
|
|
108
131
|
if (options.version) { output.log(packageVersion); return { status: "version" }; }
|
|
109
132
|
const interactive = dependencies.interactive ?? (process.stdin.isTTY && !options.yes);
|
|
133
|
+
if (options.command === "update") {
|
|
134
|
+
const directory = resolve(options.directory ?? ".");
|
|
135
|
+
const templateDirectory = dependencies.templateDirectory ?? defaultTemplateDirectory;
|
|
136
|
+
const planner = dependencies.planProjectUpdate ?? planProjectUpdate;
|
|
137
|
+
const applier = dependencies.applyProjectUpdate ?? applyProjectUpdate;
|
|
138
|
+
const updatePlan = await planner({ directory, templateDirectory });
|
|
139
|
+
output.log(JSON.stringify(publicUpdatePlan(updatePlan), null, 2));
|
|
140
|
+
if (options.plan) return { status: "update-planned", plan: publicUpdatePlan(updatePlan) };
|
|
141
|
+
if (!updatePlan.ready) throw new Error(`update is blocked by locally modified managed files: ${updatePlan.conflicts.join(", ")}`);
|
|
142
|
+
if (!options.yes) {
|
|
143
|
+
if (!interactive) throw new Error("interactive update confirmation is required; use --yes only after reviewing update --plan");
|
|
144
|
+
const input = createInterface({ input: process.stdin, output: process.stdout });
|
|
145
|
+
try { await (dependencies.confirmUpdate ?? confirmUpdate)(updatePlan, input); } finally { input.close(); }
|
|
146
|
+
}
|
|
147
|
+
const result = await applier({ plan: updatePlan, templateDirectory, runner: dependencies.commandRunner });
|
|
148
|
+
output.log(result.status === "current"
|
|
149
|
+
? `Safest Resolve is already current at ${result.toVersion}.`
|
|
150
|
+
: `Updated Safest Resolve from ${result.fromVersion} to ${result.toVersion}.`);
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
110
153
|
if (interactive) {
|
|
111
154
|
const input = createInterface({ input: process.stdin, output: process.stdout });
|
|
112
155
|
try { await completeInteractive(options, input); } finally { input.close(); }
|
package/src/config.mjs
CHANGED
|
@@ -69,14 +69,15 @@ export function buildConfiguration(options) {
|
|
|
69
69
|
|
|
70
70
|
export function buildWranglerConfiguration(config) {
|
|
71
71
|
const publicHostname = new URL(config.publicBaseUrl).hostname.toLowerCase();
|
|
72
|
+
const usesWorkersDev = publicHostname.endsWith(".workers.dev");
|
|
72
73
|
const wrangler = {
|
|
73
74
|
$schema: "node_modules/wrangler/config-schema.json",
|
|
74
75
|
name: config.resources.workerName,
|
|
75
76
|
main: "src/index.ts",
|
|
76
77
|
compatibility_date: "2026-08-28",
|
|
77
78
|
compatibility_flags: ["nodejs_compat"],
|
|
78
|
-
workers_dev:
|
|
79
|
-
preview_urls:
|
|
79
|
+
workers_dev: usesWorkersDev,
|
|
80
|
+
preview_urls: false,
|
|
80
81
|
observability: { enabled: true },
|
|
81
82
|
ai: { binding: "AI", remote: true },
|
|
82
83
|
assets: { directory: "public", binding: "ASSETS", run_worker_first: true },
|
|
@@ -131,7 +132,7 @@ export function buildWranglerConfiguration(config) {
|
|
|
131
132
|
},
|
|
132
133
|
triggers: { crons: ["*/5 * * * *"] },
|
|
133
134
|
};
|
|
134
|
-
if (!
|
|
135
|
+
if (!usesWorkersDev) {
|
|
135
136
|
wrangler.routes = [{ pattern: publicHostname, custom_domain: true }];
|
|
136
137
|
}
|
|
137
138
|
wrangler.send_email = [{ name: "EMAIL" }];
|
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,265 @@
|
|
|
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
|
+
|
|
11
|
+
function stableValue(value) {
|
|
12
|
+
if (Array.isArray(value)) return value.map(stableValue);
|
|
13
|
+
if (value && typeof value === "object") {
|
|
14
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])]));
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function stableJson(value) {
|
|
20
|
+
return JSON.stringify(stableValue(value));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sha256(value) {
|
|
24
|
+
return createHash("sha256").update(value).digest("hex");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function fileHash(path) {
|
|
28
|
+
return sha256(await readFile(path));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function portablePath(path) {
|
|
32
|
+
return path.split(sep).join("/");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function filesBelow(directory, current = directory) {
|
|
36
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
37
|
+
const result = [];
|
|
38
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
39
|
+
const path = join(current, entry.name);
|
|
40
|
+
if (entry.isSymbolicLink()) throw new Error(`template contains an unsupported symbolic link: ${path}`);
|
|
41
|
+
if (entry.isDirectory()) result.push(...await filesBelow(directory, path));
|
|
42
|
+
else if (entry.isFile()) result.push(portablePath(relative(directory, path)));
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function templateFileHashes(templateDirectory) {
|
|
48
|
+
const result = {};
|
|
49
|
+
for (const path of await filesBelow(templateDirectory)) result[path] = await fileHash(resolve(templateDirectory, path));
|
|
50
|
+
result[".gitignore"] = result["gitignore.template"];
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizedWranglerConfiguration(configuration) {
|
|
55
|
+
const normalized = structuredClone(configuration);
|
|
56
|
+
delete normalized.account_id;
|
|
57
|
+
for (const database of normalized.d1_databases ?? []) {
|
|
58
|
+
delete database.database_id;
|
|
59
|
+
delete database.preview_database_id;
|
|
60
|
+
}
|
|
61
|
+
return normalized;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function wranglerHash(configuration) {
|
|
65
|
+
return sha256(stableJson(normalizedWranglerConfiguration(configuration)));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function mergeWranglerRuntimeState(desired, current) {
|
|
69
|
+
const result = structuredClone(desired);
|
|
70
|
+
if (typeof current.account_id === "string" && current.account_id) result.account_id = current.account_id;
|
|
71
|
+
for (const database of result.d1_databases ?? []) {
|
|
72
|
+
const installed = (current.d1_databases ?? []).find(({ binding }) => binding === database.binding);
|
|
73
|
+
if (!installed) continue;
|
|
74
|
+
for (const field of ["database_id", "preview_database_id"]) {
|
|
75
|
+
if (typeof installed[field] === "string" && installed[field]) database[field] = installed[field];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function templateVersion(templateDirectory) {
|
|
82
|
+
const metadata = JSON.parse(await readFile(resolve(templateDirectory, "package.json"), "utf8"));
|
|
83
|
+
if (typeof metadata.version !== "string" || !metadata.version) throw new Error("update template has no valid version");
|
|
84
|
+
return metadata.version;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function writeManifest(directory, manifest) {
|
|
88
|
+
const path = resolve(directory, MANIFEST_PATH);
|
|
89
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
90
|
+
await writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o644 });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function createManagedManifest({ directory, templateDirectory, wranglerConfiguration }) {
|
|
94
|
+
const manifest = {
|
|
95
|
+
schemaVersion: MANIFEST_SCHEMA_VERSION,
|
|
96
|
+
installedVersion: await templateVersion(templateDirectory),
|
|
97
|
+
managedFiles: await templateFileHashes(templateDirectory),
|
|
98
|
+
wranglerHash: wranglerHash(wranglerConfiguration),
|
|
99
|
+
};
|
|
100
|
+
await writeManifest(directory, manifest);
|
|
101
|
+
return manifest;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function readManifest(directory) {
|
|
105
|
+
let manifest;
|
|
106
|
+
try {
|
|
107
|
+
manifest = JSON.parse(await readFile(resolve(directory, MANIFEST_PATH), "utf8"));
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error?.code === "ENOENT") {
|
|
110
|
+
throw new Error("this project predates safe updates; create a fresh project with create-safest-tools 0.4.0 or later");
|
|
111
|
+
}
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
if (manifest?.schemaVersion !== MANIFEST_SCHEMA_VERSION || typeof manifest.installedVersion !== "string"
|
|
115
|
+
|| !manifest.managedFiles || typeof manifest.managedFiles !== "object" || typeof manifest.wranglerHash !== "string") {
|
|
116
|
+
throw new Error("the Safest managed-file manifest is invalid or unsupported");
|
|
117
|
+
}
|
|
118
|
+
return manifest;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function optionalFileHash(path) {
|
|
122
|
+
try { return await fileHash(path); }
|
|
123
|
+
catch (error) { if (error?.code === "ENOENT") return null; throw error; }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function numericVersion(version) {
|
|
127
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/u.exec(version);
|
|
128
|
+
if (!match) throw new Error(`unsupported Safest version: ${version}`);
|
|
129
|
+
return match.slice(1).map(Number);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function compareVersions(left, right) {
|
|
133
|
+
const leftParts = numericVersion(left);
|
|
134
|
+
const rightParts = numericVersion(right);
|
|
135
|
+
for (let index = 0; index < leftParts.length; index += 1) {
|
|
136
|
+
if (leftParts[index] !== rightParts[index]) return leftParts[index] - rightParts[index];
|
|
137
|
+
}
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function readProjectJson(directory, name) {
|
|
142
|
+
try { return JSON.parse(await readFile(resolve(directory, name), "utf8")); }
|
|
143
|
+
catch (error) { throw new Error(`cannot read ${name} from the generated project: ${error instanceof Error ? error.message : String(error)}`); }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function planProjectUpdate({ directory, templateDirectory }) {
|
|
147
|
+
const projectDirectory = resolve(directory);
|
|
148
|
+
const [manifest, targetVersion, targetFiles, reportsConfiguration, currentWrangler] = await Promise.all([
|
|
149
|
+
readManifest(projectDirectory),
|
|
150
|
+
templateVersion(templateDirectory),
|
|
151
|
+
templateFileHashes(templateDirectory),
|
|
152
|
+
readProjectJson(projectDirectory, "reports.config.json"),
|
|
153
|
+
readProjectJson(projectDirectory, "wrangler.jsonc"),
|
|
154
|
+
]);
|
|
155
|
+
if (compareVersions(targetVersion, manifest.installedVersion) < 0) {
|
|
156
|
+
throw new Error(`refusing to downgrade Safest Resolve from ${manifest.installedVersion} to ${targetVersion}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const added = [];
|
|
160
|
+
const updated = [];
|
|
161
|
+
const removed = [];
|
|
162
|
+
const conflicts = [];
|
|
163
|
+
for (const [path, installedHash] of Object.entries(manifest.managedFiles)) {
|
|
164
|
+
const currentHash = await optionalFileHash(resolve(projectDirectory, path));
|
|
165
|
+
if (!REBUILDABLE_PATHS.has(path) && currentHash !== installedHash) conflicts.push(path);
|
|
166
|
+
if (!(path in targetFiles)) removed.push(path);
|
|
167
|
+
else if (targetFiles[path] !== installedHash) updated.push(path);
|
|
168
|
+
}
|
|
169
|
+
for (const [path, targetHash] of Object.entries(targetFiles)) {
|
|
170
|
+
if (path in manifest.managedFiles) continue;
|
|
171
|
+
const currentHash = await optionalFileHash(resolve(projectDirectory, path));
|
|
172
|
+
if (currentHash !== null && currentHash !== targetHash) conflicts.push(path);
|
|
173
|
+
added.push(path);
|
|
174
|
+
}
|
|
175
|
+
if (wranglerHash(currentWrangler) !== manifest.wranglerHash) conflicts.push("wrangler.jsonc");
|
|
176
|
+
|
|
177
|
+
const desiredWrangler = mergeWranglerRuntimeState(buildWranglerConfiguration(reportsConfiguration), currentWrangler);
|
|
178
|
+
const deployed = (currentWrangler.d1_databases ?? []).some(({ database_id: id }) => typeof id === "string" && id.length > 0);
|
|
179
|
+
const uniqueConflicts = [...new Set(conflicts)].sort();
|
|
180
|
+
return {
|
|
181
|
+
action: "update",
|
|
182
|
+
projectDirectory,
|
|
183
|
+
fromVersion: manifest.installedVersion,
|
|
184
|
+
toVersion: targetVersion,
|
|
185
|
+
deployed,
|
|
186
|
+
ready: uniqueConflicts.length === 0,
|
|
187
|
+
changes: { added: added.sort(), updated: updated.sort(), removed: removed.sort() },
|
|
188
|
+
conflicts: uniqueConflicts,
|
|
189
|
+
preserves: ["reports.config.json", ".safest/secrets.env", ".safest/backups/", "Cloudflare account_id", "D1 database_id"],
|
|
190
|
+
manifest,
|
|
191
|
+
targetFiles,
|
|
192
|
+
desiredWrangler,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function defaultRunner(program, args, { cwd, label }) {
|
|
197
|
+
return new Promise((resolvePromise, reject) => {
|
|
198
|
+
const child = spawn(program, args, { cwd, env: process.env, stdio: "inherit" });
|
|
199
|
+
child.once("error", reject);
|
|
200
|
+
child.once("exit", (code, signal) => code === 0
|
|
201
|
+
? resolvePromise()
|
|
202
|
+
: reject(new Error(`${label} failed${signal ? ` with signal ${signal}` : ` with exit code ${code}`}`)));
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function copyWithParents(source, destination) {
|
|
207
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
208
|
+
await copyFile(source, destination);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function createRollbackSnapshot(plan, now) {
|
|
212
|
+
const stamp = now.toISOString().replace(/[^0-9A-Z]+/giu, "-").replace(/-+$/u, "");
|
|
213
|
+
const snapshot = resolve(plan.projectDirectory, ".safest", "update-rollback", `${plan.fromVersion}-to-${plan.toVersion}-${stamp}`);
|
|
214
|
+
for (const path of Object.keys(plan.manifest.managedFiles)) {
|
|
215
|
+
const source = resolve(plan.projectDirectory, path);
|
|
216
|
+
try { await copyWithParents(source, resolve(snapshot, path)); }
|
|
217
|
+
catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
218
|
+
}
|
|
219
|
+
for (const path of ["reports.config.json", "wrangler.jsonc", "package-lock.json", MANIFEST_PATH]) {
|
|
220
|
+
const source = resolve(plan.projectDirectory, path);
|
|
221
|
+
try { await copyWithParents(source, resolve(snapshot, path)); }
|
|
222
|
+
catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
223
|
+
}
|
|
224
|
+
return snapshot;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function syncManagedFiles(plan, templateDirectory) {
|
|
228
|
+
for (const path of plan.changes.removed) await rm(resolve(plan.projectDirectory, path), { force: true });
|
|
229
|
+
for (const path of Object.keys(plan.targetFiles)) {
|
|
230
|
+
const sourcePath = path === ".gitignore" ? "gitignore.template" : path;
|
|
231
|
+
await copyWithParents(resolve(templateDirectory, sourcePath), resolve(plan.projectDirectory, path));
|
|
232
|
+
}
|
|
233
|
+
await writeFile(resolve(plan.projectDirectory, "wrangler.jsonc"), `${JSON.stringify(plan.desiredWrangler, null, 2)}\n`);
|
|
234
|
+
await writeManifest(plan.projectDirectory, {
|
|
235
|
+
schemaVersion: MANIFEST_SCHEMA_VERSION,
|
|
236
|
+
installedVersion: plan.toVersion,
|
|
237
|
+
managedFiles: plan.targetFiles,
|
|
238
|
+
wranglerHash: wranglerHash(plan.desiredWrangler),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function applyProjectUpdate({ plan, templateDirectory, runner = defaultRunner, now = new Date() }) {
|
|
243
|
+
if (!plan.ready) throw new Error(`update is blocked by locally modified managed files: ${plan.conflicts.join(", ")}`);
|
|
244
|
+
const changed = plan.fromVersion !== plan.toVersion
|
|
245
|
+
|| Object.values(plan.changes).some((paths) => paths.length > 0);
|
|
246
|
+
if (!changed) return { status: "current", fromVersion: plan.fromVersion, toVersion: plan.toVersion };
|
|
247
|
+
const snapshot = await createRollbackSnapshot(plan, now);
|
|
248
|
+
try {
|
|
249
|
+
if (plan.deployed) {
|
|
250
|
+
await runner("npm", ["run", "backup"], { cwd: plan.projectDirectory, label: "pre-update customer-data backup" });
|
|
251
|
+
}
|
|
252
|
+
await syncManagedFiles(plan, templateDirectory);
|
|
253
|
+
await runner("npm", ["install"], { cwd: plan.projectDirectory, label: "updated dependency installation" });
|
|
254
|
+
await runner("npm", ["run", "check"], { cwd: plan.projectDirectory, label: "updated project validation" });
|
|
255
|
+
if (plan.deployed) {
|
|
256
|
+
await runner("npm", ["run", "upgrade:plan"], { cwd: plan.projectDirectory, label: "Cloudflare upgrade plan" });
|
|
257
|
+
await runner("npm", ["run", "upgrade:deploy", "--", "--yes"], { cwd: plan.projectDirectory, label: "Cloudflare upgrade deployment" });
|
|
258
|
+
} else {
|
|
259
|
+
await runner("npm", ["run", "setup:plan"], { cwd: plan.projectDirectory, label: "updated setup plan" });
|
|
260
|
+
}
|
|
261
|
+
} catch (error) {
|
|
262
|
+
throw new Error(`Safest update stopped. The local rollback snapshot is ${snapshot}. ${error instanceof Error ? error.message : String(error)}`);
|
|
263
|
+
}
|
|
264
|
+
return { status: plan.deployed ? "updated-and-deployed" : "updated-locally", fromVersion: plan.fromVersion, toVersion: plan.toVersion, snapshot };
|
|
265
|
+
}
|
package/template/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Before deployment:
|
|
|
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
|
|
|
@@ -27,10 +27,12 @@ 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, and Cloudflare resource IDs. 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.
|
|
@@ -94,10 +94,6 @@ function Navigation({ session, currentView, reportCount, open, onClose }: {
|
|
|
94
94
|
</Fragment>
|
|
95
95
|
))}
|
|
96
96
|
</nav>
|
|
97
|
-
<div className="principle">
|
|
98
|
-
<span className="principle-dot" aria-hidden="true" />
|
|
99
|
-
<div><strong>Customer-owned records</strong><p>Reports and decisions stay in this deployment.</p></div>
|
|
100
|
-
</div>
|
|
101
97
|
</aside>
|
|
102
98
|
<button className="nav-scrim" type="button" aria-label="Close navigation" hidden={!open} onClick={onClose} />
|
|
103
99
|
</>
|