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.
Files changed (240) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +71 -0
  3. package/bin/create-safest-tools.mjs +12 -0
  4. package/package.json +34 -0
  5. package/src/cli.mjs +128 -0
  6. package/src/config.mjs +151 -0
  7. package/src/scaffold.mjs +30 -0
  8. package/template/LICENSE +201 -0
  9. package/template/README.md +35 -0
  10. package/template/console/README.md +28 -0
  11. package/template/console/analytics/AnalyticsWorkspace.tsx +55 -0
  12. package/template/console/analytics/controller.ts +133 -0
  13. package/template/console/analytics/events.ts +33 -0
  14. package/template/console/analytics/store.ts +33 -0
  15. package/template/console/analytics/types.ts +38 -0
  16. package/template/console/appeals/AppealWorkspace.tsx +73 -0
  17. package/template/console/appeals/controller.ts +125 -0
  18. package/template/console/appeals/events.ts +33 -0
  19. package/template/console/appeals/store.ts +35 -0
  20. package/template/console/appeals/types.ts +37 -0
  21. package/template/console/assistant/AssistantWorkspace.tsx +133 -0
  22. package/template/console/assistant/types.ts +34 -0
  23. package/template/console/auth/AccountJourney.tsx +69 -0
  24. package/template/console/auth/InvitationAcceptance.tsx +145 -0
  25. package/template/console/auth/OAuthButtons.tsx +78 -0
  26. package/template/console/auth/OperatorLogin.tsx +110 -0
  27. package/template/console/auth/PasswordRecovery.tsx +132 -0
  28. package/template/console/auth/account-route.ts +15 -0
  29. package/template/console/auth/browser-navigation.ts +7 -0
  30. package/template/console/auth/events.ts +15 -0
  31. package/template/console/command/CommandCentre.tsx +119 -0
  32. package/template/console/command/controller.ts +267 -0
  33. package/template/console/command/events.ts +19 -0
  34. package/template/console/command/store.ts +36 -0
  35. package/template/console/command/types.ts +61 -0
  36. package/template/console/components/AnalystIdentity.tsx +25 -0
  37. package/template/console/components/PageHeader.tsx +27 -0
  38. package/template/console/configuration/ConfigurationDialog.tsx +200 -0
  39. package/template/console/configuration/ConfigurationWorkspace.tsx +66 -0
  40. package/template/console/configuration/FormBuilder.tsx +143 -0
  41. package/template/console/configuration/api.ts +40 -0
  42. package/template/console/configuration/events.ts +14 -0
  43. package/template/console/configuration/types.ts +79 -0
  44. package/template/console/lib/format.ts +49 -0
  45. package/template/console/lib/http.ts +96 -0
  46. package/template/console/main.tsx +225 -0
  47. package/template/console/operations/OperationsWorkspace.tsx +126 -0
  48. package/template/console/operations/controller.ts +237 -0
  49. package/template/console/operations/events.ts +37 -0
  50. package/template/console/operations/store.ts +32 -0
  51. package/template/console/operations/types.ts +107 -0
  52. package/template/console/people/PeopleWorkspace.tsx +81 -0
  53. package/template/console/people/types.ts +25 -0
  54. package/template/console/profile/ProfileWorkspace.tsx +136 -0
  55. package/template/console/profile/events.ts +9 -0
  56. package/template/console/profile/types.ts +6 -0
  57. package/template/console/quality/QualityWorkspace.tsx +87 -0
  58. package/template/console/quality/controller.ts +154 -0
  59. package/template/console/quality/events.ts +34 -0
  60. package/template/console/quality/store.ts +36 -0
  61. package/template/console/quality/types.ts +70 -0
  62. package/template/console/queues/QueueEditor.tsx +173 -0
  63. package/template/console/queues/QueueWorkspace.tsx +109 -0
  64. package/template/console/queues/controller.ts +194 -0
  65. package/template/console/queues/events.ts +34 -0
  66. package/template/console/queues/store.ts +37 -0
  67. package/template/console/queues/types.ts +102 -0
  68. package/template/console/registry/RegistryDialog.tsx +198 -0
  69. package/template/console/registry/RegistryWorkspace.tsx +108 -0
  70. package/template/console/registry/events.ts +14 -0
  71. package/template/console/registry/types.ts +52 -0
  72. package/template/console/reports/ReportDrawer.tsx +159 -0
  73. package/template/console/reports/ReportWorkspace.tsx +90 -0
  74. package/template/console/reports/controller.ts +517 -0
  75. package/template/console/reports/events.ts +42 -0
  76. package/template/console/reports/store.ts +39 -0
  77. package/template/console/reports/types.ts +230 -0
  78. package/template/console/settings/BrandEditor.tsx +126 -0
  79. package/template/console/settings/ChannelDialog.tsx +241 -0
  80. package/template/console/settings/SettingsWorkspace.tsx +155 -0
  81. package/template/console/settings/events.ts +19 -0
  82. package/template/console/settings/handoff.ts +22 -0
  83. package/template/console/settings/types.ts +93 -0
  84. package/template/console/shell/WorkspaceShell.tsx +160 -0
  85. package/template/console/shell/controller.ts +182 -0
  86. package/template/console/shell/events.ts +25 -0
  87. package/template/console/shell/navigation.ts +61 -0
  88. package/template/console/shell/store.ts +56 -0
  89. package/template/console/shell/types.ts +86 -0
  90. package/template/console/workflows/CreateWorkflowDialog.tsx +87 -0
  91. package/template/console/workflows/WorkflowCanvas.tsx +42 -0
  92. package/template/console/workflows/WorkflowDialogs.tsx +6 -0
  93. package/template/console/workflows/WorkflowStudio.tsx +331 -0
  94. package/template/console/workflows/WorkflowWorkspace.tsx +82 -0
  95. package/template/console/workflows/events.ts +17 -0
  96. package/template/console/workflows/graph.ts +156 -0
  97. package/template/console/workflows/templates.ts +70 -0
  98. package/template/console/workflows/types.ts +201 -0
  99. package/template/gitignore.template +18 -0
  100. package/template/migrations/0001_reports_foundation.sql +444 -0
  101. package/template/migrations/0002_human_report_loop.sql +65 -0
  102. package/template/migrations/0003_delivery_reliability.sql +18 -0
  103. package/template/migrations/0004_public_intake.sql +14 -0
  104. package/template/migrations/0005_operations_visibility.sql +24 -0
  105. package/template/migrations/0006_ai_governance.sql +198 -0
  106. package/template/migrations/0007_ai_release_gates.sql +6 -0
  107. package/template/migrations/0008_retention_analytics_exports.sql +52 -0
  108. package/template/migrations/0009_retention_derived_copies.sql +14 -0
  109. package/template/migrations/0010_ai_quality_controls.sql +26 -0
  110. package/template/migrations/0011_analyst_presence.sql +28 -0
  111. package/template/migrations/0012_queue_policies.sql +87 -0
  112. package/template/migrations/0013_routing_agents.sql +63 -0
  113. package/template/migrations/0014_webhook_enrichments.sql +122 -0
  114. package/template/migrations/0015_queue_owned_ai.sql +55 -0
  115. package/template/migrations/0016_operator_accounts.sql +67 -0
  116. package/template/migrations/0017_operator_profiles_and_recovery.sql +33 -0
  117. package/template/migrations/0018_platform_configuration.sql +382 -0
  118. package/template/migrations/0019_workflow_authoring_runtime.sql +372 -0
  119. package/template/migrations/0020_tasks_findings_assistant_budgets.sql +427 -0
  120. package/template/migrations/0021_abuse_evidence_operations.sql +324 -0
  121. package/template/migrations/0022_workflow_dispatch_operations.sql +42 -0
  122. package/template/migrations/0023_component_connection_execution.sql +75 -0
  123. package/template/migrations/0024_access_runtime_integrity.sql +72 -0
  124. package/template/migrations/0025_installation_timezone.sql +11 -0
  125. package/template/migrations/0026_ai_and_egress_execution_controls.sql +49 -0
  126. package/template/migrations/0027_prompt_and_ai_registry.sql +37 -0
  127. package/template/migrations/0028_step_attempt_ai_provenance.sql +13 -0
  128. package/template/migrations/0029_evidence_fetch_transport.sql +5 -0
  129. package/template/migrations/0030_evidence_dlq_incidents.sql +45 -0
  130. package/template/migrations/0031_shadow_quality_integrity.sql +7 -0
  131. package/template/migrations/0032_action_delivery_outbox.sql +55 -0
  132. package/template/migrations/0033_configuration_and_assistant_drafts.sql +43 -0
  133. package/template/migrations/0034_installation_integrations.sql +31 -0
  134. package/template/migrations/0035_workspace_governance.sql +21 -0
  135. package/template/migrations/0036_published_routing_baseline.sql +15 -0
  136. package/template/migrations/0037_builtin_phishing_specialist.sql +71 -0
  137. package/template/migrations/0038_remove_deprecated_enrichment_runtime.sql +228 -0
  138. package/template/migrations/0039_secure_reporting_channels.sql +45 -0
  139. package/template/migrations/0040_notification_only_reporting.sql +24 -0
  140. package/template/migrations/0041_better_auth_credentials.sql +17 -0
  141. package/template/package.json +47 -0
  142. package/template/public/_headers +27 -0
  143. package/template/public/app-icon-192.png +0 -0
  144. package/template/public/app-icon-512.png +0 -0
  145. package/template/public/brand-icon.svg +7 -0
  146. package/template/public/brand-tokens.css +80 -0
  147. package/template/public/console/auth-shell.js +19340 -0
  148. package/template/public/customer-brand.js +58 -0
  149. package/template/public/embed/embed.css +139 -0
  150. package/template/public/embed/embed.js +408 -0
  151. package/template/public/embed/index.html +91 -0
  152. package/template/public/favicon.svg +7 -0
  153. package/template/public/fonts/Manrope-Variable.ttf +0 -0
  154. package/template/public/fonts/Newsreader-Italic-Variable.ttf +0 -0
  155. package/template/public/fonts/Newsreader-Variable.ttf +0 -0
  156. package/template/public/index.html +123 -0
  157. package/template/public/logo-primary.svg +7 -0
  158. package/template/public/logo-reversed.svg +7 -0
  159. package/template/public/manifest.webmanifest +21 -0
  160. package/template/public/public-report.js +184 -0
  161. package/template/public/report/index.html +39 -0
  162. package/template/public/report/public-report.css +20 -0
  163. package/template/public/social-card.png +0 -0
  164. package/template/public/styles.css +1521 -0
  165. package/template/public/widget.css +80 -0
  166. package/template/public/widget.js +220 -0
  167. package/template/reports.config.example.json +38 -0
  168. package/template/reports.schema.json +89 -0
  169. package/template/scripts/reports-auth-onboarding.mjs +180 -0
  170. package/template/scripts/reports-backup.mjs +276 -0
  171. package/template/scripts/reports-cloudflare-preflight.mjs +152 -0
  172. package/template/scripts/reports-deploy.mjs +173 -0
  173. package/template/scripts/reports-plan.mjs +226 -0
  174. package/template/scripts/reports-restore.mjs +180 -0
  175. package/template/scripts/reports-secrets.mjs +73 -0
  176. package/template/scripts/reports-uninstall-plan.mjs +32 -0
  177. package/template/src/agent-executor.ts +330 -0
  178. package/template/src/ai-observability.ts +163 -0
  179. package/template/src/ai-registry-validation.ts +244 -0
  180. package/template/src/ai-registry.ts +292 -0
  181. package/template/src/api-cursor.ts +84 -0
  182. package/template/src/assistant.ts +554 -0
  183. package/template/src/audit.ts +39 -0
  184. package/template/src/backup-service.ts +269 -0
  185. package/template/src/budget-control.ts +127 -0
  186. package/template/src/component-executor.ts +845 -0
  187. package/template/src/configuration-registry.ts +550 -0
  188. package/template/src/connection-egress.ts +430 -0
  189. package/template/src/connection-oauth.ts +368 -0
  190. package/template/src/evidence-service.ts +335 -0
  191. package/template/src/human-tasks.ts +316 -0
  192. package/template/src/index.ts +4114 -0
  193. package/template/src/installation-admin.ts +301 -0
  194. package/template/src/intake-abuse.ts +156 -0
  195. package/template/src/platform-registry-validation.ts +367 -0
  196. package/template/src/platform-registry.ts +753 -0
  197. package/template/src/report-admin.ts +342 -0
  198. package/template/src/report-ai-quality.ts +390 -0
  199. package/template/src/report-ai-validation.ts +80 -0
  200. package/template/src/report-ai.ts +857 -0
  201. package/template/src/report-auth.ts +292 -0
  202. package/template/src/report-better-auth.ts +583 -0
  203. package/template/src/report-context-schema.ts +132 -0
  204. package/template/src/report-context.ts +126 -0
  205. package/template/src/report-crypto.ts +142 -0
  206. package/template/src/report-delivery.ts +579 -0
  207. package/template/src/report-form-validation.ts +51 -0
  208. package/template/src/report-governance.ts +510 -0
  209. package/template/src/report-http.ts +90 -0
  210. package/template/src/report-operations.ts +685 -0
  211. package/template/src/report-operator-accounts.ts +810 -0
  212. package/template/src/report-presence.ts +398 -0
  213. package/template/src/report-queue-validation.ts +309 -0
  214. package/template/src/report-queues.ts +478 -0
  215. package/template/src/report-repository.ts +972 -0
  216. package/template/src/report-router-agent.ts +328 -0
  217. package/template/src/report-router-validation.ts +81 -0
  218. package/template/src/report-routing.ts +178 -0
  219. package/template/src/report-turnstile.ts +142 -0
  220. package/template/src/report-types.ts +224 -0
  221. package/template/src/report-validation.ts +279 -0
  222. package/template/src/report-workflow-validation.ts +119 -0
  223. package/template/src/report-workflow.ts +886 -0
  224. package/template/src/shadow-quality.ts +278 -0
  225. package/template/src/workflow-actions.ts +782 -0
  226. package/template/src/workflow-compiler.ts +230 -0
  227. package/template/src/workflow-dynamic-runtime.ts +613 -0
  228. package/template/src/workflow-effects.ts +316 -0
  229. package/template/src/workflow-expressions.ts +191 -0
  230. package/template/src/workflow-platform-types.ts +121 -0
  231. package/template/src/workflow-platform-validation.ts +540 -0
  232. package/template/src/workflow-repository.ts +916 -0
  233. package/template/src/workflow-runs.ts +686 -0
  234. package/template/src/workflow-simulator.ts +211 -0
  235. package/template/src/workspace-branding.ts +128 -0
  236. package/template/src/workspace-governance.ts +279 -0
  237. package/template/tsconfig.console.json +23 -0
  238. package/template/tsconfig.json +22 -0
  239. package/template/vite.console.config.ts +20 -0
  240. package/template/worker-configuration.d.ts +65 -0
