create-safest-tools 0.2.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/LICENSE +201 -0
- package/README.md +71 -0
- package/bin/create-safest-tools.mjs +12 -0
- package/package.json +34 -0
- package/src/cli.mjs +128 -0
- package/src/config.mjs +151 -0
- package/src/scaffold.mjs +30 -0
- package/template/LICENSE +201 -0
- package/template/README.md +35 -0
- package/template/console/README.md +28 -0
- package/template/console/analytics/AnalyticsWorkspace.tsx +55 -0
- package/template/console/analytics/controller.ts +133 -0
- package/template/console/analytics/events.ts +33 -0
- package/template/console/analytics/store.ts +33 -0
- package/template/console/analytics/types.ts +38 -0
- package/template/console/appeals/AppealWorkspace.tsx +73 -0
- package/template/console/appeals/controller.ts +125 -0
- package/template/console/appeals/events.ts +33 -0
- package/template/console/appeals/store.ts +35 -0
- package/template/console/appeals/types.ts +37 -0
- package/template/console/assistant/AssistantWorkspace.tsx +133 -0
- package/template/console/assistant/types.ts +34 -0
- package/template/console/auth/AccountJourney.tsx +69 -0
- package/template/console/auth/InvitationAcceptance.tsx +145 -0
- package/template/console/auth/OAuthButtons.tsx +78 -0
- package/template/console/auth/OperatorLogin.tsx +110 -0
- package/template/console/auth/PasswordRecovery.tsx +132 -0
- package/template/console/auth/account-route.ts +15 -0
- package/template/console/auth/browser-navigation.ts +7 -0
- package/template/console/auth/events.ts +15 -0
- package/template/console/command/CommandCentre.tsx +119 -0
- package/template/console/command/controller.ts +267 -0
- package/template/console/command/events.ts +19 -0
- package/template/console/command/store.ts +36 -0
- package/template/console/command/types.ts +61 -0
- package/template/console/components/AnalystIdentity.tsx +25 -0
- package/template/console/components/PageHeader.tsx +27 -0
- package/template/console/configuration/ConfigurationDialog.tsx +200 -0
- package/template/console/configuration/ConfigurationWorkspace.tsx +66 -0
- package/template/console/configuration/FormBuilder.tsx +143 -0
- package/template/console/configuration/api.ts +40 -0
- package/template/console/configuration/events.ts +14 -0
- package/template/console/configuration/types.ts +79 -0
- package/template/console/lib/format.ts +49 -0
- package/template/console/lib/http.ts +96 -0
- package/template/console/main.tsx +225 -0
- package/template/console/operations/OperationsWorkspace.tsx +126 -0
- package/template/console/operations/controller.ts +237 -0
- package/template/console/operations/events.ts +37 -0
- package/template/console/operations/store.ts +32 -0
- package/template/console/operations/types.ts +107 -0
- package/template/console/people/PeopleWorkspace.tsx +81 -0
- package/template/console/people/types.ts +25 -0
- package/template/console/profile/ProfileWorkspace.tsx +136 -0
- package/template/console/profile/events.ts +9 -0
- package/template/console/profile/types.ts +6 -0
- package/template/console/quality/QualityWorkspace.tsx +87 -0
- package/template/console/quality/controller.ts +154 -0
- package/template/console/quality/events.ts +34 -0
- package/template/console/quality/store.ts +36 -0
- package/template/console/quality/types.ts +70 -0
- package/template/console/queues/QueueEditor.tsx +173 -0
- package/template/console/queues/QueueWorkspace.tsx +109 -0
- package/template/console/queues/controller.ts +194 -0
- package/template/console/queues/events.ts +34 -0
- package/template/console/queues/store.ts +37 -0
- package/template/console/queues/types.ts +102 -0
- package/template/console/registry/RegistryDialog.tsx +198 -0
- package/template/console/registry/RegistryWorkspace.tsx +108 -0
- package/template/console/registry/events.ts +14 -0
- package/template/console/registry/types.ts +52 -0
- package/template/console/reports/ReportDrawer.tsx +159 -0
- package/template/console/reports/ReportWorkspace.tsx +90 -0
- package/template/console/reports/controller.ts +517 -0
- package/template/console/reports/events.ts +42 -0
- package/template/console/reports/store.ts +39 -0
- package/template/console/reports/types.ts +230 -0
- package/template/console/settings/BrandEditor.tsx +126 -0
- package/template/console/settings/ChannelDialog.tsx +241 -0
- package/template/console/settings/SettingsWorkspace.tsx +155 -0
- package/template/console/settings/events.ts +19 -0
- package/template/console/settings/handoff.ts +22 -0
- package/template/console/settings/types.ts +93 -0
- package/template/console/shell/WorkspaceShell.tsx +160 -0
- package/template/console/shell/controller.ts +182 -0
- package/template/console/shell/events.ts +25 -0
- package/template/console/shell/navigation.ts +61 -0
- package/template/console/shell/store.ts +56 -0
- package/template/console/shell/types.ts +86 -0
- package/template/console/workflows/CreateWorkflowDialog.tsx +87 -0
- package/template/console/workflows/WorkflowCanvas.tsx +42 -0
- package/template/console/workflows/WorkflowDialogs.tsx +6 -0
- package/template/console/workflows/WorkflowStudio.tsx +331 -0
- package/template/console/workflows/WorkflowWorkspace.tsx +82 -0
- package/template/console/workflows/events.ts +17 -0
- package/template/console/workflows/graph.ts +156 -0
- package/template/console/workflows/templates.ts +70 -0
- package/template/console/workflows/types.ts +201 -0
- package/template/gitignore.template +18 -0
- package/template/migrations/0001_reports_foundation.sql +444 -0
- package/template/migrations/0002_human_report_loop.sql +65 -0
- package/template/migrations/0003_delivery_reliability.sql +18 -0
- package/template/migrations/0004_public_intake.sql +14 -0
- package/template/migrations/0005_operations_visibility.sql +24 -0
- package/template/migrations/0006_ai_governance.sql +198 -0
- package/template/migrations/0007_ai_release_gates.sql +6 -0
- package/template/migrations/0008_retention_analytics_exports.sql +52 -0
- package/template/migrations/0009_retention_derived_copies.sql +14 -0
- package/template/migrations/0010_ai_quality_controls.sql +26 -0
- package/template/migrations/0011_analyst_presence.sql +28 -0
- package/template/migrations/0012_queue_policies.sql +87 -0
- package/template/migrations/0013_routing_agents.sql +63 -0
- package/template/migrations/0014_webhook_enrichments.sql +122 -0
- package/template/migrations/0015_queue_owned_ai.sql +55 -0
- package/template/migrations/0016_operator_accounts.sql +67 -0
- package/template/migrations/0017_operator_profiles_and_recovery.sql +33 -0
- package/template/migrations/0018_platform_configuration.sql +382 -0
- package/template/migrations/0019_workflow_authoring_runtime.sql +372 -0
- package/template/migrations/0020_tasks_findings_assistant_budgets.sql +427 -0
- package/template/migrations/0021_abuse_evidence_operations.sql +324 -0
- package/template/migrations/0022_workflow_dispatch_operations.sql +42 -0
- package/template/migrations/0023_component_connection_execution.sql +75 -0
- package/template/migrations/0024_access_runtime_integrity.sql +72 -0
- package/template/migrations/0025_installation_timezone.sql +11 -0
- package/template/migrations/0026_ai_and_egress_execution_controls.sql +49 -0
- package/template/migrations/0027_prompt_and_ai_registry.sql +37 -0
- package/template/migrations/0028_step_attempt_ai_provenance.sql +13 -0
- package/template/migrations/0029_evidence_fetch_transport.sql +5 -0
- package/template/migrations/0030_evidence_dlq_incidents.sql +45 -0
- package/template/migrations/0031_shadow_quality_integrity.sql +7 -0
- package/template/migrations/0032_action_delivery_outbox.sql +55 -0
- package/template/migrations/0033_configuration_and_assistant_drafts.sql +43 -0
- package/template/migrations/0034_installation_integrations.sql +31 -0
- package/template/migrations/0035_workspace_governance.sql +21 -0
- package/template/migrations/0036_published_routing_baseline.sql +15 -0
- package/template/migrations/0037_builtin_phishing_specialist.sql +71 -0
- package/template/migrations/0038_remove_deprecated_enrichment_runtime.sql +228 -0
- package/template/migrations/0039_secure_reporting_channels.sql +45 -0
- package/template/migrations/0040_notification_only_reporting.sql +24 -0
- package/template/migrations/0041_better_auth_credentials.sql +17 -0
- package/template/package.json +47 -0
- package/template/public/_headers +27 -0
- package/template/public/app-icon-192.png +0 -0
- package/template/public/app-icon-512.png +0 -0
- package/template/public/brand-icon.svg +7 -0
- package/template/public/brand-tokens.css +80 -0
- package/template/public/console/auth-shell.js +19340 -0
- package/template/public/customer-brand.js +58 -0
- package/template/public/embed/embed.css +139 -0
- package/template/public/embed/embed.js +408 -0
- package/template/public/embed/index.html +91 -0
- package/template/public/favicon.svg +7 -0
- package/template/public/fonts/Manrope-Variable.ttf +0 -0
- package/template/public/fonts/Newsreader-Italic-Variable.ttf +0 -0
- package/template/public/fonts/Newsreader-Variable.ttf +0 -0
- package/template/public/index.html +123 -0
- package/template/public/logo-primary.svg +7 -0
- package/template/public/logo-reversed.svg +7 -0
- package/template/public/manifest.webmanifest +21 -0
- package/template/public/public-report.js +184 -0
- package/template/public/report/index.html +39 -0
- package/template/public/report/public-report.css +20 -0
- package/template/public/social-card.png +0 -0
- package/template/public/styles.css +1521 -0
- package/template/public/widget.css +80 -0
- package/template/public/widget.js +220 -0
- package/template/reports.config.example.json +38 -0
- package/template/reports.schema.json +89 -0
- package/template/scripts/reports-auth-onboarding.mjs +180 -0
- package/template/scripts/reports-backup.mjs +276 -0
- package/template/scripts/reports-cloudflare-preflight.mjs +152 -0
- package/template/scripts/reports-deploy.mjs +173 -0
- package/template/scripts/reports-plan.mjs +226 -0
- package/template/scripts/reports-restore.mjs +180 -0
- package/template/scripts/reports-secrets.mjs +73 -0
- package/template/scripts/reports-uninstall-plan.mjs +32 -0
- package/template/src/agent-executor.ts +330 -0
- package/template/src/ai-observability.ts +163 -0
- package/template/src/ai-registry-validation.ts +244 -0
- package/template/src/ai-registry.ts +292 -0
- package/template/src/api-cursor.ts +84 -0
- package/template/src/assistant.ts +554 -0
- package/template/src/audit.ts +39 -0
- package/template/src/backup-service.ts +269 -0
- package/template/src/budget-control.ts +127 -0
- package/template/src/component-executor.ts +845 -0
- package/template/src/configuration-registry.ts +550 -0
- package/template/src/connection-egress.ts +430 -0
- package/template/src/connection-oauth.ts +368 -0
- package/template/src/evidence-service.ts +335 -0
- package/template/src/human-tasks.ts +316 -0
- package/template/src/index.ts +4114 -0
- package/template/src/installation-admin.ts +301 -0
- package/template/src/intake-abuse.ts +156 -0
- package/template/src/platform-registry-validation.ts +367 -0
- package/template/src/platform-registry.ts +753 -0
- package/template/src/report-admin.ts +342 -0
- package/template/src/report-ai-quality.ts +390 -0
- package/template/src/report-ai-validation.ts +80 -0
- package/template/src/report-ai.ts +857 -0
- package/template/src/report-auth.ts +292 -0
- package/template/src/report-better-auth.ts +583 -0
- package/template/src/report-context-schema.ts +132 -0
- package/template/src/report-context.ts +126 -0
- package/template/src/report-crypto.ts +142 -0
- package/template/src/report-delivery.ts +579 -0
- package/template/src/report-form-validation.ts +51 -0
- package/template/src/report-governance.ts +510 -0
- package/template/src/report-http.ts +90 -0
- package/template/src/report-operations.ts +685 -0
- package/template/src/report-operator-accounts.ts +810 -0
- package/template/src/report-presence.ts +398 -0
- package/template/src/report-queue-validation.ts +309 -0
- package/template/src/report-queues.ts +478 -0
- package/template/src/report-repository.ts +972 -0
- package/template/src/report-router-agent.ts +328 -0
- package/template/src/report-router-validation.ts +81 -0
- package/template/src/report-routing.ts +178 -0
- package/template/src/report-turnstile.ts +142 -0
- package/template/src/report-types.ts +224 -0
- package/template/src/report-validation.ts +279 -0
- package/template/src/report-workflow-validation.ts +119 -0
- package/template/src/report-workflow.ts +886 -0
- package/template/src/shadow-quality.ts +278 -0
- package/template/src/workflow-actions.ts +782 -0
- package/template/src/workflow-compiler.ts +230 -0
- package/template/src/workflow-dynamic-runtime.ts +613 -0
- package/template/src/workflow-effects.ts +316 -0
- package/template/src/workflow-expressions.ts +191 -0
- package/template/src/workflow-platform-types.ts +121 -0
- package/template/src/workflow-platform-validation.ts +540 -0
- package/template/src/workflow-repository.ts +916 -0
- package/template/src/workflow-runs.ts +686 -0
- package/template/src/workflow-simulator.ts +211 -0
- package/template/src/workspace-branding.ts +128 -0
- package/template/src/workspace-governance.ts +279 -0
- package/template/tsconfig.console.json +23 -0
- package/template/tsconfig.json +22 -0
- package/template/vite.console.config.ts +20 -0
- package/template/worker-configuration.d.ts +65 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
const RESOURCE_NAME = /^[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/u;
|
|
5
|
+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u;
|
|
6
|
+
const ACCESS_AUD = /^[a-f0-9]{64}$/iu;
|
|
7
|
+
const AUTH_PROVIDERS = new Set(["google", "github", "cloudflare"]);
|
|
8
|
+
const AUTH_SECRET_NAMES = {
|
|
9
|
+
google: ["AUTH_GOOGLE_CLIENT_ID", "AUTH_GOOGLE_CLIENT_SECRET"],
|
|
10
|
+
github: ["AUTH_GITHUB_CLIENT_ID", "AUTH_GITHUB_CLIENT_SECRET"],
|
|
11
|
+
cloudflare: ["AUTH_CLOUDFLARE_CLIENT_ID", "AUTH_CLOUDFLARE_CLIENT_SECRET"],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export class ReportsConfigError extends Error {
|
|
15
|
+
constructor(message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "ReportsConfigError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function object(value, field) {
|
|
22
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new ReportsConfigError(`${field} must be an object`);
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function exactKeys(value, allowed, field) {
|
|
27
|
+
const unknown = Object.keys(value).find((key) => !allowed.has(key));
|
|
28
|
+
if (unknown) throw new ReportsConfigError(`${field} contains an unknown field: ${unknown}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function text(value, field, maximum = 500) {
|
|
32
|
+
if (typeof value !== "string" || !value.trim() || value.trim().length > maximum) {
|
|
33
|
+
throw new ReportsConfigError(`${field} must contain 1 to ${maximum} characters`);
|
|
34
|
+
}
|
|
35
|
+
return value.trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function resourceName(value, field) {
|
|
39
|
+
const result = text(value, field, 63).toLowerCase();
|
|
40
|
+
if (!RESOURCE_NAME.test(result)) throw new ReportsConfigError(`${field} must be a lowercase Cloudflare resource name`);
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function origin(value, field) {
|
|
45
|
+
let url;
|
|
46
|
+
try { url = new URL(text(value, field)); } catch { throw new ReportsConfigError(`${field} must be a valid URL origin`); }
|
|
47
|
+
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || (url.pathname && url.pathname !== "/")) {
|
|
48
|
+
throw new ReportsConfigError(`${field} must be an HTTPS origin without credentials, path, query, or fragment`);
|
|
49
|
+
}
|
|
50
|
+
return url.origin;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function integer(value, field, minimum, maximum) {
|
|
54
|
+
if (!Number.isInteger(value) || value < minimum || value > maximum) {
|
|
55
|
+
throw new ReportsConfigError(`${field} must be an integer from ${minimum} to ${maximum}`);
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function stringList(value, field, maximum, parse) {
|
|
61
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > maximum) {
|
|
62
|
+
throw new ReportsConfigError(`${field} must contain 1 to ${maximum} values`);
|
|
63
|
+
}
|
|
64
|
+
const result = value.map((entry, index) => parse(entry, `${field}[${index}]`));
|
|
65
|
+
if (new Set(result).size !== result.length) throw new ReportsConfigError(`${field} cannot contain duplicates`);
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseReportsConfig(value) {
|
|
70
|
+
const input = object(value, "configuration");
|
|
71
|
+
exactKeys(input, new Set(["$schema", "schemaVersion", "installationId", "resources", "publicBaseUrl", "allowedOrigins", "access", "email", "auth", "retention"]), "configuration");
|
|
72
|
+
if (input.schemaVersion !== 1) throw new ReportsConfigError("schemaVersion must be 1");
|
|
73
|
+
const resources = object(input.resources, "resources");
|
|
74
|
+
exactKeys(resources, new Set(["workerName", "databaseName", "profileMediaBucketName", "workflowArtifactsBucketName", "evidenceBucketName", "exportsBucketName", "workflowName", "reportQueueName", "deliveryQueueName", "operationsDlqName"]), "resources");
|
|
75
|
+
const access = object(input.access, "access");
|
|
76
|
+
exactKeys(access, new Set(["audience", "ownerEmails"]), "access");
|
|
77
|
+
const ownerEmails = stringList(access.ownerEmails, "access.ownerEmails", 20, (email, field) => {
|
|
78
|
+
const result = text(email, field, 254).toLowerCase();
|
|
79
|
+
if (!EMAIL.test(result)) throw new ReportsConfigError(`${field} is invalid`);
|
|
80
|
+
return result;
|
|
81
|
+
});
|
|
82
|
+
const retention = object(input.retention, "retention");
|
|
83
|
+
exactKeys(retention, new Set(["reportsDays", "messagesDays", "appealWindowDays"]), "retention");
|
|
84
|
+
const email = input.email === undefined ? { enabled: false, fromAddress: "" } : object(input.email, "email");
|
|
85
|
+
exactKeys(email, new Set(["enabled", "fromAddress"]), "email");
|
|
86
|
+
if (typeof email.enabled !== "boolean") throw new ReportsConfigError("email.enabled must be boolean");
|
|
87
|
+
const emailFromAddress = String(email.fromAddress ?? "").trim().toLowerCase();
|
|
88
|
+
if (email.enabled && !EMAIL.test(emailFromAddress)) throw new ReportsConfigError("email.fromAddress must be a valid onboarded sender when email is enabled");
|
|
89
|
+
if (!email.enabled && emailFromAddress) throw new ReportsConfigError("email.fromAddress must be empty when email is disabled");
|
|
90
|
+
const auth = input.auth === undefined ? { password: true, providers: [] } : object(input.auth, "auth");
|
|
91
|
+
exactKeys(auth, new Set(["password", "providers"]), "auth");
|
|
92
|
+
if (auth.password !== true) throw new ReportsConfigError("auth.password must be true because password recovery is a required sign-in method");
|
|
93
|
+
if (!Array.isArray(auth.providers) || auth.providers.length > AUTH_PROVIDERS.size) {
|
|
94
|
+
throw new ReportsConfigError("auth.providers must be an array with up to three providers");
|
|
95
|
+
}
|
|
96
|
+
const authProviders = auth.providers.map((provider, index) => text(provider, `auth.providers[${index}]`, 20).toLowerCase());
|
|
97
|
+
if (authProviders.some((provider) => !AUTH_PROVIDERS.has(provider))) {
|
|
98
|
+
throw new ReportsConfigError("auth.providers may contain only google, github, or cloudflare");
|
|
99
|
+
}
|
|
100
|
+
if (new Set(authProviders).size !== authProviders.length) throw new ReportsConfigError("auth.providers cannot contain duplicates");
|
|
101
|
+
const parsed = {
|
|
102
|
+
schemaVersion: 1,
|
|
103
|
+
installationId: resourceName(input.installationId, "installationId"),
|
|
104
|
+
resources: {
|
|
105
|
+
workerName: resourceName(resources.workerName, "resources.workerName"),
|
|
106
|
+
databaseName: resourceName(resources.databaseName, "resources.databaseName"),
|
|
107
|
+
profileMediaBucketName: resourceName(resources.profileMediaBucketName, "resources.profileMediaBucketName"),
|
|
108
|
+
workflowArtifactsBucketName: resourceName(resources.workflowArtifactsBucketName, "resources.workflowArtifactsBucketName"),
|
|
109
|
+
evidenceBucketName: resourceName(resources.evidenceBucketName, "resources.evidenceBucketName"),
|
|
110
|
+
exportsBucketName: resourceName(resources.exportsBucketName, "resources.exportsBucketName"),
|
|
111
|
+
workflowName: resourceName(resources.workflowName, "resources.workflowName"),
|
|
112
|
+
reportQueueName: resourceName(resources.reportQueueName, "resources.reportQueueName"),
|
|
113
|
+
deliveryQueueName: resourceName(resources.deliveryQueueName, "resources.deliveryQueueName"),
|
|
114
|
+
operationsDlqName: resourceName(resources.operationsDlqName, "resources.operationsDlqName"),
|
|
115
|
+
},
|
|
116
|
+
publicBaseUrl: origin(input.publicBaseUrl, "publicBaseUrl"),
|
|
117
|
+
allowedOrigins: stringList(input.allowedOrigins, "allowedOrigins", 20, origin),
|
|
118
|
+
access: {
|
|
119
|
+
audience: text(access.audience, "access.audience", 200),
|
|
120
|
+
ownerEmails,
|
|
121
|
+
},
|
|
122
|
+
email: { enabled: email.enabled, fromAddress: emailFromAddress },
|
|
123
|
+
auth: { password: true, providers: authProviders },
|
|
124
|
+
retention: {
|
|
125
|
+
reportsDays: integer(retention.reportsDays, "retention.reportsDays", 30, 3_650),
|
|
126
|
+
messagesDays: integer(retention.messagesDays, "retention.messagesDays", 30, 3_650),
|
|
127
|
+
appealWindowDays: integer(retention.appealWindowDays, "retention.appealWindowDays", 1, 365),
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
const names = Object.values(parsed.resources);
|
|
131
|
+
if (new Set(names).size !== names.length) throw new ReportsConfigError("every Cloudflare resource name must be unique");
|
|
132
|
+
return parsed;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function buildReportsPlan(config) {
|
|
136
|
+
const accessReady = ACCESS_AUD.test(config.access.audience);
|
|
137
|
+
const emailReady = config.email.enabled;
|
|
138
|
+
const publicHostname = new URL(config.publicBaseUrl).hostname;
|
|
139
|
+
const ownerBootstrapPath = `${publicHostname}/v1/infrastructure/*`;
|
|
140
|
+
const customDomain = publicHostname.endsWith(".workers.dev") ? null : publicHostname;
|
|
141
|
+
return {
|
|
142
|
+
product: "Safest Resolve",
|
|
143
|
+
schemaVersion: 1,
|
|
144
|
+
installationId: config.installationId,
|
|
145
|
+
ownership: "All resources, data, secrets, policies, and logs remain in the customer's Cloudflare account.",
|
|
146
|
+
ready: accessReady && emailReady,
|
|
147
|
+
blockers: [
|
|
148
|
+
...(!accessReady ? ["Create the customer-owned Cloudflare Access application and replace access.audience with its 64-character AUD."] : []),
|
|
149
|
+
...(!emailReady ? ["Onboard the sender domain in Cloudflare Email Sending and set email.fromAddress; invitations and password recovery cannot be deployed without email delivery."] : []),
|
|
150
|
+
],
|
|
151
|
+
resources: {
|
|
152
|
+
worker: config.resources.workerName,
|
|
153
|
+
d1Database: config.resources.databaseName,
|
|
154
|
+
r2Buckets: [config.resources.profileMediaBucketName, config.resources.workflowArtifactsBucketName, config.resources.evidenceBucketName, config.resources.exportsBucketName],
|
|
155
|
+
dynamicWorkflow: config.resources.workflowName,
|
|
156
|
+
queues: [config.resources.reportQueueName, config.resources.deliveryQueueName, config.resources.operationsDlqName],
|
|
157
|
+
durableObject: "WorkspacePresence (SQLite-backed, declared by Worker migration)",
|
|
158
|
+
workersAiBinding: "AI",
|
|
159
|
+
emailServiceBinding: `EMAIL (${config.email.fromAddress || "not configured"})`,
|
|
160
|
+
staticAssets: "public/",
|
|
161
|
+
publicHostname,
|
|
162
|
+
customDomain: customDomain ?? "workers.dev hostname (no custom domain mutation)",
|
|
163
|
+
secrets: [
|
|
164
|
+
"BETTER_AUTH_SECRET", "ADMIN_API_KEY", "CONTEXT_SIGNING_SECRET", "CREDENTIAL_ENCRYPTION_KEY", "RATE_LIMIT_PEPPER", "INTEGRATION_API_KEYS_JSON",
|
|
165
|
+
...config.auth.providers.flatMap((provider) => AUTH_SECRET_NAMES[provider]),
|
|
166
|
+
],
|
|
167
|
+
optionalSecrets: ["TURNSTILE_SECRET_KEY", "ACTION_WEBHOOK_SECRET", "NOTIFICATION_WEBHOOK_SECRET", "OPERATOR_NOTIFICATION_WEBHOOK_SECRET", "one secret per published connection or enrichment"],
|
|
168
|
+
authentication: {
|
|
169
|
+
password: true,
|
|
170
|
+
passwordRecovery: "email",
|
|
171
|
+
oauthProviders: config.auth.providers,
|
|
172
|
+
callbackUrls: Object.fromEntries(config.auth.providers.map((provider) => [provider, `${config.publicBaseUrl}/api/auth/callback/${provider}`])),
|
|
173
|
+
},
|
|
174
|
+
access: {
|
|
175
|
+
applicationPath: ownerBootstrapPath,
|
|
176
|
+
audience: accessReady ? config.access.audience : "not configured",
|
|
177
|
+
ownerEmails: config.access.ownerEmails,
|
|
178
|
+
purpose: "Infrastructure-owner bootstrap and recovery only. Invited admins and analysts use Safest accounts.",
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
networkContract: {
|
|
182
|
+
publicBaseUrl: config.publicBaseUrl,
|
|
183
|
+
allowedOrigins: config.allowedOrigins,
|
|
184
|
+
requestInspection: false,
|
|
185
|
+
reportSubmissionOnly: true,
|
|
186
|
+
},
|
|
187
|
+
order: [
|
|
188
|
+
"Review this plan, the infrastructure-owner allowlist, and the Access application path.",
|
|
189
|
+
"Create or select the Cloudflare Access application for /v1/infrastructure/* and record its AUD.",
|
|
190
|
+
"Onboard the sender domain to Cloudflare Email Service before deployment.",
|
|
191
|
+
"Initialize local secrets without committing them.",
|
|
192
|
+
"Create D1, four private R2 buckets, and the three Queues in the customer account.",
|
|
193
|
+
"Apply all report migrations before accepting traffic.",
|
|
194
|
+
`Deploy the Worker, assets, Worker Loader, Dynamic Workflow, presence Durable Object, Queue consumers, cron, Workers AI binding${customDomain ? `, and ${customDomain} Custom Domain` : ""}.`,
|
|
195
|
+
"Bootstrap the infrastructure owner, verify required setup features and authenticated /ready, invite an administrator, invite an analyst, then verify report intake, routing, and delivery recovery.",
|
|
196
|
+
],
|
|
197
|
+
lifecycle: {
|
|
198
|
+
backup: "D1 SQL export plus all four private R2 buckets, stable inventory, per-object SHA-256 digests, and a durable verification record; Queue and live Workflow state remain separate.",
|
|
199
|
+
restore: "Verify every D1/R2 digest locally, then produce a non-mutating plan for a separately named target environment with live effects disabled.",
|
|
200
|
+
upgrade: "Backup first, apply forward-only migrations, then deploy and verify.",
|
|
201
|
+
uninstall: "Plan only. No generic uninstall command is generated; inspect exact owned resources and take a fresh backup before manual removal.",
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function loadReportsPlan(path = "reports.config.json") {
|
|
207
|
+
const absolute = resolve(path);
|
|
208
|
+
let value;
|
|
209
|
+
try { value = JSON.parse(await readFile(absolute, "utf8")); } catch (error) {
|
|
210
|
+
if (error?.code === "ENOENT") throw new ReportsConfigError(`configuration not found: ${absolute}`);
|
|
211
|
+
if (error instanceof SyntaxError) throw new ReportsConfigError(`configuration is not valid JSON: ${error.message}`);
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
const config = parseReportsConfig(value);
|
|
215
|
+
return { config, plan: buildReportsPlan(config), path: absolute };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (process.argv[1] && resolve(process.argv[1]) === new URL(import.meta.url).pathname) {
|
|
219
|
+
loadReportsPlan(process.argv[2] ?? "reports.config.json").then(({ plan }) => {
|
|
220
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
221
|
+
if (!plan.ready) process.exitCode = 2;
|
|
222
|
+
}).catch((error) => {
|
|
223
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
224
|
+
process.exitCode = 1;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { loadReportsPlan } from "./reports-plan.mjs";
|
|
7
|
+
|
|
8
|
+
const projectRoot = fileURLToPath(new URL("..", import.meta.url));
|
|
9
|
+
const BUCKETS = new Set(["profile_media", "workflow_artifacts", "evidence", "exports"]);
|
|
10
|
+
|
|
11
|
+
function parseArguments(argv) {
|
|
12
|
+
const result = { mode: null, directory: null, targetConfig: "reports.restore.config.json" };
|
|
13
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
14
|
+
const argument = argv[index];
|
|
15
|
+
if (argument === "--verify" || argument === "--plan") {
|
|
16
|
+
if (result.mode) throw new Error("choose exactly one of --verify or --plan");
|
|
17
|
+
result.mode = argument.slice(2);
|
|
18
|
+
const value = argv[index + 1];
|
|
19
|
+
if (!value || value.startsWith("-")) throw new Error(`${argument} needs a backup directory`);
|
|
20
|
+
result.directory = value;
|
|
21
|
+
index += 1;
|
|
22
|
+
} else if (argument === "--target-config") {
|
|
23
|
+
const value = argv[index + 1];
|
|
24
|
+
if (!value || value.startsWith("-")) throw new Error("--target-config needs a path");
|
|
25
|
+
result.targetConfig = value;
|
|
26
|
+
index += 1;
|
|
27
|
+
} else throw new Error(`unknown option: ${argument}`);
|
|
28
|
+
}
|
|
29
|
+
if (!result.mode || !result.directory) throw new Error("use --verify <backup-directory> or --plan <backup-directory>");
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function hashFile(path) {
|
|
34
|
+
const hash = createHash("sha256");
|
|
35
|
+
let bytes = 0;
|
|
36
|
+
for await (const chunk of createReadStream(path)) {
|
|
37
|
+
hash.update(chunk);
|
|
38
|
+
bytes += chunk.length;
|
|
39
|
+
}
|
|
40
|
+
return { digest: `sha256:${hash.digest("hex")}`, bytes };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function safeRelativeFile(directory, value, field) {
|
|
44
|
+
if (typeof value !== "string" || !value || isAbsolute(value) || value.includes("\\") || value.split("/").includes("..")) {
|
|
45
|
+
throw new Error(`${field} is not a safe relative backup path`);
|
|
46
|
+
}
|
|
47
|
+
const path = resolve(directory, value);
|
|
48
|
+
const inside = relative(directory, path);
|
|
49
|
+
if (!inside || inside.startsWith("..") || isAbsolute(inside)) throw new Error(`${field} escapes the backup directory`);
|
|
50
|
+
return path;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function jsonFile(path, maximumBytes) {
|
|
54
|
+
const metadata = await stat(path);
|
|
55
|
+
if (!metadata.isFile() || metadata.size > maximumBytes) throw new Error(`backup manifest is missing or larger than ${maximumBytes} bytes`);
|
|
56
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function expectedDigest(value, field) {
|
|
60
|
+
if (typeof value !== "string" || !/^sha256:[0-9a-f]{64}$/u.test(value)) throw new Error(`${field} is not a SHA-256 digest`);
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function verifyBackup(directoryValue) {
|
|
65
|
+
const directory = await realpath(resolve(projectRoot, directoryValue));
|
|
66
|
+
const manifestPath = resolve(directory, "manifest.json");
|
|
67
|
+
const manifest = await jsonFile(manifestPath, 2 * 1024 * 1024);
|
|
68
|
+
if (manifest?.schemaVersion !== 2 || manifest.status !== "verified" || typeof manifest.backupId !== "string") {
|
|
69
|
+
throw new Error("manifest.json is not a verified Safest backup v2 manifest");
|
|
70
|
+
}
|
|
71
|
+
const manifestHash = await hashFile(manifestPath);
|
|
72
|
+
const sidecar = (await readFile(resolve(directory, "manifest.sha256"), "utf8")).trim().split(/\s+/u)[0];
|
|
73
|
+
if (manifestHash.digest !== sidecar) throw new Error("manifest.json does not match manifest.sha256");
|
|
74
|
+
const databasePath = safeRelativeFile(directory, manifest.d1?.file, "manifest.d1.file");
|
|
75
|
+
const database = await hashFile(databasePath);
|
|
76
|
+
if (database.digest !== expectedDigest(manifest.d1?.digest, "manifest.d1.digest") || database.bytes !== manifest.d1?.bytes) {
|
|
77
|
+
throw new Error("database.sql failed digest or size verification");
|
|
78
|
+
}
|
|
79
|
+
const r2ManifestPath = safeRelativeFile(directory, manifest.r2?.manifest, "manifest.r2.manifest");
|
|
80
|
+
const r2ManifestHash = await hashFile(r2ManifestPath);
|
|
81
|
+
if (r2ManifestHash.digest !== expectedDigest(manifest.r2?.digest, "manifest.r2.digest")) throw new Error("r2-manifest.json failed digest verification");
|
|
82
|
+
const r2 = await jsonFile(r2ManifestPath, 256 * 1024 * 1024);
|
|
83
|
+
if (r2?.schemaVersion !== 1 || r2.backupId !== manifest.backupId || !r2.verifiedStableInventory || !Array.isArray(r2.objects)) {
|
|
84
|
+
throw new Error("r2-manifest.json is invalid or belongs to another backup");
|
|
85
|
+
}
|
|
86
|
+
const seenObjects = new Set();
|
|
87
|
+
const seenFiles = new Set();
|
|
88
|
+
let objectBytes = 0;
|
|
89
|
+
for (const object of r2.objects) {
|
|
90
|
+
if (!object || !BUCKETS.has(object.bucket) || typeof object.key !== "string" || !object.key || typeof object.etag !== "string") {
|
|
91
|
+
throw new Error("r2-manifest.json contains an invalid object record");
|
|
92
|
+
}
|
|
93
|
+
const identity = `${object.bucket}\0${object.key}`;
|
|
94
|
+
if (seenObjects.has(identity)) throw new Error("r2-manifest.json contains a duplicate bucket/key");
|
|
95
|
+
seenObjects.add(identity);
|
|
96
|
+
if (seenFiles.has(object.file)) throw new Error("r2-manifest.json reuses an object file");
|
|
97
|
+
seenFiles.add(object.file);
|
|
98
|
+
const objectPath = safeRelativeFile(directory, object.file, "R2 object file");
|
|
99
|
+
const actual = await hashFile(objectPath);
|
|
100
|
+
if (actual.digest !== expectedDigest(object.digest, "R2 object digest") || actual.bytes !== object.size) {
|
|
101
|
+
throw new Error(`R2 object failed digest or size verification: ${object.bucket}/${object.key}`);
|
|
102
|
+
}
|
|
103
|
+
objectBytes += actual.bytes;
|
|
104
|
+
}
|
|
105
|
+
if (r2.objectCount !== r2.objects.length || manifest.r2?.objectCount !== r2.objects.length
|
|
106
|
+
|| r2.objectBytes !== objectBytes || manifest.r2?.objectBytes !== objectBytes) {
|
|
107
|
+
throw new Error("R2 object counts or byte totals do not match the manifests");
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
directory,
|
|
111
|
+
manifest,
|
|
112
|
+
r2,
|
|
113
|
+
verification: {
|
|
114
|
+
manifestDigest: manifestHash.digest,
|
|
115
|
+
d1Digest: database.digest,
|
|
116
|
+
r2ManifestDigest: r2ManifestHash.digest,
|
|
117
|
+
objectCount: r2.objects.length,
|
|
118
|
+
objectBytes,
|
|
119
|
+
allObjectDigests: true,
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function restoreReports(argv = process.argv.slice(2)) {
|
|
125
|
+
const options = parseArguments(argv);
|
|
126
|
+
const verified = await verifyBackup(options.directory);
|
|
127
|
+
if (options.mode === "verify") {
|
|
128
|
+
const result = { action: "restore_verify", backupId: verified.manifest.backupId, status: "verified", ...verified.verification };
|
|
129
|
+
console.log(JSON.stringify(result, null, 2));
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
const { config: target } = await loadReportsPlan(options.targetConfig);
|
|
133
|
+
if (target.installationId === verified.manifest.installationId) {
|
|
134
|
+
throw new Error("restore planning requires a different installationId so production data is first restored into a separate environment");
|
|
135
|
+
}
|
|
136
|
+
if (verified.manifest.sourcePublicBaseUrl && target.publicBaseUrl === verified.manifest.sourcePublicBaseUrl) {
|
|
137
|
+
throw new Error("restore planning requires a different publicBaseUrl from the source environment");
|
|
138
|
+
}
|
|
139
|
+
const bucketNames = {
|
|
140
|
+
profile_media: target.resources.profileMediaBucketName,
|
|
141
|
+
workflow_artifacts: target.resources.workflowArtifactsBucketName,
|
|
142
|
+
evidence: target.resources.evidenceBucketName,
|
|
143
|
+
exports: target.resources.exportsBucketName,
|
|
144
|
+
};
|
|
145
|
+
const plan = {
|
|
146
|
+
action: "restore_plan_only",
|
|
147
|
+
mutatesCloudflare: false,
|
|
148
|
+
backupId: verified.manifest.backupId,
|
|
149
|
+
sourceInstallationId: verified.manifest.installationId,
|
|
150
|
+
targetInstallationId: target.installationId,
|
|
151
|
+
targetPublicBaseUrl: target.publicBaseUrl,
|
|
152
|
+
verification: verified.verification,
|
|
153
|
+
order: [
|
|
154
|
+
"Provision the separate target environment from its reviewed setup plan.",
|
|
155
|
+
"Provision new target secrets separately; never copy secrets from the backup because they are not included.",
|
|
156
|
+
`Import database.sql into the target D1 database ${target.resources.databaseName}.`,
|
|
157
|
+
`Restore and verify ${verified.r2.objects.length} objects into the four exact target R2 buckets.`,
|
|
158
|
+
"Keep action, email, notification, and external connection delivery disabled in the target environment.",
|
|
159
|
+
"Verify workflow artifact digests, run authenticated setup checks, and create operations incidents for interrupted nonterminal runs.",
|
|
160
|
+
"Treat pending Queues and live Workflow engine state as nonrestorable; reconcile from authoritative D1 records.",
|
|
161
|
+
],
|
|
162
|
+
d1: { sourceFile: "database.sql", targetDatabase: target.resources.databaseName },
|
|
163
|
+
r2: {
|
|
164
|
+
targetBuckets: bucketNames,
|
|
165
|
+
objectCount: verified.r2.objects.length,
|
|
166
|
+
objectBytes: verified.verification.objectBytes,
|
|
167
|
+
objects: verified.r2.objects.map((object) => ({ bucket: bucketNames[object.bucket], key: object.key, sourceFile: object.file, digest: object.digest })),
|
|
168
|
+
},
|
|
169
|
+
prohibited: ["restoring directly over the source installation", "restoring source secrets", "connecting restored staging data to live effects", "claiming Queue or Workflow engine state was captured"],
|
|
170
|
+
};
|
|
171
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
172
|
+
return plan;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
176
|
+
restoreReports().catch((error) => {
|
|
177
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
178
|
+
process.exitCode = 1;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function createSecrets() {
|
|
6
|
+
const random = () => randomBytes(32).toString("base64url");
|
|
7
|
+
return {
|
|
8
|
+
BETTER_AUTH_SECRET: random(),
|
|
9
|
+
ADMIN_API_KEY: random(),
|
|
10
|
+
CONTEXT_SIGNING_SECRET: random(),
|
|
11
|
+
CREDENTIAL_ENCRYPTION_KEY: random(),
|
|
12
|
+
RATE_LIMIT_PEPPER: random(),
|
|
13
|
+
INTEGRATION_API_KEYS_JSON: JSON.stringify({ default: random() }),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function renderSecrets(secrets) {
|
|
18
|
+
return `${Object.entries(secrets).map(([name, value]) => `${name}=${value}`).join("\n")}\n`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseSecrets(raw) {
|
|
22
|
+
const values = {};
|
|
23
|
+
for (const line of raw.split(/\r?\n/u)) {
|
|
24
|
+
const normalized = line.trim();
|
|
25
|
+
if (!normalized || normalized.startsWith("#")) continue;
|
|
26
|
+
const separator = normalized.indexOf("=");
|
|
27
|
+
if (separator > 0) values[normalized.slice(0, separator).trim()] = normalized.slice(separator + 1).trim();
|
|
28
|
+
}
|
|
29
|
+
return values;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function readSecrets(path = ".safest/secrets.env") {
|
|
33
|
+
return parseSecrets(await readFile(resolve(path), "utf8"));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function updateSecrets(path, secrets) {
|
|
37
|
+
const absolute = resolve(path);
|
|
38
|
+
const temporary = `${absolute}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
39
|
+
await mkdir(dirname(absolute), { recursive: true, mode: 0o700 });
|
|
40
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
41
|
+
try {
|
|
42
|
+
await handle.writeFile(renderSecrets(secrets), "utf8");
|
|
43
|
+
} finally {
|
|
44
|
+
await handle.close();
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
await rename(temporary, absolute);
|
|
48
|
+
await chmod(absolute, 0o600);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
await unlink(temporary).catch(() => {});
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
return absolute;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function initializeSecrets(path = ".safest/secrets.env") {
|
|
57
|
+
const absolute = resolve(path);
|
|
58
|
+
await mkdir(dirname(absolute), { recursive: true, mode: 0o700 });
|
|
59
|
+
const handle = await open(absolute, "wx", 0o600);
|
|
60
|
+
try { await handle.writeFile(renderSecrets(createSecrets()), "utf8"); } finally { await handle.close(); }
|
|
61
|
+
await chmod(absolute, 0o600);
|
|
62
|
+
return absolute;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (process.argv[1] && resolve(process.argv[1]) === new URL(import.meta.url).pathname) {
|
|
66
|
+
initializeSecrets(process.argv[2]).then((path) => {
|
|
67
|
+
console.log(`Created ${path} with owner-only permissions. Store it securely and do not commit it.`);
|
|
68
|
+
}).catch((error) => {
|
|
69
|
+
if (error?.code === "EEXIST") console.error("Refusing to overwrite the existing secrets file.");
|
|
70
|
+
else console.error(error instanceof Error ? error.message : String(error));
|
|
71
|
+
process.exitCode = 1;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { loadReportsPlan } from "./reports-plan.mjs";
|
|
2
|
+
|
|
3
|
+
export async function buildReportsUninstallPlan() {
|
|
4
|
+
const { config } = await loadReportsPlan();
|
|
5
|
+
return {
|
|
6
|
+
action: "uninstall_plan_only",
|
|
7
|
+
installationId: config.installationId,
|
|
8
|
+
warning: "This command never changes Cloudflare. Take and verify a fresh backup, drain Queues, inspect bindings and dependencies, then remove only the exact customer-owned resources below.",
|
|
9
|
+
inspectBeforeRemoval: [
|
|
10
|
+
"Queue backlog and dead letters",
|
|
11
|
+
"Worker service bindings and custom domains",
|
|
12
|
+
"verified D1 and R2 backup manifests and checksums",
|
|
13
|
+
"Access application policies",
|
|
14
|
+
"enrichment, notification, and action webhook dependencies",
|
|
15
|
+
],
|
|
16
|
+
exactResources: {
|
|
17
|
+
worker: config.resources.workerName,
|
|
18
|
+
queues: [config.resources.reportQueueName, config.resources.deliveryQueueName, config.resources.operationsDlqName],
|
|
19
|
+
database: config.resources.databaseName,
|
|
20
|
+
r2Buckets: [config.resources.profileMediaBucketName, config.resources.workflowArtifactsBucketName, config.resources.evidenceBucketName, config.resources.exportsBucketName],
|
|
21
|
+
dynamicWorkflow: config.resources.workflowName,
|
|
22
|
+
accessAudience: config.access.audience,
|
|
23
|
+
},
|
|
24
|
+
defaultPreservation: ["D1 database", "all private R2 buckets", "Access application", "independent secrets recovery", "verified D1/R2 archive manifests"],
|
|
25
|
+
note: "Removal stays manual so data, dependencies, and exact resource ownership can be verified immediately beforehand.",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
buildReportsUninstallPlan().then((plan) => console.log(JSON.stringify(plan, null, 2))).catch((error) => {
|
|
30
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
});
|