@@ -0,0 +1,269 @@
1
+ import { canonicalJson } from "./audit";
2
+ import { ApiError } from "./report-http";
3
+ import type { OperatorSession } from "./report-types";
4
+
5
+ export const BACKUP_BUCKETS = ["profile_media", "workflow_artifacts", "evidence", "exports"] as const;
6
+ export type BackupBucketName = typeof BACKUP_BUCKETS[number];
7
+
8
+ interface BackupRow {
9
+ id: string;
10
+ environment: string;
11
+ applicationVersion: string;
12
+ state: string;
13
+ d1ExportReference: string;
14
+ d1Digest: string;
15
+ r2ManifestReference: string | null;
16
+ r2ManifestDigest: string | null;
17
+ createdBy: string;
18
+ createdAt: string;
19
+ completedAt: string | null;
20
+ verifiedAt: string | null;
21
+ }
22
+
23
+ export interface BackupCompletionInput {
24
+ state: "verified" | "failed";
25
+ d1ExportReference?: string;
26
+ d1Digest?: string;
27
+ r2ManifestReference?: string;
28
+ r2ManifestDigest?: string;
29
+ failureCode?: string;
30
+ objectCount?: number;
31
+ objectBytes?: number;
32
+ }
33
+
34
+ function safeText(value: unknown, field: string, minimum: number, maximum: number): string {
35
+ if (typeof value !== "string") throw new ApiError(400, "backup_input_invalid", `${field} is invalid.`);
36
+ const normalized = value.trim();
37
+ if (normalized.length < minimum || normalized.length > maximum || /[\u0000-\u001f\u007f]/u.test(normalized)) {
38
+ throw new ApiError(400, "backup_input_invalid", `${field} is invalid.`);
39
+ }
40
+ return normalized;
41
+ }
42
+
43
+ function backupId(value: unknown): string {
44
+ const result = safeText(value, "backup_id", 36, 36).toLowerCase();
45
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test(result)) {
46
+ throw new ApiError(400, "backup_id_invalid", "backup_id must be a UUID.");
47
+ }
48
+ return result;
49
+ }
50
+
51
+ function digest(value: unknown, field: string): string {
52
+ const result = safeText(value, field, 71, 71).toLowerCase();
53
+ if (!/^sha256:[0-9a-f]{64}$/u.test(result)) throw new ApiError(400, "backup_digest_invalid", `${field} must be a SHA-256 digest.`);
54
+ return result;
55
+ }
56
+
57
+ function reference(value: unknown, field: string): string {
58
+ const result = safeText(value, field, 1, 500);
59
+ if (result.startsWith("/") || result.includes("\\") || result.split("/").includes("..")) {
60
+ throw new ApiError(400, "backup_reference_invalid", `${field} must be a relative archive reference.`);
61
+ }
62
+ return result;
63
+ }
64
+
65
+ export function parseBackupBucket(value: unknown): BackupBucketName {
66
+ if (typeof value === "string" && (BACKUP_BUCKETS as readonly string[]).includes(value)) return value as BackupBucketName;
67
+ throw new ApiError(400, "backup_bucket_invalid", "bucket must name one of the installation's private R2 bindings.");
68
+ }
69
+
70
+ export function validateBackupObjectKey(value: unknown): string {
71
+ if (typeof value !== "string" || value.length < 1 || /[\u0000-\u001f\u007f]/u.test(value)
72
+ || new TextEncoder().encode(value).byteLength > 1_024) {
73
+ throw new ApiError(400, "backup_object_key_invalid", "The R2 object key is invalid or too long.");
74
+ }
75
+ return value;
76
+ }
77
+
78
+ export function parseBackupCompletion(value: Record<string, unknown>): BackupCompletionInput {
79
+ const allowed = new Set(["state", "d1_export_reference", "d1_digest", "r2_manifest_reference", "r2_manifest_digest", "failure_code", "object_count", "object_bytes"]);
80
+ const unknown = Object.keys(value).find((key) => !allowed.has(key));
81
+ if (unknown) throw new ApiError(400, "backup_input_invalid", `Unsupported backup completion field: ${unknown}.`);
82
+ if (value.state === "failed") {
83
+ return { state: "failed", failureCode: safeText(value.failure_code, "failure_code", 3, 100) };
84
+ }
85
+ if (value.state !== "verified") throw new ApiError(400, "backup_state_invalid", "state must be verified or failed.");
86
+ const objectCount = Number(value.object_count);
87
+ const objectBytes = Number(value.object_bytes);
88
+ if (!Number.isSafeInteger(objectCount) || objectCount < 0 || !Number.isSafeInteger(objectBytes) || objectBytes < 0) {
89
+ throw new ApiError(400, "backup_inventory_invalid", "object_count and object_bytes must be non-negative safe integers.");
90
+ }
91
+ return {
92
+ state: "verified",
93
+ d1ExportReference: reference(value.d1_export_reference, "d1_export_reference"),
94
+ d1Digest: digest(value.d1_digest, "d1_digest"),
95
+ r2ManifestReference: reference(value.r2_manifest_reference, "r2_manifest_reference"),
96
+ r2ManifestDigest: digest(value.r2_manifest_digest, "r2_manifest_digest"),
97
+ objectCount,
98
+ objectBytes,
99
+ };
100
+ }
101
+
102
+ export function backupBucket(env: Env, name: BackupBucketName): R2Bucket {
103
+ if (name === "profile_media") return env.PROFILE_MEDIA;
104
+ if (name === "workflow_artifacts") return env.WORKFLOW_ARTIFACTS;
105
+ if (name === "evidence") return env.EVIDENCE_OBJECTS;
106
+ return env.EXPORTS;
107
+ }
108
+
109
+ function audit(
110
+ db: D1Database,
111
+ session: OperatorSession,
112
+ idempotencyKey: string,
113
+ action: string,
114
+ backupIdValue: string,
115
+ details: Record<string, unknown>,
116
+ createdAt: string,
117
+ ): D1PreparedStatement {
118
+ return db.prepare(`
119
+ INSERT OR IGNORE INTO audit_events (
120
+ id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at
121
+ ) VALUES (?1, ?2, ?3, ?4, ?5, 'backup_manifest', ?6, ?7, ?8)
122
+ `).bind(crypto.randomUUID(), idempotencyKey, action, session.actor.type, session.actor.id, backupIdValue, canonicalJson(details), createdAt);
123
+ }
124
+
125
+ async function loadBackup(db: D1Database, id: string): Promise<BackupRow | null> {
126
+ return db.prepare(`
127
+ SELECT id, environment, application_version AS applicationVersion, state,
128
+ d1_export_reference AS d1ExportReference, d1_digest AS d1Digest,
129
+ r2_manifest_reference AS r2ManifestReference, r2_manifest_digest AS r2ManifestDigest,
130
+ created_by AS createdBy, created_at AS createdAt, completed_at AS completedAt,
131
+ verified_at AS verifiedAt
132
+ FROM backup_manifests WHERE id = ?1 AND installation_id = 'default' LIMIT 1
133
+ `).bind(id).first<BackupRow>();
134
+ }
135
+
136
+ export async function startBackup(
137
+ db: D1Database,
138
+ session: OperatorSession,
139
+ input: Record<string, unknown>,
140
+ idempotencyKey: string,
141
+ ): Promise<{ backupId: string; state: string; idempotentReplay: boolean }> {
142
+ const allowed = new Set(["backup_id", "environment", "application_version"]);
143
+ const unknown = Object.keys(input).find((key) => !allowed.has(key));
144
+ if (unknown) throw new ApiError(400, "backup_input_invalid", `Unsupported backup start field: ${unknown}.`);
145
+ const id = backupId(input.backup_id);
146
+ const environment = safeText(input.environment, "environment", 2, 100);
147
+ const applicationVersion = safeText(input.application_version, "application_version", 1, 100);
148
+ const existing = await loadBackup(db, id);
149
+ if (existing) {
150
+ if (existing.environment !== environment || existing.applicationVersion !== applicationVersion || existing.createdBy !== session.actor.id) {
151
+ throw new ApiError(409, "backup_id_reused", "The backup identifier already belongs to another backup request.");
152
+ }
153
+ return { backupId: id, state: existing.state, idempotentReplay: true };
154
+ }
155
+ const timestamp = new Date().toISOString();
156
+ await db.batch([
157
+ db.prepare(`
158
+ INSERT INTO backup_manifests (
159
+ id, installation_id, environment, schema_version, application_version,
160
+ d1_export_reference, d1_digest, state, created_by, created_at
161
+ ) VALUES (?1, 'default', ?2, 1, ?3, ?4, 'pending', 'running', ?5, ?6)
162
+ `).bind(id, environment, applicationVersion, `pending/${id}/database.sql`, session.actor.id, timestamp),
163
+ audit(db, session, `backup-start:${idempotencyKey}`, "backup.started", id, { environment, applicationVersion }, timestamp),
164
+ ]);
165
+ return { backupId: id, state: "running", idempotentReplay: false };
166
+ }
167
+
168
+ export async function assertBackupTransferAllowed(db: D1Database, idValue: string): Promise<string> {
169
+ const id = backupId(idValue);
170
+ const row = await loadBackup(db, id);
171
+ if (!row) throw new ApiError(404, "backup_not_found", "The backup does not exist.");
172
+ if (row.state !== "running") throw new ApiError(409, "backup_not_running", "R2 backup transfer is only available while the backup is running.");
173
+ return id;
174
+ }
175
+
176
+ export async function listBackupObjects(
177
+ bucket: R2Bucket,
178
+ cursor: string | undefined,
179
+ limit: number,
180
+ ): Promise<{ objects: Array<{ key: string; size: number; etag: string; version: string; uploadedAt: string }>; nextCursor: string | null }> {
181
+ const page = await bucket.list({ ...(cursor ? { cursor } : {}), limit });
182
+ return {
183
+ objects: page.objects.map((object) => ({
184
+ key: object.key,
185
+ size: object.size,
186
+ etag: object.etag,
187
+ version: object.version,
188
+ uploadedAt: object.uploaded.toISOString(),
189
+ })),
190
+ nextCursor: page.truncated ? page.cursor : null,
191
+ };
192
+ }
193
+
194
+ export async function readBackupObject(bucket: R2Bucket, keyValue: unknown, etagValue: unknown): Promise<R2ObjectBody> {
195
+ const key = validateBackupObjectKey(keyValue);
196
+ const etag = safeText(etagValue, "etag", 1, 200);
197
+ const object = await bucket.get(key, { onlyIf: { etagMatches: etag } });
198
+ if (!object) throw new ApiError(404, "backup_object_not_found", "The R2 object no longer exists.");
199
+ if (!("body" in object)) throw new ApiError(409, "backup_object_changed", "The R2 object changed after inventory and the backup must be retried.");
200
+ return object;
201
+ }
202
+
203
+ export async function finalizeBackup(
204
+ db: D1Database,
205
+ session: OperatorSession,
206
+ idValue: string,
207
+ input: BackupCompletionInput,
208
+ idempotencyKey: string,
209
+ ): Promise<{ backupId: string; state: string; idempotentReplay: boolean }> {
210
+ const id = backupId(idValue);
211
+ const existing = await loadBackup(db, id);
212
+ if (!existing) throw new ApiError(404, "backup_not_found", "The backup does not exist.");
213
+ if (existing.createdBy !== session.actor.id) throw new ApiError(403, "backup_owner_denied", "Only the deployment owner that started the backup can finalize it.");
214
+ if (existing.state !== "running") {
215
+ const sameVerified = input.state === "verified" && existing.state === "verified"
216
+ && existing.d1Digest === input.d1Digest && existing.r2ManifestDigest === input.r2ManifestDigest;
217
+ const sameFailed = input.state === "failed" && existing.state === "failed";
218
+ if (sameVerified || sameFailed) return { backupId: id, state: existing.state, idempotentReplay: true };
219
+ throw new ApiError(409, "backup_state_conflict", "The backup is already finalized with different results.");
220
+ }
221
+ const timestamp = new Date().toISOString();
222
+ if (input.state === "failed") {
223
+ await db.batch([
224
+ db.prepare(`UPDATE backup_manifests SET state = 'failed', completed_at = ?2 WHERE id = ?1 AND state = 'running'`).bind(id, timestamp),
225
+ audit(db, session, `backup-failed:${idempotencyKey}`, "backup.failed", id, { failureCode: input.failureCode }, timestamp),
226
+ ]);
227
+ return { backupId: id, state: "failed", idempotentReplay: false };
228
+ }
229
+ await db.batch([
230
+ db.prepare(`
231
+ UPDATE backup_manifests SET d1_export_reference = ?2, d1_digest = ?3,
232
+ r2_manifest_reference = ?4, r2_manifest_digest = ?5, state = 'verified',
233
+ completed_at = ?6, verified_at = ?6
234
+ WHERE id = ?1 AND state = 'running'
235
+ `).bind(id, input.d1ExportReference, input.d1Digest, input.r2ManifestReference, input.r2ManifestDigest, timestamp),
236
+ audit(db, session, `backup-verified:${idempotencyKey}`, "backup.verified", id, {
237
+ d1Digest: input.d1Digest,
238
+ r2ManifestDigest: input.r2ManifestDigest,
239
+ objectCount: input.objectCount,
240
+ objectBytes: input.objectBytes,
241
+ }, timestamp),
242
+ ]);
243
+ return { backupId: id, state: "verified", idempotentReplay: false };
244
+ }
245
+
246
+ export async function listBackups(
247
+ db: D1Database,
248
+ limit: number,
249
+ cursor?: { createdAt: string; id: string },
250
+ ): Promise<{ backups: Record<string, unknown>[]; nextCursor: { createdAt: string; id: string } | null }> {
251
+ const rows = await db.prepare(`
252
+ SELECT id, environment, schema_version AS schemaVersion, application_version AS applicationVersion,
253
+ d1_digest AS d1Digest, r2_manifest_digest AS r2ManifestDigest, state,
254
+ created_by AS createdBy, created_at AS createdAt, completed_at AS completedAt,
255
+ verified_at AS verifiedAt
256
+ FROM backup_manifests WHERE installation_id = 'default'
257
+ AND (?1 IS NULL OR created_at < ?1 OR (created_at = ?1 AND id < ?2))
258
+ ORDER BY created_at DESC, id DESC LIMIT ?3
259
+ `).bind(cursor?.createdAt ?? null, cursor?.id ?? null, limit + 1).all<Record<string, unknown>>();
260
+ const hasMore = rows.results.length > limit;
261
+ const backups = rows.results.slice(0, limit);
262
+ const last = hasMore ? backups.at(-1) : undefined;
263
+ return {
264
+ backups,
265
+ nextCursor: last && typeof last.createdAt === "string" && typeof last.id === "string"
266
+ ? { createdAt: last.createdAt, id: last.id }
267
+ : null,
268
+ };
269
+ }
@@ -0,0 +1,127 @@
1
+ export interface BudgetScope { type: string; id: string }
2
+
3
+ export interface BudgetReservationHandle {
4
+ id: string;
5
+ policyId: string;
6
+ windowStart: string;
7
+ amount: number;
8
+ }
9
+
10
+ export class BudgetControlError extends Error {
11
+ constructor(
12
+ readonly code: string,
13
+ readonly retryable: boolean,
14
+ readonly delaySeconds: number,
15
+ readonly fallbackReference: string | null,
16
+ ) { super(code); this.name = "BudgetControlError"; }
17
+ }
18
+
19
+ function now(): string { return new Date().toISOString(); }
20
+
21
+ export async function reserveBudgets(
22
+ db: D1Database,
23
+ input: {
24
+ installationId: string;
25
+ scopes: BudgetScope[];
26
+ metric: string;
27
+ amount: number;
28
+ operationType: string;
29
+ operationId: string;
30
+ attemptId: string;
31
+ },
32
+ ): Promise<BudgetReservationHandle[]> {
33
+ if (!Number.isInteger(input.amount) || input.amount < 0) throw new BudgetControlError("budget_amount_invalid", false, 0, null);
34
+ const policies = await db.prepare(`
35
+ SELECT id, scope_type AS scopeType, scope_id AS scopeId,
36
+ limit_value AS limitValue, window_seconds AS windowSeconds,
37
+ behavior, fallback_reference AS fallbackReference
38
+ FROM budget_policies WHERE installation_id = ?1 AND metric = ?2 AND retired_at IS NULL
39
+ ORDER BY published_at DESC, id
40
+ `).bind(input.installationId, input.metric).all<{
41
+ id: string; scopeType: string; scopeId: string; limitValue: number;
42
+ windowSeconds: number; behavior: string; fallbackReference: string | null;
43
+ }>();
44
+ const allowed = new Set([
45
+ `installation:${input.installationId}`,
46
+ ...input.scopes.map((scope) => `${scope.type}:${scope.id}`),
47
+ ]);
48
+ const applicable = policies.results.filter((policy) => allowed.has(`${policy.scopeType}:${policy.scopeId}`));
49
+ const reservations: BudgetReservationHandle[] = [];
50
+ try {
51
+ for (const policy of applicable) {
52
+ const epoch = Math.floor(Date.now() / 1_000);
53
+ const windowEpoch = Math.floor(epoch / policy.windowSeconds) * policy.windowSeconds;
54
+ const windowStart = new Date(windowEpoch * 1_000).toISOString();
55
+ const reservationId = `${policy.id}:${input.operationId}:${input.attemptId}`;
56
+ const existing = await db.prepare(`SELECT status, reserved_value AS amount FROM budget_reservations WHERE id = ?1 LIMIT 1`)
57
+ .bind(reservationId).first<{ status: string; amount: number }>();
58
+ if (existing?.status === "reserved" || existing?.status === "committed") {
59
+ reservations.push({ id: reservationId, policyId: policy.id, windowStart, amount: existing.amount });
60
+ continue;
61
+ }
62
+ const timestamp = now();
63
+ await db.prepare(`INSERT INTO budget_usage (policy_id, window_start, reserved_value, consumed_value, revision, updated_at) VALUES (?1, ?2, 0, 0, 1, ?3) ON CONFLICT(policy_id, window_start) DO NOTHING`)
64
+ .bind(policy.id, windowStart, timestamp).run();
65
+ const changed = await db.prepare(`
66
+ UPDATE budget_usage SET reserved_value = reserved_value + ?3,
67
+ revision = revision + 1, updated_at = ?4
68
+ WHERE policy_id = ?1 AND window_start = ?2
69
+ AND reserved_value + consumed_value + ?3 <= ?5
70
+ `).bind(policy.id, windowStart, input.amount, timestamp, policy.limitValue).run();
71
+ if (changed.meta.changes !== 1) {
72
+ const retryable = policy.behavior === "defer";
73
+ throw new BudgetControlError(
74
+ policy.behavior === "fallback" ? "budget_fallback_required" : "budget_exhausted",
75
+ retryable,
76
+ retryable ? Math.max(1, policy.windowSeconds) : 0,
77
+ policy.fallbackReference,
78
+ );
79
+ }
80
+ await db.prepare(`
81
+ INSERT INTO budget_reservations (
82
+ id, policy_id, window_start, operation_type, operation_id,
83
+ attempt_id, reserved_value, status, expires_at, created_at, updated_at
84
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'reserved', ?8, ?9, ?9)
85
+ `).bind(
86
+ reservationId, policy.id, windowStart, input.operationType,
87
+ input.operationId, input.attemptId, input.amount,
88
+ new Date(Date.now() + 15 * 60_000).toISOString(), timestamp,
89
+ ).run();
90
+ reservations.push({ id: reservationId, policyId: policy.id, windowStart, amount: input.amount });
91
+ }
92
+ return reservations;
93
+ } catch (error) {
94
+ await settleBudgets(db, reservations, false);
95
+ throw error;
96
+ }
97
+ }
98
+
99
+ export async function settleBudgets(
100
+ db: D1Database,
101
+ reservations: BudgetReservationHandle[],
102
+ commit: boolean,
103
+ actualAmount?: number,
104
+ ): Promise<void> {
105
+ const timestamp = now();
106
+ for (const reservation of reservations) {
107
+ const consumed = commit ? Math.max(0, Math.floor(actualAmount ?? reservation.amount)) : 0;
108
+ const changed = await db.prepare(`
109
+ UPDATE budget_reservations SET status = ?2, committed_value = ?3, updated_at = ?4
110
+ WHERE id = ?1 AND status = 'reserved'
111
+ `).bind(reservation.id, commit ? "committed" : "released", commit ? consumed : null, timestamp).run();
112
+ if (changed.meta.changes !== 1) continue;
113
+ await db.prepare(`
114
+ UPDATE budget_usage SET reserved_value = MAX(0, reserved_value - ?3),
115
+ consumed_value = consumed_value + ?4, revision = revision + 1,
116
+ updated_at = ?5 WHERE policy_id = ?1 AND window_start = ?2
117
+ `).bind(reservation.policyId, reservation.windowStart, reservation.amount, consumed, timestamp).run();
118
+ }
119
+ }
120
+
121
+ export async function releaseExpiredBudgetReservations(db: D1Database, limit = 200): Promise<number> {
122
+ const expired = await db.prepare(`SELECT id, policy_id AS policyId, window_start AS windowStart, reserved_value AS amount FROM budget_reservations WHERE status = 'reserved' AND expires_at <= ?1 ORDER BY expires_at, id LIMIT ?2`)
123
+ .bind(now(), Math.max(1, Math.min(1_000, limit))).all<{ id: string; policyId: string; windowStart: string; amount: number }>();
124
+ await settleBudgets(db, expired.results, false);
125
+ return expired.results.length;
126
+ }
127
+